mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97ec5d115c | ||
|
|
722a6cf50f | ||
|
|
d039dcfee5 | ||
|
|
d09aa11f2e | ||
|
|
8a2264546d | ||
|
|
0801e1dc44 | ||
|
|
a04d5fa5e0 | ||
|
|
e1280e8cc7 | ||
|
|
70884c2e30 | ||
|
|
917e95e3d7 | ||
|
|
27222bbb0d | ||
|
|
3720034464 | ||
|
|
de6cab1f9a | ||
|
|
2d1fbc2611 | ||
|
|
0afc70218f | ||
|
|
8d3abbc5f0 | ||
|
|
7ec14bc7db | ||
|
|
2843010a97 | ||
|
|
ad4e21ee61 | ||
|
|
4de39ab6f2 | ||
|
|
fbb646b8eb | ||
|
|
46f75ecd6f | ||
|
|
f68caa3bbd | ||
|
|
0109bf5b72 | ||
|
|
c69fb52d5c | ||
|
|
3a26f1f1c2 | ||
|
|
f1b94a5b69 | ||
|
|
e3fce9191a | ||
|
|
2c0a1b9bbb | ||
|
|
0d7417f858 | ||
|
|
2eee098c66 | ||
|
|
890d4b3c16 | ||
|
|
af9a7c6345 | ||
|
|
6fff1fd75f | ||
|
|
e6b7b08bda | ||
|
|
f4c3cbd53d | ||
|
|
c0c2f35266 | ||
|
|
ccea980753 | ||
|
|
2bddebd576 | ||
|
|
b9866ddbeb | ||
|
|
6fc79a7184 | ||
|
|
50039571b6 | ||
|
|
1216f20567 | ||
|
|
0752ea761c | ||
|
|
98461088f8 | ||
|
|
d4a3224aa3 | ||
|
|
197ce6faff | ||
|
|
c98e293879 | ||
|
|
ebbe382596 | ||
|
|
01d28eb2c5 | ||
|
|
7d1c2e7f0a | ||
|
|
22b499af20 | ||
|
|
7208f86723 | ||
|
|
2974a8b01c | ||
|
|
7f05a707bd | ||
|
|
1d056253ef | ||
|
|
a22dc5facb | ||
|
|
aeafd4ac79 | ||
|
|
14861df6b2 | ||
|
|
34a7595a6d | ||
|
|
b2108c0216 | ||
|
|
5a5a449210 | ||
|
|
0405de30bd | ||
|
|
40c2799b06 | ||
|
|
4bcdeefe68 | ||
|
|
b6ba6dd90e | ||
|
|
954f8b7b8d | ||
|
|
24d700673c | ||
|
|
c6d64029e0 | ||
|
|
40ff866598 | ||
|
|
36888e35d3 | ||
|
|
30432b931a | ||
|
|
e2174a14cf | ||
|
|
d3cb89d8c9 | ||
|
|
c8625b3396 | ||
|
|
b576f01d12 | ||
|
|
a87cafee3c | ||
|
|
1c51fde163 | ||
|
|
b05699db77 | ||
|
|
163e48ab27 | ||
|
|
0b41d992a4 | ||
|
|
c2ddfd6867 | ||
|
|
81b9dc1f06 | ||
|
|
5506ddb2c2 | ||
|
|
c74469b01a | ||
|
|
b706693f82 | ||
|
|
cd800148d5 | ||
|
|
900b0d7aa6 | ||
|
|
7e21c1f888 | ||
|
|
00d6f9c230 | ||
|
|
4c03080efa | ||
|
|
432a11d289 | ||
|
|
e9e233404b | ||
|
|
8101d42604 | ||
|
|
88cedd43bc | ||
|
|
d3a2934d9b | ||
|
|
13a36b6199 | ||
|
|
a155464d46 |
No files matched your search
@@ -64,9 +64,9 @@ Keep comments light. Prefer self-documenting code — clear names, small functio
|
||||
- The _why_ is non-obvious (a hidden constraint, a workaround, a subtle invariant).
|
||||
- A non-trivial usage detail would otherwise trip the next reader.
|
||||
|
||||
Don't restate what the code already says. Don't write comments that reference the current task or PR — those rot.
|
||||
Don't restate what the code already says. Don't write comments that reference the current task or PR or version of something — those rot.
|
||||
|
||||
**No ticket references in code or test comments.** Don't write `PUT-1234`, `AUTH-3`, `GUI-2`, `// fix for FOO-99`, `// per ROLLOUT-1`, or section headers like `// -- PUT-1020: ... --`. The ticket exists in git history and the issue tracker; the code should describe the *why* in domain terms ("logout cascade transparently invalidates...") not project-management terms ("the cascade from AUTH-3..."). Same rule for test-block names and section dividers — describe what's being tested, not which ticket asked for it.
|
||||
**No ticket references in code or test comments.** Don't write `PUT-1234`, `AUTH-3`, `GUI-2`, `// fix for FOO-99`, `// per ROLLOUT-1`, or section headers like `// -- PUT-1020: ... --`. The ticket exists in git history and the issue tracker; the code should describe the _why_ in domain terms ("logout cascade transparently invalidates...") not project-management terms ("the cascade from AUTH-3..."). Same rule for test-block names and section dividers — describe what's being tested, not which ticket asked for it.
|
||||
|
||||
**Use plain ASCII `-` in comment section dividers, not box-drawing characters.** Write `// -- Section name --`, not `// ── Section name ──`. The `─` (U+2500) character renders inconsistently in some editors and diff tools and adds nothing over `-`.
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"default_user_group": "78b1b1dd-c959-44d2-b02c-8735671f9997",
|
||||
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
|
||||
"storage_capacity": 104857600,
|
||||
"disable_user_signup": false,
|
||||
"strict_email_verification_required": false,
|
||||
"gui_assets_root": "./src/gui",
|
||||
"puterjs_root": "./src/puter-js/dist",
|
||||
|
||||
@@ -75,6 +75,9 @@
|
||||
"url_signature_secret": "change-me",
|
||||
"cookie_name": "puter_auth_token",
|
||||
"min_pass_length": 6,
|
||||
// When true, anonymous users must log in instead of creating temp or
|
||||
// permanent accounts.
|
||||
"disable_user_signup": false,
|
||||
"allow_system_login": false,
|
||||
"strict_email_verification_required": false,
|
||||
"captcha": {
|
||||
|
||||
@@ -104,7 +104,7 @@ Puterは[**puter.com**](https://puter.com)でホストサービスとして利
|
||||
|
||||
メンテナーやコミュニティと以下のチャンネルを通じてつながりましょう:
|
||||
|
||||
- バグ報告や機能リクエストがありますか? [issueを開く](https://github.com/HeyPuter/puter/issues/new/choose) してください。
|
||||
- バグ報告や機能リクエストは [新規Issue](https://github.com/HeyPuter/puter/issues/new/choose) まで
|
||||
- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u)
|
||||
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
|
||||
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
|
||||
|
||||
+36
-1
@@ -135,7 +135,7 @@ Replace `puter.localhost`, `site.puter.localhost`, `host.puter.localhost`, `dev.
|
||||
Why these knobs:
|
||||
|
||||
- `jwt_secret` + `jwt_secret_v2` — Puter signs new auth tokens with `jwt_secret_v2` (v2 token format, `kid: 'v2'` JWT header). `jwt_secret` is verify-only and lets tokens minted before this version (cookies still in users' browsers, stored API tokens) keep working. `allow_v1_tokens: true` keeps that fallback enabled. Fresh installs can leave it as-is; future versions will flip the flag to `false` and retire `jwt_secret` entirely once legacy tokens have drained.
|
||||
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
|
||||
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start:gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
|
||||
- `database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
|
||||
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
|
||||
- `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
|
||||
@@ -199,6 +199,32 @@ Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
|
||||
"s3": { "s3Config": { "publicEndpoint": "https://s3.puter.local", ... } }
|
||||
```
|
||||
|
||||
## Running behind your own reverse proxy
|
||||
|
||||
The bundled `puter-nginx` mirrors production and is the supported default — it terminates TLS (Step 3) and forwards every Host to Puter. But plenty of self-hosters already run their own edge proxy (Caddy, Traefik, HAProxy, a cloud load balancer, another nginx). You can put Puter behind it instead; Puter doesn't terminate TLS itself in any setup, so "your proxy does TLS" is just a matter of pointing it at the Puter container and getting a handful of details right.
|
||||
|
||||
You can keep the bundled nginx as the single hop your proxy talks to, or bypass it and forward straight to the Puter container on port `4100` (uncomment the `4100:4100` mapping under the `puter` service in [docker-compose.yml](../docker-compose.yml), or attach your proxy to the compose network). Either works — what matters is the rules below.
|
||||
|
||||
**The rules that actually matter** (getting any of these wrong is what causes the redirect loops and "Invalid Host header" failures people hit):
|
||||
|
||||
1. **Don't rewrite the `Host` header.** Puter routes entirely on Host — `api.<domain>`, `site.<domain>`, `app.<domain>` are all distinguished by the incoming host. Forward the original host through unchanged. Do **not** rewrite external hostnames to an internal name like `puter.local`; that forces you to remap every subdomain by hand and still breaks signed-URL and CORS checks. Most proxies preserve Host by default — just don't override it.
|
||||
|
||||
2. **The external domain must equal `domain` in your `config.json`.** If users reach the box at `puter.example.com`, then `domain` must be `puter.example.com` and the hosting domains must be its real subdomains (`site.puter.example.com`, `app.puter.example.com`, …) — exactly as the installer/`config.json` lays them out. Puter rejects hosts it doesn't recognize with `Invalid Host header`.
|
||||
|
||||
3. **Set `protocol` to match the public scheme.** When your proxy terminates TLS, set `"protocol": "https"` (installer: `PUTER_PROTOCOL=https`). Puter builds its origins, redirects, signed S3 URLs, and OIDC callback URLs from this — leave it `http` behind an HTTPS proxy and you get redirect loops, broken logins, and mixed-content errors.
|
||||
|
||||
4. **Forward the standard proxy headers.** Puter needs:
|
||||
- `Host` — the original request host (rule 1).
|
||||
- `X-Forwarded-Proto` — the **external** scheme (`https`), so Puter knows TLS was terminated upstream.
|
||||
- `X-Forwarded-For` — the real client IP (rate limiting + audit logs).
|
||||
- `Upgrade` / `Connection` — passed through for WebSocket / socket.io upgrades, or the realtime connection silently fails.
|
||||
|
||||
5. **Set `trust_proxy` to the number of hops in front of Puter.** One external proxy talking directly to Puter → `"trust_proxy": 1` (installer: `PUTER_TRUST_PROXY=1`). A second proxy in front (e.g. Cloudflare → your proxy → Puter) → `2`. This is the count of proxies between the client and Puter, **including** the bundled nginx if you keep it. Too low and `req.ip` becomes a proxy address (breaks rate limiting); never set `true` (it trusts every hop and makes `X-Forwarded-For` forgeable).
|
||||
|
||||
6. **Route the wildcard to Puter.** Your proxy must forward `*.<domain>` (covering `api.*`, `app.*`, and `s3.<domain>`) and `*.site.<domain>` (and any other hosting domains) to Puter — same wildcard DNS as Step 2. Puter does the per-subdomain routing internally; the proxy just needs to hand it the traffic with the Host intact.
|
||||
|
||||
With those in place the rest of the stack is unchanged — `docker compose up -d` as below.
|
||||
|
||||
## Step 4 — Bring it up
|
||||
|
||||
```bash
|
||||
@@ -353,6 +379,15 @@ Built-in proof-of-work captcha — no external service needed.
|
||||
|
||||
`difficulty` is one of `easy` / `medium` / `hard`.
|
||||
|
||||
### Disable new signups
|
||||
|
||||
Force visitors to log in with an existing account instead of creating a
|
||||
temporary or permanent one.
|
||||
|
||||
```json
|
||||
"disable_user_signup": true
|
||||
```
|
||||
|
||||
### Block disposable email TLDs
|
||||
|
||||
Only enforced when `env: "prod"`.
|
||||
|
||||
+1
-1
@@ -264,7 +264,7 @@ services:
|
||||
# Persistent runtime data (anything your config points at /var/puter).
|
||||
- ./puter/data/puter:/var/puter:z
|
||||
healthcheck:
|
||||
test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
|
||||
test: wget --no-verbose --tries=1 --spider http://localhost:4100/ || exit 1
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
|
||||
+59
-141
@@ -1,75 +1,66 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { runWithContext } from '../src/backend/core/context.ts';
|
||||
import { PuterServer } from '../src/backend/server.ts';
|
||||
import { setupTestServer } from '../src/backend/testUtil.ts';
|
||||
import {
|
||||
handleAppTelemetryUserCount,
|
||||
handleAppTelemetryUsers,
|
||||
} from './appTelemetry.ts';
|
||||
|
||||
interface CapturedResponse {
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
const makeReq = (query: Record<string, unknown> = {}): Request =>
|
||||
({ query }) as unknown as Request;
|
||||
|
||||
const makeRes = () => {
|
||||
const captured: CapturedResponse = { body: undefined };
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
captured.body = value;
|
||||
return res;
|
||||
}),
|
||||
status: vi.fn(() => res),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
};
|
||||
|
||||
// `handleAppTelemetryUsers` calls `Context.get('actor')` for the permission
|
||||
// gate. Wrap test calls in `runWithContext` so the ALS lookup resolves.
|
||||
const callWithActor = async (
|
||||
actor: { user: { uuid: string; id?: number }; app?: null } | undefined,
|
||||
fn: () => Promise<void>,
|
||||
) => runWithContext({ actor }, fn);
|
||||
// Importing the module registers the `appTelemetry` driver into the shared
|
||||
// extensionStore, so `setupTestServer` instantiates it on `server.drivers`.
|
||||
import { AppTelemetryDriver } from './appTelemetry.ts';
|
||||
|
||||
let server: PuterServer;
|
||||
let driver: AppTelemetryDriver;
|
||||
|
||||
// `get_users` reads `Context.get('actor')` for the ownership gate. Wrap
|
||||
// actor-dependent calls in `runWithContext` so the ALS lookup resolves.
|
||||
const callWithActor = <T>(
|
||||
actor: { user: { uuid: string; id?: number } } | undefined,
|
||||
fn: () => Promise<T>,
|
||||
) => runWithContext({ actor }, fn);
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
driver = (server.drivers as unknown as Record<string, AppTelemetryDriver>)
|
||||
.appTelemetry;
|
||||
// Guard: the driver must have been wired onto the server. If this is
|
||||
// undefined the extension didn't register, and every test below would
|
||||
// fail with a confusing "cannot read property of undefined".
|
||||
expect(driver).toBeInstanceOf(AppTelemetryDriver);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
const seedOwnedApp = async (prefix: string) => {
|
||||
const owner = await server.stores.user.create({
|
||||
username: `${prefix}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `${prefix}_${slug}`,
|
||||
title: `${prefix} ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
return { owner, app: app! };
|
||||
};
|
||||
|
||||
describe('appTelemetry driver — get_users', () => {
|
||||
it('throws HttpError(400) when app_uuid is missing', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(makeReq({}), res),
|
||||
).rejects.toMatchObject({
|
||||
await expect(driver.get_users({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('app_uuid'),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws HttpError(400) for a non-numeric limit', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: 'app-anything', limit: 'banana' }),
|
||||
res,
|
||||
),
|
||||
driver.get_users({ app_uuid: 'app-anything', limit: 'banana' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('limit'),
|
||||
@@ -77,22 +68,14 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
});
|
||||
|
||||
it('throws HttpError(400) when offset exceeds the allowed maximum', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: 'app-anything', offset: 1_000_001 }),
|
||||
res,
|
||||
),
|
||||
driver.get_users({ app_uuid: 'app-anything', offset: 1_000_001 }),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws HttpError(404) when the app cannot be found', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: 'app-does-not-exist' }),
|
||||
res,
|
||||
),
|
||||
driver.get_users({ app_uuid: 'app-does-not-exist' }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 404,
|
||||
message: 'App not found',
|
||||
@@ -100,25 +83,10 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
});
|
||||
|
||||
it('throws HttpError(403) when the caller does not own the app', async () => {
|
||||
// Seed an owner user + an app belonging to them, then call the
|
||||
// handler as a *different* user. The real permission service should
|
||||
// reject since the caller has no `apps-of-user:<owner>:write` perm.
|
||||
const owner = await server.stores.user.create({
|
||||
username: `owner_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `app_${slug}`,
|
||||
title: `App ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
|
||||
// Seed an owner + their app, then call as a *different* user. The
|
||||
// real permission service should reject: the caller has no
|
||||
// `apps-of-user:<owner>:write`.
|
||||
const { app } = await seedOwnedApp('owner');
|
||||
const intruder = await server.stores.user.create({
|
||||
username: `intruder_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
@@ -126,15 +94,11 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
email: null,
|
||||
});
|
||||
|
||||
const { res } = makeRes();
|
||||
await callWithActor(
|
||||
{ user: { uuid: intruder.uuid, id: intruder.id as number } },
|
||||
async () => {
|
||||
await expect(
|
||||
handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: app!.uid }),
|
||||
res,
|
||||
),
|
||||
driver.get_users({ app_uuid: app.uid }),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
message: 'Permission denied',
|
||||
@@ -144,78 +108,32 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
|
||||
});
|
||||
|
||||
it('returns an empty list for an owned app with no authenticated users', async () => {
|
||||
const owner = await server.stores.user.create({
|
||||
username: `owner2_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `app2_${slug}`,
|
||||
title: `App2 ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
const { owner, app } = await seedOwnedApp('owner2');
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callWithActor(
|
||||
const result = await callWithActor(
|
||||
{ user: { uuid: owner.uuid, id: owner.id as number } },
|
||||
async () => {
|
||||
await handleAppTelemetryUsers(
|
||||
makeReq({ app_uuid: app!.uid }),
|
||||
res,
|
||||
);
|
||||
},
|
||||
() => driver.get_users({ app_uuid: app.uid }),
|
||||
);
|
||||
|
||||
expect(captured.body).toEqual([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appTelemetry extension — handleAppTelemetryUserCount', () => {
|
||||
describe('appTelemetry driver — user_count', () => {
|
||||
it('throws HttpError(400) when app_uuid is missing', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUserCount(makeReq({}), res),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
await expect(driver.user_count({})).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws HttpError(404) for an unknown app_uuid', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
handleAppTelemetryUserCount(
|
||||
makeReq({ app_uuid: 'app-not-here' }),
|
||||
res,
|
||||
),
|
||||
driver.user_count({ app_uuid: 'app-not-here' }),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('returns { count: 0 } for an app with no authenticated users', async () => {
|
||||
const owner = await server.stores.user.create({
|
||||
username: `counto_${Math.random().toString(36).slice(2, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: 'x',
|
||||
email: null,
|
||||
});
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `appcount_${slug}`,
|
||||
title: `AppCount ${slug}`,
|
||||
index_url: `https://example.com/${slug}`,
|
||||
},
|
||||
{ ownerUserId: owner.id as number },
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await handleAppTelemetryUserCount(
|
||||
makeReq({ app_uuid: app!.uid }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.body).toEqual({ count: 0 });
|
||||
it('returns 0 for an app with no authenticated users', async () => {
|
||||
const { app } = await seedOwnedApp('appcount');
|
||||
await expect(driver.user_count({ app_uuid: app.uid })).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
+88
-80
@@ -1,11 +1,16 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import type { Actor } from '@heyputer/backend/src/core/actor';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import { PuterDriver } from '@heyputer/backend/src/drivers/types';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
|
||||
const clients = extension.import('client');
|
||||
const stores = extension.import('store');
|
||||
const services = extension.import('service');
|
||||
// App-telemetry lets an app owner enumerate the users who have
|
||||
// authenticated into their app. v1 shipped this as a driver on the
|
||||
// `app-telemetry` interface (methods `get_users` / `user_count`) and
|
||||
// puter-js's `puter.apps(...).getUsers()` still calls it that way
|
||||
// (`puter.drivers.call('app-telemetry', 'app-telemetry', 'get_users', …)`).
|
||||
// This is the v2 port of that driver — same interface/method/return shapes
|
||||
// so existing puter-js callers work unchanged.
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 1000;
|
||||
@@ -41,91 +46,94 @@ const parseIntParam = (
|
||||
return parsed;
|
||||
};
|
||||
|
||||
export const handleAppTelemetryUsers = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
/**
|
||||
* Driver exposing the `app-telemetry` interface.
|
||||
*
|
||||
* The `/drivers/call` permission gate checks
|
||||
* `service:app-telemetry:ii:app-telemetry`, which every actor already holds
|
||||
* via the blanket `service` grant (hardcoded-permissions.js +
|
||||
* `default_implicit_user_app_permissions`). The real authorization — "is the
|
||||
* caller the app owner?" — is enforced inside `get_users` below, exactly as
|
||||
* v1 did.
|
||||
*/
|
||||
export class AppTelemetryDriver extends PuterDriver {
|
||||
readonly driverInterface = 'app-telemetry';
|
||||
readonly driverName = 'app-telemetry';
|
||||
readonly isDefault = true;
|
||||
|
||||
const safeLimit = parseIntParam(req.query.limit, {
|
||||
key: 'limit',
|
||||
min: 1,
|
||||
max: MAX_LIMIT,
|
||||
fallback: DEFAULT_LIMIT,
|
||||
});
|
||||
const safeOffset = parseIntParam(req.query.offset, {
|
||||
key: 'offset',
|
||||
min: 0,
|
||||
max: MAX_OFFSET,
|
||||
fallback: 0,
|
||||
});
|
||||
/** Users who have authenticated into the given app (owner-only). */
|
||||
async get_users({
|
||||
app_uuid,
|
||||
limit,
|
||||
offset,
|
||||
}: {
|
||||
app_uuid?: string;
|
||||
limit?: unknown;
|
||||
offset?: unknown;
|
||||
} = {}): Promise<Array<{ user: string; user_uuid: string }>> {
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
|
||||
const app = await stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
const safeLimit = parseIntParam(limit, {
|
||||
key: 'limit',
|
||||
min: 1,
|
||||
max: MAX_LIMIT,
|
||||
fallback: DEFAULT_LIMIT,
|
||||
});
|
||||
const safeOffset = parseIntParam(offset, {
|
||||
key: 'offset',
|
||||
min: 0,
|
||||
max: MAX_OFFSET,
|
||||
fallback: 0,
|
||||
});
|
||||
|
||||
// `apps-of-user:<uuid>:write` — the implicator keys on the owner's
|
||||
// UUID, not the numeric id. Look up the owner explicitly. v1 got
|
||||
// this for free because its entity-storage layer eager-joined the
|
||||
// owner row; v2's AppStore.getByUid returns the raw row with only
|
||||
// `owner_user_id` populated.
|
||||
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
|
||||
if (!ownerId) throw new HttpError(404, 'App owner not found');
|
||||
const owner = (await stores.user.getById(ownerId)) as {
|
||||
uuid?: string;
|
||||
} | null;
|
||||
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
|
||||
const app = await this.stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
|
||||
const actor = Context.get('actor');
|
||||
const ownsApp = await services.permission
|
||||
.check(actor!, `apps-of-user:${owner.uuid}:write`)
|
||||
.catch(() => false);
|
||||
if (!ownsApp) throw new HttpError(403, 'Permission denied');
|
||||
// The `apps-of-user:<uuid>:write` implicator keys on the owner's
|
||||
// UUID, not the numeric id. Look up the owner explicitly — the raw
|
||||
// app row only carries `owner_user_id`. (v1 got the owner for free
|
||||
// because its entity-storage layer eager-joined the owner row.)
|
||||
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
|
||||
if (!ownerId) throw new HttpError(404, 'App owner not found');
|
||||
const owner = await this.stores.user.getById(ownerId);
|
||||
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
|
||||
|
||||
const users = await clients.db.read(
|
||||
`SELECT u.username, u.uuid FROM user_to_app_permissions p
|
||||
INNER JOIN ${clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
|
||||
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
|
||||
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
|
||||
LIMIT ? OFFSET ?`,
|
||||
[(app as Record<string, unknown>).id, safeLimit, safeOffset],
|
||||
);
|
||||
const actor = Context.get('actor');
|
||||
if (!actor) throw new HttpError(401, 'Authentication required');
|
||||
const ownsApp = await this.services.permission
|
||||
.check(actor as Actor, `apps-of-user:${owner.uuid}:write`)
|
||||
.catch(() => false);
|
||||
if (!ownsApp) throw new HttpError(403, 'Permission denied');
|
||||
|
||||
res.json(
|
||||
(users as Array<{ username: string; uuid: string }>).map((e) => ({
|
||||
user: e.username,
|
||||
user_uuid: e.uuid,
|
||||
})),
|
||||
);
|
||||
};
|
||||
const users = (await this.clients.db.read(
|
||||
`SELECT u.username, u.uuid FROM user_to_app_permissions p
|
||||
INNER JOIN ${this.clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
|
||||
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
|
||||
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
|
||||
LIMIT ? OFFSET ?`,
|
||||
[(app as { id: number }).id, safeLimit, safeOffset],
|
||||
)) as Array<{ username: string; uuid: string }>;
|
||||
|
||||
export const handleAppTelemetryUserCount = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const { app_uuid } = req.query as Record<string, string>;
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
return users.map((e) => ({ user: e.username, user_uuid: e.uuid }));
|
||||
}
|
||||
|
||||
const app = await stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
/** Count of users who have authenticated into the given app. */
|
||||
async user_count({
|
||||
app_uuid,
|
||||
}: { app_uuid?: string } = {}): Promise<number> {
|
||||
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
|
||||
|
||||
const [row] = (await clients.db.read(
|
||||
`SELECT COUNT(*) AS n FROM user_to_app_permissions
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
|
||||
[(app as Record<string, unknown>).id],
|
||||
)) as Array<{ n: number }>;
|
||||
const app = await this.stores.app.getByUid(app_uuid);
|
||||
if (!app) throw new HttpError(404, 'App not found');
|
||||
|
||||
res.json({ count: row?.n ?? 0 });
|
||||
};
|
||||
const [row] = (await this.clients.db.read(
|
||||
`SELECT COUNT(*) AS n FROM user_to_app_permissions
|
||||
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
|
||||
[(app as { id: number }).id],
|
||||
)) as Array<{ n: number }>;
|
||||
|
||||
extension.get(
|
||||
'/app-telemetry/users',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
handleAppTelemetryUsers,
|
||||
);
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
extension.get(
|
||||
'/app-telemetry/user-count',
|
||||
{ subdomain: 'api', requireAuth: true },
|
||||
handleAppTelemetryUserCount,
|
||||
);
|
||||
extension.registerDriver('appTelemetry', AppTelemetryDriver);
|
||||
@@ -132,9 +132,41 @@ describe('installedApps extension — handleInstalledApps', () => {
|
||||
expect(list[0].uid).toBe(app!.uid);
|
||||
expect(list[0].name).toBe(app!.name);
|
||||
expect(list[0].title).toBe(app!.title);
|
||||
// index_url is required so the dashboard can derive a hostname title
|
||||
// for anonymous (app-…) apps.
|
||||
expect(list[0].index_url).toBe(app!.index_url);
|
||||
expect(Object.prototype.hasOwnProperty.call(list[0], 'iconUrl')).toBe(
|
||||
true,
|
||||
);
|
||||
// An owned app is not external, and the raw owner id must not leak.
|
||||
expect(list[0].external).toBe(false);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('flags apps with no owner_user_id as external', async () => {
|
||||
const user = await seedUser();
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
// createFromOrigin bootstraps an app with owner_user_id = null.
|
||||
const app = await server.stores.app.createFromOrigin(
|
||||
`app-${slug}`,
|
||||
`https://external-${slug}.example.com`,
|
||||
);
|
||||
await grantInstalled(app!.id as number, user.id as number);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleInstalledApps(makeReq({}), res),
|
||||
);
|
||||
|
||||
const list = captured.body as Array<Record<string, unknown>>;
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].external).toBe(true);
|
||||
expect(
|
||||
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('clamps page/limit to safe ranges (page>=1, 1<=limit<=100)', async () => {
|
||||
|
||||
@@ -49,6 +49,8 @@ export const handleInstalledApps = async (
|
||||
apps.title,
|
||||
apps.description,
|
||||
apps.icon,
|
||||
apps.index_url,
|
||||
apps.owner_user_id,
|
||||
MIN(perm.dt) AS installed_at
|
||||
FROM apps
|
||||
LEFT JOIN user_to_app_permissions AS perm ON apps.id = perm.app_id
|
||||
@@ -62,10 +64,18 @@ export const handleInstalledApps = async (
|
||||
|
||||
const apiBaseUrl = extension.config.api_base_url as string | undefined;
|
||||
res.json(
|
||||
installedApps.map((app) => ({
|
||||
...app,
|
||||
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
||||
})),
|
||||
installedApps.map((app) => {
|
||||
// An app with no owner_user_id (null/empty) isn't owned by a Puter
|
||||
// user — it's an "external" app. Derive a flag and don't leak the
|
||||
// raw owner id to the client.
|
||||
const { owner_user_id, ...rest } = app;
|
||||
const external = owner_user_id == null || owner_user_id === '';
|
||||
return {
|
||||
...rest,
|
||||
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
|
||||
external,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { HttpError } from '@heyputer/backend/src/core/http';
|
||||
import {
|
||||
@@ -6,6 +5,7 @@ import {
|
||||
driversContainers,
|
||||
} from '@heyputer/backend/src/exports';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
const services = extension.import('service');
|
||||
const clients = extension.import('client');
|
||||
|
||||
@@ -107,6 +107,8 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.email).toBe(user.email);
|
||||
expect(body.is_temp).toBe(false);
|
||||
expect(body.oidc_only).toBe(false);
|
||||
// is_user_token is present (true) for user actors.
|
||||
expect(body.is_user_token).toBe(true);
|
||||
// `directories` is only sent to user actors — confirm it’s present.
|
||||
expect(body.directories).toBeDefined();
|
||||
// taskbar_items is only sent to user actors.
|
||||
@@ -146,6 +148,8 @@ describe('whoami extension — handleWhoami', () => {
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.app_name).toBe('app-test-actor');
|
||||
// is_user_token is stripped for app actors.
|
||||
expect(body.is_user_token).toBeUndefined();
|
||||
expect(body.desktop_bg_url).toBeUndefined();
|
||||
expect(body.desktop_bg_color).toBeUndefined();
|
||||
expect(body.desktop_bg_fit).toBeUndefined();
|
||||
|
||||
@@ -72,10 +72,14 @@ export const handleWhoami = async (
|
||||
unconfirmed_email: user.email,
|
||||
email_confirmed: user.email_confirmed || user.username === 'admin',
|
||||
requires_email_confirmation: user.requires_email_confirmation,
|
||||
phone: user.phone,
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
desktop_bg_color: user.desktop_bg_color,
|
||||
desktop_bg_fit: user.desktop_bg_fit,
|
||||
is_temp: user.password === null && user.email === null,
|
||||
is_user_token: true,
|
||||
oidc_only: oidcOnly,
|
||||
taskbar_items: isUser
|
||||
? await getTaskbarItems(
|
||||
@@ -157,6 +161,7 @@ export const handleWhoami = async (
|
||||
delete details.desktop_bg_color;
|
||||
delete details.desktop_bg_fit;
|
||||
delete details.human_readable_age;
|
||||
delete details.is_user_token;
|
||||
}
|
||||
|
||||
if (actor.app) {
|
||||
|
||||
+22
-10
@@ -16,11 +16,20 @@
|
||||
# the compose file + brings the stack up. Set PUTER_FORCE=1 to overwrite.
|
||||
#
|
||||
# Tunable env vars:
|
||||
# PUTER_DIR install directory (default: ./puter-selfhosted)
|
||||
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
|
||||
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
|
||||
# PUTER_PORT HTTP port for nginx (default: 80)
|
||||
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
|
||||
# PUTER_DIR install directory (default: ./puter-selfhosted)
|
||||
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
|
||||
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
|
||||
# PUTER_PORT HTTP port for nginx (default: 80)
|
||||
# PUTER_PROTOCOL public scheme: http | https (default: http)
|
||||
# Set https when a TLS-terminating reverse proxy (Caddy,
|
||||
# Traefik, nginx, a cloud LB) sits in front — Puter then
|
||||
# builds https:// origins, S3 URLs, and redirects. See
|
||||
# "Running behind your own reverse proxy" in doc/self-hosting.md.
|
||||
# PUTER_TRUST_PROXY number of reverse-proxy hops in front (default: 1)
|
||||
# 1 = the bundled nginx (or a single external proxy);
|
||||
# 2 = two hops (e.g. Cloudflare → your proxy → Puter).
|
||||
# PUTER_ENV prod | dev (default: prod)
|
||||
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
|
||||
|
||||
set -eu
|
||||
|
||||
@@ -28,6 +37,9 @@ PUTER_DIR="${PUTER_DIR:-puter-selfhosted}"
|
||||
PUTER_URL="${PUTER_URL:-https://raw.githubusercontent.com/HeyPuter/puter/main}"
|
||||
PUTER_DOMAIN="${PUTER_DOMAIN:-puter.localhost}"
|
||||
PUTER_PORT="${PUTER_PORT:-80}"
|
||||
PUTER_PROTOCOL="${PUTER_PROTOCOL:-http}"
|
||||
PUTER_TRUST_PROXY="${PUTER_TRUST_PROXY:-1}"
|
||||
PUTER_ENV="${PUTER_ENV:-prod}"
|
||||
PUTER_FORCE="${PUTER_FORCE:-0}"
|
||||
|
||||
log() { printf '\033[1;36m[puter-install]\033[0m %s\n' "$*"; }
|
||||
@@ -117,9 +129,9 @@ EOF
|
||||
cat > puter/config/config.json <<EOF
|
||||
{
|
||||
"domain": "$PUTER_DOMAIN",
|
||||
"protocol": "http",
|
||||
"protocol": "$PUTER_PROTOCOL",
|
||||
"pub_port": $PUTER_PORT,
|
||||
"env": "prod",
|
||||
"env": "$PUTER_ENV",
|
||||
|
||||
"static_hosting_domain": "site.$PUTER_DOMAIN",
|
||||
"static_hosting_domain_alt": "host.$PUTER_DOMAIN",
|
||||
@@ -159,7 +171,7 @@ EOF
|
||||
"s3": {
|
||||
"s3Config": {
|
||||
"endpoint": "http://s3:9000",
|
||||
"publicEndpoint": "http://s3.$PUTER_DOMAIN",
|
||||
"publicEndpoint": "$PUTER_PROTOCOL://s3.$PUTER_DOMAIN",
|
||||
"accessKeyId": "puter",
|
||||
"secretAccessKey": "$S3_SECRET_KEY",
|
||||
"region": "us-east-1",
|
||||
@@ -173,7 +185,7 @@ EOF
|
||||
"ollama": { "enabled": false }
|
||||
},
|
||||
|
||||
"trust_proxy": 1
|
||||
"trust_proxy": $PUTER_TRUST_PROXY
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
@@ -187,6 +199,6 @@ log "stack starting. first boot takes ~30s while MariaDB initialises."
|
||||
log "follow puter logs:"
|
||||
log " cd $PUTER_DIR && docker compose logs -f puter"
|
||||
log ""
|
||||
log "open http://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
|
||||
log "open $PUTER_PROTOCOL://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
|
||||
log "first-boot admin password is logged once — grab it with:"
|
||||
log " cd $PUTER_DIR && docker compose logs puter | grep password"
|
||||
Generated
+5176
-4782
File diff suppressed because it is too large.
Load diff
+17
-6
@@ -44,18 +44,18 @@
|
||||
"yaml": "^2.8.1"
|
||||
},
|
||||
"scripts": {
|
||||
"test:backend": "vitest run --config src/backend/vitest.config.ts ",
|
||||
"test:backend:postgres": "PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
|
||||
"start=gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"test:backend": "npm run setupExtensions && vitest run --config src/backend/vitest.config.ts ",
|
||||
"test:backend:postgres": "npm run setupExtensions && PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
|
||||
"start:gui": "nodemon --exec \"node dev-server.js\" ",
|
||||
"start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
|
||||
"prestart": "npm run build:ts",
|
||||
"prestart": "npm run setupExtensions && npm run build:ts",
|
||||
"dev": "npm start",
|
||||
"build": "npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
|
||||
"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",
|
||||
"check-translations": "node tools/check-translations.js",
|
||||
"prepare": "husky",
|
||||
"build:ts": "tsc -p tsconfig.build.json && node ./tools/write-dist-package-json.mjs",
|
||||
"postinstall": "node ./tools/extensionSetup.mjs"
|
||||
"setupExtensions": "node ./tools/extensionSetup.mjs"
|
||||
},
|
||||
"workspaces": [
|
||||
"src/*",
|
||||
@@ -77,9 +77,20 @@
|
||||
"ai": "^6.0.73",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.6",
|
||||
"open": "^10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@google/genai": false,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true,
|
||||
"[email protected]": true
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,13 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[50, ['0055_username_nocase_unique.sql']],
|
||||
[51, ['0056_sessions_kind_worker.sql']],
|
||||
[52, ['0057_add_user_reputation.sql']],
|
||||
[53, ['0058_add_phone_verification.sql']],
|
||||
[54, ['0059_add_card_verification.sql']],
|
||||
[55, ['0060_add_card_fingerprint.sql']],
|
||||
[56, ['0061_add_suspended_at.sql']],
|
||||
[57, ['0062_blocked-app-origins.sql']],
|
||||
[58, ['0063_add_suspended_reason.sql']],
|
||||
[59, ['0064_abuse-moderation-events.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
--
|
||||
|
||||
--
|
||||
-- Idempotent column-ensure helper (used by CALLs below; dropped at end of file)
|
||||
-- Idempotent column-ensure helper. Used by the CALLs below AND by later
|
||||
-- migration files, which CALL it as a one-line idempotent column add. Left
|
||||
-- resident (not dropped at end of file) so it outlives this migration.
|
||||
--
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_col;
|
||||
@@ -1333,7 +1335,10 @@ CALL _puter_add_col('user_update_audit', 'new_username', '`new_username` varchar
|
||||
CALL _puter_add_col('user_update_audit', 'reason', '`reason` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL');
|
||||
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_col;
|
||||
-- _puter_add_col is intentionally NOT dropped here: later migration files CALL
|
||||
-- it as a one-line idempotent column add, and migrations replay on every boot
|
||||
-- with no applied-state tracking, so the helper must outlive this file. The
|
||||
-- DROP-before-CREATE at the top keeps this migration itself replay-safe.
|
||||
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 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/>.
|
||||
|
||||
-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the
|
||||
-- E.164 number collected during verification (indexed like `email`);
|
||||
-- `requires_phone_verification` gates account use for low-reputation signups
|
||||
-- (not indexed, mirroring `requires_email_confirmation`).
|
||||
--
|
||||
-- Idempotent: column adds use _puter_add_col (defined in mig_1, which leaves it
|
||||
-- resident for later migrations); the index add is guarded against
|
||||
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'phone', '`phone` varchar(20) DEFAULT NULL');
|
||||
CALL _puter_add_col('user', 'requires_phone_verification', '`requires_phone_verification` tinyint(1) NOT NULL DEFAULT ''0''');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_phone_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_phone_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_phone'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_phone` (`phone`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_phone_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_phone_index;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Credit-card verification column. Mirrors SQLite migration 0059.
|
||||
-- `requires_card_verification` gates account use for low-reputation signups;
|
||||
-- the card itself never touches our DB, so this is the only column (not
|
||||
-- indexed, mirroring `requires_phone_verification`).
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (from mig_1) so the
|
||||
-- directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'requires_card_verification', '`requires_card_verification` tinyint(1) NOT NULL DEFAULT ''0''');
|
||||
@@ -0,0 +1,47 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
|
||||
-- is the Stripe card fingerprint (stable per card number) recorded when a user
|
||||
-- clears card verification — the card sibling of `phone`, indexed like it so
|
||||
-- admin tooling can find the accounts that verified with a given card. The card
|
||||
-- itself never touches our DB, only Stripe's fingerprint for it.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations); the index add is guarded against
|
||||
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'card_fingerprint', '`card_fingerprint` varchar(128) DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_card_fingerprint_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_card_fingerprint'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_card_fingerprint` (`card_fingerprint`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_card_fingerprint_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
|
||||
-- the account was suspended, as unix seconds (NULL while not suspended) — the
|
||||
-- timestamp sibling of the boolean `suspended` flag, indexed so the
|
||||
-- signup-abuse harness can count an IP's recently-suspended accounts.
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations); the index add is guarded against
|
||||
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
|
||||
|
||||
CALL _puter_add_col('user', 'suspended_at', '`suspended_at` bigint DEFAULT NULL');
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_user_suspended_at_index()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'user'
|
||||
AND INDEX_NAME = 'idx_user_suspended_at'
|
||||
) THEN
|
||||
ALTER TABLE `user` ADD INDEX `idx_user_suspended_at` (`suspended_at`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_user_suspended_at_index();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062.
|
||||
-- An app whose `index_url` host (or a request origin) matches an entry is
|
||||
-- denied access to Puter resources: it cannot obtain an app token and
|
||||
-- already-issued app tokens are rejected on each request. `include_subdomains
|
||||
-- = 1` also blocks every subdomain of `domain`. Enforced in AuthService via
|
||||
-- AppOriginBlocklistService.
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`domain` VARCHAR(255) NOT NULL,
|
||||
`include_subdomains` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`reason` TEXT DEFAULT NULL,
|
||||
`created_by` VARCHAR(255) DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_blocked_app_origins_domain` (`domain`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
|
||||
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
|
||||
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
|
||||
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
|
||||
--
|
||||
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
|
||||
-- leaves it resident for later migrations).
|
||||
|
||||
CALL _puter_add_col('user', 'suspended_reason', '`suspended_reason` VARCHAR(64) DEFAULT NULL');
|
||||
@@ -0,0 +1,38 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064.
|
||||
-- Used to measure the abuse system's false-positive rate: `unsuspend` (admin
|
||||
-- unblock) and `admin_create_user` are the false-positive signals; `suspend`
|
||||
-- gives the denominator. Preserves history the `user` suspension columns lose
|
||||
-- on unsuspend. `created_at` is unix seconds (matching `user.suspended_at`).
|
||||
--
|
||||
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`action` VARCHAR(32) NOT NULL,
|
||||
`target_user_id` BIGINT DEFAULT NULL,
|
||||
`target_username` VARCHAR(255) DEFAULT NULL,
|
||||
`admin_username` VARCHAR(255) DEFAULT NULL,
|
||||
`reason` TEXT DEFAULT NULL,
|
||||
`source` VARCHAR(64) DEFAULT NULL,
|
||||
`created_at` BIGINT NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_abuse_moderation_events_created_at` (`created_at`),
|
||||
KEY `idx_abuse_moderation_events_action` (`action`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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/>.
|
||||
|
||||
-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the
|
||||
-- E.164 number collected during verification (indexed like `email`);
|
||||
-- `requires_phone_verification` gates account use for low-reputation signups
|
||||
-- (not indexed, mirroring `requires_email_confirmation`).
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS phone varchar(20);
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_phone_verification boolean NOT NULL DEFAULT FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_phone ON "user" (phone);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Credit-card verification column. Mirrors SQLite migration 0059.
|
||||
-- `requires_card_verification` gates account use for low-reputation signups;
|
||||
-- the card itself never touches our DB, so this is the only column (not
|
||||
-- indexed, mirroring `requires_phone_verification`).
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_card_verification boolean NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
|
||||
-- is the Stripe card fingerprint (stable per card number) recorded when a user
|
||||
-- clears card verification — the card sibling of `phone`, indexed like it so
|
||||
-- admin tooling can find the accounts that verified with a given card. The card
|
||||
-- itself never touches our DB, only Stripe's fingerprint for it.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS card_fingerprint varchar(128);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON "user" (card_fingerprint);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
|
||||
-- the account was suspended, as unix seconds (NULL while not suspended) — the
|
||||
-- timestamp sibling of the boolean `suspended` flag, indexed so the
|
||||
-- signup-abuse harness can count an IP's recently-suspended accounts.
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_at bigint;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON "user" (suspended_at);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062 /
|
||||
-- MySQL mysql_mig_17 (which Postgres was originally missed for). An app whose
|
||||
-- `index_url` host (or a request origin) matches an entry is denied access to
|
||||
-- Puter resources. `include_subdomains = 1` also blocks every subdomain of
|
||||
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocked_app_origins (
|
||||
id SERIAL PRIMARY KEY,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
include_subdomains SMALLINT NOT NULL DEFAULT 0,
|
||||
reason TEXT DEFAULT NULL,
|
||||
created_by VARCHAR(255) DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_blocked_app_origins_domain
|
||||
ON blocked_app_origins (domain);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
|
||||
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
|
||||
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
|
||||
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_reason text;
|
||||
@@ -0,0 +1,40 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064 /
|
||||
-- MySQL mysql_mig_19. Used to measure the abuse system's false-positive rate:
|
||||
-- `unsuspend` (admin unblock) and `admin_create_user` are the false-positive
|
||||
-- signals; `suspend` gives the denominator. Preserves history the `user`
|
||||
-- suspension columns lose on unsuspend. `created_at` is unix seconds.
|
||||
--
|
||||
-- Idempotent via IF NOT EXISTS.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS abuse_moderation_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
target_user_id BIGINT DEFAULT NULL,
|
||||
target_username VARCHAR(255) DEFAULT NULL,
|
||||
admin_username VARCHAR(255) DEFAULT NULL,
|
||||
reason TEXT DEFAULT NULL,
|
||||
source VARCHAR(64) DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
|
||||
ON abuse_moderation_events (created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
|
||||
ON abuse_moderation_events (action);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 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/>.
|
||||
|
||||
-- SMS phone verification. `phone` holds the E.164 number collected during
|
||||
-- verification (indexed like `email`). `requires_phone_verification` gates
|
||||
-- account use until verified — the abuse v2 harness sets it for low-reputation
|
||||
-- signups instead of blocking them. (Not indexed, mirroring
|
||||
-- `requires_email_confirmation`.)
|
||||
ALTER TABLE `user` ADD COLUMN `phone` varchar(20) DEFAULT NULL;
|
||||
ALTER TABLE `user` ADD COLUMN `requires_phone_verification` tinyint(1) NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_phone ON `user` (`phone`);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Credit-card verification ($0 authorization handled by a payments
|
||||
-- extension). `requires_card_verification` gates account use until verified —
|
||||
-- the abuse v2 harness sets it for low-reputation signups instead of blocking
|
||||
-- them. The card itself never touches our DB, so this is the only column.
|
||||
-- (Not indexed, mirroring `requires_phone_verification`.)
|
||||
ALTER TABLE `user` ADD COLUMN `requires_card_verification` tinyint(1) NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Card fingerprint column. `card_fingerprint` is the Stripe card fingerprint
|
||||
-- (stable per card number) recorded when a user clears card verification — the
|
||||
-- card sibling of `phone`. Indexed like `phone` so admin tooling can find the
|
||||
-- accounts that verified with a given card. The card itself never touches our
|
||||
-- DB, only Stripe's fingerprint for it. Server-only (not on the whoami
|
||||
-- allowlist), so it never reaches clients.
|
||||
ALTER TABLE `user` ADD COLUMN `card_fingerprint` varchar(128) DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON `user` (`card_fingerprint`);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- 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/>.
|
||||
|
||||
-- When the account was suspended, as unix seconds; NULL while not suspended.
|
||||
-- The timestamp sibling of the existing boolean `suspended` flag, stamped by
|
||||
-- every suspension site. Indexed so the signup-abuse harness can count an IP's
|
||||
-- recently-suspended accounts (see extensions/abuse/v2 suspendedAccountsByIp).
|
||||
ALTER TABLE `user` ADD COLUMN `suspended_at` INTEGER DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON `user` (`suspended_at`);
|
||||
@@ -0,0 +1,34 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Admin-managed blocklist of app origins. An app whose `index_url` host (or
|
||||
-- a request origin) matches an entry is denied access to Puter resources:
|
||||
-- it cannot obtain an app token and already-issued app tokens are rejected
|
||||
-- on each request. `include_subdomains = 1` also blocks every subdomain of
|
||||
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"domain" TEXT NOT NULL,
|
||||
"include_subdomains" INTEGER NOT NULL DEFAULT 0,
|
||||
"reason" TEXT DEFAULT NULL,
|
||||
"created_by" TEXT DEFAULT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_blocked_app_origins_domain`
|
||||
ON `blocked_app_origins` (`domain`);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Why an account was suspended, NULL while not suspended. Companion to the
|
||||
-- boolean `suspended` flag and `suspended_at` timestamp. Constrained at the
|
||||
-- application layer to a fixed set of reasons (see extensions/admin
|
||||
-- suspension_reasons.js) — kept as free TEXT here so adding a reason later
|
||||
-- needs no migration.
|
||||
ALTER TABLE `user` ADD COLUMN `suspended_reason` TEXT DEFAULT NULL;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Append-only log of admin moderation actions, used to measure the abuse
|
||||
-- system's false-positive rate: `unsuspend` (admin unblock) and
|
||||
-- `admin_create_user` (an account minted by an admin, typically to recover a
|
||||
-- legitimately-blocked signup) are the false-positive signals; `suspend` gives
|
||||
-- the denominator. Unlike the `suspended`/`suspended_at`/`suspended_reason`
|
||||
-- columns on `user` — which are cleared on unsuspend and so keep no history —
|
||||
-- this table preserves every event. `created_at` is unix seconds (matching
|
||||
-- `user.suspended_at`), stamped by the recorder in
|
||||
-- extensions/admin/moderation_events.js. Written best-effort at the admin
|
||||
-- suspend/unsuspend/create-user sites; surfaced on the /admin/abuse dashboard.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"action" TEXT NOT NULL, -- 'suspend' | 'unsuspend' | 'admin_create_user'
|
||||
"target_user_id" INTEGER DEFAULT NULL,
|
||||
"target_username" TEXT DEFAULT NULL,
|
||||
"admin_username" TEXT DEFAULT NULL, -- the acting admin (req.actor.user.username)
|
||||
"reason" TEXT DEFAULT NULL,
|
||||
"source" TEXT DEFAULT NULL, -- 'user_page' | 'email_hostname_blacklist' | 'create_user'
|
||||
"created_at" INTEGER NOT NULL -- unix seconds
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
|
||||
ON `abuse_moderation_events` (`created_at`);
|
||||
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
|
||||
ON `abuse_moderation_events` (`action`);
|
||||
@@ -120,6 +120,21 @@ export type EventMap = {
|
||||
actor: unknown;
|
||||
result: { allowed: boolean; reason?: string; error?: string };
|
||||
};
|
||||
// Emitted after an app token is granted (get-user-app-token). The app-side
|
||||
// analogue of `site.htmlServed` — the external-app abuse scanner
|
||||
// (extensions/subdomainAbuse/appScan.ts) listens to screenshot + classify
|
||||
// external `index_url`s that Puter never serves itself.
|
||||
'puter.app.authenticated': {
|
||||
app_uid?: string;
|
||||
user_id?: number | null;
|
||||
app?: {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
index_url?: string | null;
|
||||
owner_user_id?: number | null;
|
||||
name?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
// ---- Auth / signup ----
|
||||
'puter.signup.validate': {
|
||||
@@ -131,6 +146,18 @@ export type EventMap = {
|
||||
data?: unknown;
|
||||
abuse?: unknown;
|
||||
trail?: Array<string>;
|
||||
/**
|
||||
* Set by the abuse harness for flagged signups — the id under which the
|
||||
* decision trail is persisted to KV (`abuse:trail:<id>`), shared back on
|
||||
* the request for log / support correlation.
|
||||
*/
|
||||
trail_id?: string;
|
||||
/** Device signal forwarded verbatim from the signup request body. */
|
||||
fingerprint?: string | null;
|
||||
/** Set by the abuse harness — require SMS phone verification post-signup. */
|
||||
requires_phone_verification?: boolean;
|
||||
/** Set by the abuse harness — require card verification post-signup. */
|
||||
requires_card_verification?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'puter.signup.success': {
|
||||
@@ -139,6 +166,9 @@ export type EventMap = {
|
||||
email: string;
|
||||
username: string;
|
||||
ip?: string | null;
|
||||
fingerprint?: string | null;
|
||||
/** True when the created account is a temp user (no email/password). */
|
||||
is_temp?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'email.validate': {
|
||||
@@ -158,6 +188,78 @@ export type EventMap = {
|
||||
user_uid: string;
|
||||
email: string;
|
||||
};
|
||||
// Phone-reuse cap is pure mechanism here — the abuse extension answers
|
||||
// (emitted via `emitAndWait`) by counting how many OTHER accounts have
|
||||
// already verified this number and flipping `allowed` to false when the
|
||||
// cross-account limit is hit. No extension listening → `allowed` stays
|
||||
// true and the send proceeds.
|
||||
'puter.phone-verification.check': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
phone: string;
|
||||
// Client-supplied device fingerprint for this request, or null. Lets
|
||||
// the abuse extension cap sends per device (across accounts) so a
|
||||
// device farm can't dodge the per-account cap with fresh signups.
|
||||
device_fingerprint: string | null;
|
||||
allowed: boolean;
|
||||
reason: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// Fire-and-forget signal that a code was actually sent — the abuse
|
||||
// extension bumps its per-number / per-account / per-device send-velocity
|
||||
// counters off this. No-op with no extension listening.
|
||||
'puter.phone-verification.sent': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
phone: string;
|
||||
device_fingerprint: string | null;
|
||||
};
|
||||
'user.phone-verified': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
phone: string;
|
||||
};
|
||||
// Card verification is pure mechanism here — a payments extension fills
|
||||
// these in (emitted via `emitAndWait`). `enabled` stays null when no
|
||||
// extension is installed; the extension always sets it (true/false) so
|
||||
// the endpoints can distinguish "disabled" from "not installed".
|
||||
'puter.card-verification.setup': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
ip?: string | null;
|
||||
// Client-supplied device fingerprint for this request, or null. Lets
|
||||
// the abuse extension cap card-verification setups per device (across
|
||||
// accounts) before any Stripe SetupIntent is created.
|
||||
device_fingerprint: string | null;
|
||||
enabled: boolean | null;
|
||||
// Set false by the extension to refuse this setup (e.g. the per-device
|
||||
// setup-velocity cap); `reason` carries the opaque code. Stays true
|
||||
// when allowed or with no extension listening.
|
||||
allowed: boolean;
|
||||
reason: string | null;
|
||||
client_secret: string | null;
|
||||
publishable_key: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'puter.card-verification.confirm': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
setup_intent_id: string;
|
||||
enabled: boolean | null;
|
||||
verified: boolean;
|
||||
reason: string | null;
|
||||
fingerprint: string | null;
|
||||
funding: string | null;
|
||||
country: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'user.card-verified': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
fingerprint: string | null;
|
||||
funding: string | null;
|
||||
country: string | null;
|
||||
};
|
||||
'user.username-changed': {
|
||||
user_id: number;
|
||||
old_username: string;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { EventClient } from './event/EventClient';
|
||||
import { DDBClient } from './dynamodb/DDBClient';
|
||||
import { RedisClient } from './redis/RedisClient';
|
||||
import { S3Client } from './s3/S3Client';
|
||||
import { PreludeClient } from './prelude/PreludeClient';
|
||||
import type { IPuterClientRegistry } from './types';
|
||||
|
||||
export const puterClients = {
|
||||
@@ -34,4 +35,5 @@ export const puterClients = {
|
||||
dynamo: DDBClient,
|
||||
redis: RedisClient,
|
||||
s3: S3Client,
|
||||
prelude: PreludeClient,
|
||||
} satisfies IPuterClientRegistry;
|
||||
@@ -0,0 +1,216 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PreludeClient } from './PreludeClient';
|
||||
import type { IConfig } from '../../types';
|
||||
|
||||
const makeClient = (apiKey?: string) =>
|
||||
new PreludeClient({
|
||||
prelude: apiKey ? { apiKey } : undefined,
|
||||
} as unknown as IConfig);
|
||||
|
||||
const okJson = (body: unknown) =>
|
||||
({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
}) as unknown as Response;
|
||||
|
||||
describe('PreludeClient', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('isConfigured reflects whether an apiKey is set', () => {
|
||||
expect(makeClient('sk_test').isConfigured()).toBe(true);
|
||||
expect(makeClient().isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
describe('isCountrySupported (€0.07 cap)', () => {
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
it('allows revenue markets up to the cap (incl. the priciest)', () => {
|
||||
expect(client.isCountrySupported('US')).toBe(true); // €0.0043
|
||||
expect(client.isCountrySupported('DE')).toBe(true); // €0.0598
|
||||
expect(client.isCountrySupported('SA')).toBe(true); // €0.0638
|
||||
expect(client.isCountrySupported('us')).toBe(true); // case-insensitive
|
||||
});
|
||||
|
||||
it('rejects countries above the cap, with no SMS, or unknown', () => {
|
||||
expect(client.isCountrySupported('PK')).toBe(false); // €0.3548
|
||||
expect(client.isCountrySupported('ID')).toBe(false); // €0.2430
|
||||
expect(client.isCountrySupported('LI')).toBe(false); // null (no SMS)
|
||||
expect(client.isCountrySupported('ZZ')).toBe(false); // unknown
|
||||
expect(client.isCountrySupported(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('honors a configured maxSmsCostEur override', () => {
|
||||
const strict = new PreludeClient({
|
||||
prelude: { apiKey: 'sk', maxSmsCostEur: 0.01 },
|
||||
} as unknown as IConfig);
|
||||
expect(strict.isCountrySupported('US')).toBe(true); // €0.0043
|
||||
expect(strict.isCountrySupported('DE')).toBe(false); // €0.0598 > 0.01
|
||||
|
||||
const loose = new PreludeClient({
|
||||
prelude: { apiKey: 'sk', maxSmsCostEur: 0.5 },
|
||||
} as unknown as IConfig);
|
||||
expect(loose.isCountrySupported('PK')).toBe(true); // €0.3548 <= 0.5
|
||||
});
|
||||
});
|
||||
|
||||
it('createVerification POSTs the phone target + ip signal with bearer auth', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
okJson({ id: 'vrf_1', status: 'success' }),
|
||||
);
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
const res = await client.createVerification('+14155550123', {
|
||||
ip: '203.0.113.7',
|
||||
});
|
||||
|
||||
expect(res).toEqual({ id: 'vrf_1', status: 'success' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.prelude.dev/v2/verification');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers.Authorization).toBe('Bearer sk_test');
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
target: { type: 'phone_number', value: '+14155550123' },
|
||||
// Defaults to RCS (cheaper); Prelude falls back to SMS. locale is
|
||||
// hardcoded to en-US so the message text is always English.
|
||||
options: { code_size: 6, preferred_channel: 'rcs', locale: 'en-US' },
|
||||
signals: { ip: '203.0.113.7' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the delivery channel sequence Prelude reports', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
okJson({
|
||||
id: 'vrf_1',
|
||||
status: 'success',
|
||||
channels: ['whatsapp', 'sms'],
|
||||
}),
|
||||
);
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
const res = await client.createVerification('+14155550123');
|
||||
|
||||
expect(res.channels).toEqual(['whatsapp', 'sms']);
|
||||
});
|
||||
|
||||
it('forwards device_id and user_agent signals when supplied', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123', {
|
||||
ip: '203.0.113.7',
|
||||
device_id: 'thumb_abc123',
|
||||
user_agent: 'Mozilla/5.0',
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(init.body).signals).toEqual({
|
||||
ip: '203.0.113.7',
|
||||
device_id: 'thumb_abc123',
|
||||
user_agent: 'Mozilla/5.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the signals object entirely when none are supplied', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123');
|
||||
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty(
|
||||
'signals',
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards dispatch_id as a top-level field, not inside signals', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123', {
|
||||
ip: '203.0.113.7',
|
||||
dispatch_id: 'd1f5e9a0-0000-4000-8000-000000000000',
|
||||
});
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.dispatch_id).toBe('d1f5e9a0-0000-4000-8000-000000000000');
|
||||
expect(body.signals).toEqual({ ip: '203.0.113.7' });
|
||||
expect(body.signals).not.toHaveProperty('dispatch_id');
|
||||
});
|
||||
|
||||
it('omits dispatch_id when not supplied', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
await client.createVerification('+14155550123', { ip: '203.0.113.7' });
|
||||
|
||||
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).not.toHaveProperty(
|
||||
'dispatch_id',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes a configured template_id + sender_id + preferred channel', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = new PreludeClient({
|
||||
prelude: {
|
||||
apiKey: 'sk_test',
|
||||
templateId: 'tmpl_puter',
|
||||
senderId: 'Puter',
|
||||
preferredChannel: 'sms',
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
|
||||
await client.createVerification('+14155550123');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(init.body).options).toEqual({
|
||||
code_size: 6,
|
||||
preferred_channel: 'sms', // config override beats the rcs default
|
||||
locale: 'en-US',
|
||||
template_id: 'tmpl_puter',
|
||||
sender_id: 'Puter',
|
||||
});
|
||||
});
|
||||
|
||||
it('checkVerification POSTs target + code and returns the status', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
const res = await client.checkVerification('+14155550123', '123456');
|
||||
|
||||
expect(res).toEqual({ status: 'success' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.prelude.dev/v2/verification/check');
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
target: { type: 'phone_number', value: '+14155550123' },
|
||||
code: '123456',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws (does not call fetch) when not configured', async () => {
|
||||
const client = makeClient();
|
||||
await expect(
|
||||
client.createVerification('+14155550123'),
|
||||
).rejects.toThrow(/not configured/i);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws on a non-2xx Prelude response', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ message: 'bad target' }),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(
|
||||
makeClient('sk_test').checkVerification('+1', 'x'),
|
||||
).rejects.toThrow(/Prelude .* failed: 400/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 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 type { IConfig } from '../../types';
|
||||
import { PuterClient } from '../types';
|
||||
import { COUNTRY_SMS_PRICES } from './countries.js';
|
||||
|
||||
const PRELUDE_API_BASE = 'https://api.prelude.dev/v2';
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
/** OTP length — matches the 6-box code UI. Prelude allows 4–8. */
|
||||
const PRELUDE_CODE_SIZE = 6;
|
||||
/**
|
||||
* Channel Prelude prioritizes for delivery. RCS is far cheaper than SMS, so we
|
||||
* prefer it by default; Prelude routes to the next reachable channel (SMS) when
|
||||
* RCS isn't available on the destination. (Requires an RCS agent provisioned in
|
||||
* the Prelude account to actually use RCS — otherwise it just falls through to
|
||||
* SMS.) Override with `config.prelude.preferredChannel`.
|
||||
*/
|
||||
const DEFAULT_PREFERRED_CHANNEL: PreludeChannel = 'rcs';
|
||||
|
||||
/** Delivery channels Prelude can prioritize via `options.preferred_channel`. */
|
||||
export type PreludeChannel =
|
||||
| 'sms'
|
||||
| 'rcs'
|
||||
| 'whatsapp'
|
||||
| 'viber'
|
||||
| 'zalo'
|
||||
| 'telegram';
|
||||
/**
|
||||
* Channels Prelude reports in a verification's `channels` array — the ordered
|
||||
* delivery sequence, first entry first. Superset of `PreludeChannel`: 'silent'
|
||||
* and 'voice' can appear as delivery methods but can't be preferred.
|
||||
*/
|
||||
export type PreludeDeliveryChannel = PreludeChannel | 'silent' | 'voice';
|
||||
/**
|
||||
* Default per-SMS cost ceiling (EUR). Countries whose Prelude SMS rate exceeds
|
||||
* this — or that have no SMS channel — are not offered phone verification. The
|
||||
* cap covers every realistic revenue market (priciest are Germany €0.0598 and
|
||||
* Saudi Arabia €0.0638) while excluding the expensive, high-fraud long tail.
|
||||
* Override per-deployment with `config.prelude.maxSmsCostEur`.
|
||||
*/
|
||||
const DEFAULT_MAX_SMS_COST_EUR = 0.07;
|
||||
|
||||
/** Status returned by Prelude when creating/retrying a verification. */
|
||||
export type PreludeCreateStatus =
|
||||
| 'success'
|
||||
| 'retry'
|
||||
| 'challenged'
|
||||
| 'blocked'
|
||||
| 'shadow_blocked';
|
||||
|
||||
/** Status returned by Prelude when checking a code. */
|
||||
export type PreludeCheckStatus =
|
||||
| 'success'
|
||||
| 'failure'
|
||||
| 'expired_or_not_found'
|
||||
| 'transaction_missing'
|
||||
| 'transaction_mismatch';
|
||||
|
||||
/**
|
||||
* Prelude Verify v2 client (https://docs.prelude.so/verify/v2). Sends and checks
|
||||
* SMS one-time codes — Prelude generates, delivers, and validates the code, so
|
||||
* we never store one ourselves. No-ops with a warning when no API key is
|
||||
* configured (so dev environments don't crash); `isConfigured()` lets callers
|
||||
* surface a clean "phone verification unavailable" error instead.
|
||||
*/
|
||||
export class PreludeClient extends PuterClient {
|
||||
private apiKey: string | null = null;
|
||||
|
||||
constructor(config: IConfig) {
|
||||
super(config);
|
||||
this.apiKey = config.prelude?.apiKey ?? null;
|
||||
}
|
||||
|
||||
override onServerStart(): void {
|
||||
if (!this.apiKey) {
|
||||
console.warn(
|
||||
'[prelude] no apiKey configured — SMS phone verification is disabled',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** True when an API key is configured and verification can be attempted. */
|
||||
isConfigured(): boolean {
|
||||
return !!this.apiKey;
|
||||
}
|
||||
|
||||
/** ISO region used to parse local-format numbers (config-driven). */
|
||||
get defaultCountry(): string | undefined {
|
||||
return this.config.prelude?.defaultCountry;
|
||||
}
|
||||
|
||||
/** Per-SMS cost ceiling in EUR (config override or the default cap). */
|
||||
get maxSmsCostEur(): number {
|
||||
return this.config.prelude?.maxSmsCostEur ?? DEFAULT_MAX_SMS_COST_EUR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether SMS verification should be offered for a country. False when the
|
||||
* country is unknown, has no SMS channel, or its rate exceeds the cost cap.
|
||||
* @param iso ISO-3166 alpha-2 (e.g. 'US') — from the parsed phone number.
|
||||
*/
|
||||
isCountrySupported(iso: string | undefined): boolean {
|
||||
if (!iso) return false; // couldn't determine country → can't price it
|
||||
const price = COUNTRY_SMS_PRICES[iso.toUpperCase()];
|
||||
if (!price || price.sms == null) return false;
|
||||
return price.sms <= this.maxSmsCostEur;
|
||||
}
|
||||
|
||||
async createVerification(
|
||||
target: string,
|
||||
signals: {
|
||||
ip?: string;
|
||||
device_id?: string;
|
||||
user_agent?: string;
|
||||
dispatch_id?: string;
|
||||
} = {},
|
||||
): Promise<{
|
||||
id?: string;
|
||||
status: PreludeCreateStatus;
|
||||
channels?: PreludeDeliveryChannel[];
|
||||
}> {
|
||||
// Match the 6-box code UI (UIWindowPhoneVerificationRequired). Without
|
||||
// code_size Prelude uses the dashboard default (4). preferred_channel
|
||||
// prioritizes RCS (cheaper); Prelude falls back to SMS when unavailable.
|
||||
const options: Record<string, unknown> = {
|
||||
code_size: PRELUDE_CODE_SIZE,
|
||||
preferred_channel:
|
||||
this.config.prelude?.preferredChannel ??
|
||||
DEFAULT_PREFERRED_CHANNEL,
|
||||
locale: 'en-US',
|
||||
};
|
||||
// Branding lives in the Prelude dashboard (the message text is a
|
||||
// template); these just select a Puter-branded template / sender when
|
||||
// configured. See IPreludeConfig.
|
||||
const { templateId, senderId } = this.config.prelude ?? {};
|
||||
if (templateId) options.template_id = templateId;
|
||||
if (senderId) options.sender_id = senderId;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
target: { type: 'phone_number', value: target },
|
||||
options,
|
||||
};
|
||||
// Only attach signals we actually have — Prelude treats the object as
|
||||
// optional and an empty one adds nothing.
|
||||
const sig: Record<string, string> = {};
|
||||
if (signals.ip) sig.ip = signals.ip;
|
||||
if (signals.device_id) sig.device_id = signals.device_id;
|
||||
if (signals.user_agent) sig.user_agent = signals.user_agent;
|
||||
if (Object.keys(sig).length > 0) body.signals = sig;
|
||||
// dispatch_id is a top-level field, not a member of `signals`.
|
||||
if (signals.dispatch_id) body.dispatch_id = signals.dispatch_id;
|
||||
return this.#post('/verification', body) as Promise<{
|
||||
id?: string;
|
||||
status: PreludeCreateStatus;
|
||||
channels?: PreludeDeliveryChannel[];
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a code the user entered against the active verification for `target`.
|
||||
* @returns `{ status }` — `'success'` means verified.
|
||||
*/
|
||||
async checkVerification(
|
||||
target: string,
|
||||
code: string,
|
||||
): Promise<{ status: PreludeCheckStatus }> {
|
||||
return this.#post('/verification/check', {
|
||||
target: { type: 'phone_number', value: target },
|
||||
code,
|
||||
}) as Promise<{ status: PreludeCheckStatus }>;
|
||||
}
|
||||
|
||||
async #post(
|
||||
path: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!this.apiKey) {
|
||||
throw new Error('Prelude is not configured');
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(`${PRELUDE_API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const json = (await res.json().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Prelude ${path} failed: ${res.status} ${
|
||||
(json as { message?: string })?.message ?? ''
|
||||
}`.trim(),
|
||||
);
|
||||
}
|
||||
return json;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,952 @@
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/** Per-country Prelude SMS pricing, hardcoded from the Prelude global price
|
||||
* sheet. `sms` is the average cost in EUR, or null when SMS is unavailable
|
||||
* there. Used to cap phone verification to affordable countries (see
|
||||
* PreludeClient.isCountrySupported). Regenerate from the price sheet when
|
||||
* Prelude updates rates. */
|
||||
export interface CountryPrice {
|
||||
name: string;
|
||||
/** Prelude SMS average in EUR, or null when SMS is not available. */
|
||||
sms: number | null;
|
||||
}
|
||||
|
||||
export const COUNTRY_SMS_PRICES: Record<string, CountryPrice> = {
|
||||
AD: {
|
||||
name: 'Andorra',
|
||||
sms: 0.0515,
|
||||
},
|
||||
AE: {
|
||||
name: 'United Arab Emirates',
|
||||
sms: 0.0145,
|
||||
},
|
||||
AF: {
|
||||
name: 'Afghanistan',
|
||||
sms: 0.2409,
|
||||
},
|
||||
AG: {
|
||||
name: 'Antigua and Barbuda',
|
||||
sms: 0.0755,
|
||||
},
|
||||
AI: {
|
||||
name: 'Anguilla',
|
||||
sms: 0.0765,
|
||||
},
|
||||
AL: {
|
||||
name: 'Albania',
|
||||
sms: 0.0345,
|
||||
},
|
||||
AM: {
|
||||
name: 'Armenia',
|
||||
sms: 0.118,
|
||||
},
|
||||
AN: {
|
||||
name: 'Netherlands Antilles',
|
||||
sms: null,
|
||||
},
|
||||
AO: {
|
||||
name: 'Angola',
|
||||
sms: 0.0347,
|
||||
},
|
||||
AR: {
|
||||
name: 'Argentina',
|
||||
sms: 0.0614,
|
||||
},
|
||||
AS: {
|
||||
name: 'American Samoa',
|
||||
sms: 0.077,
|
||||
},
|
||||
AT: {
|
||||
name: 'Austria',
|
||||
sms: 0.014,
|
||||
},
|
||||
AU: {
|
||||
name: 'Australia',
|
||||
sms: 0.0091,
|
||||
},
|
||||
AW: {
|
||||
name: 'Aruba',
|
||||
sms: 0.0617,
|
||||
},
|
||||
AZ: {
|
||||
name: 'Azerbaijan',
|
||||
sms: 0.0969,
|
||||
},
|
||||
BA: {
|
||||
name: 'Bosnia and Herzegovina',
|
||||
sms: 0.0801,
|
||||
},
|
||||
BB: {
|
||||
name: 'Barbados',
|
||||
sms: 0.2021,
|
||||
},
|
||||
BD: {
|
||||
name: 'Bangladesh',
|
||||
sms: 0.1844,
|
||||
},
|
||||
BE: {
|
||||
name: 'Belgium',
|
||||
sms: 0.0297,
|
||||
},
|
||||
BF: {
|
||||
name: 'Burkina Faso',
|
||||
sms: 0.063,
|
||||
},
|
||||
BG: {
|
||||
name: 'Bulgaria',
|
||||
sms: 0.0799,
|
||||
},
|
||||
BH: {
|
||||
name: 'Bahrain',
|
||||
sms: 0.014,
|
||||
},
|
||||
BI: {
|
||||
name: 'Burundi',
|
||||
sms: 0.217,
|
||||
},
|
||||
BJ: {
|
||||
name: 'Benin',
|
||||
sms: 0.13,
|
||||
},
|
||||
BM: {
|
||||
name: 'Bermuda',
|
||||
sms: 0.21,
|
||||
},
|
||||
BN: {
|
||||
name: 'Brunei',
|
||||
sms: 0.035,
|
||||
},
|
||||
BO: {
|
||||
name: 'Bolivia',
|
||||
sms: 0.0708,
|
||||
},
|
||||
BQ: {
|
||||
name: 'Caribbean Netherlands',
|
||||
sms: 0.0565,
|
||||
},
|
||||
BR: {
|
||||
name: 'Brazil',
|
||||
sms: 0.0086,
|
||||
},
|
||||
BS: {
|
||||
name: 'Bahamas',
|
||||
sms: 0.0672,
|
||||
},
|
||||
BT: {
|
||||
name: 'Bhutan',
|
||||
sms: null,
|
||||
},
|
||||
BW: {
|
||||
name: 'Botswana',
|
||||
sms: 0.0235,
|
||||
},
|
||||
BY: {
|
||||
name: 'Belarus',
|
||||
sms: 0.118,
|
||||
},
|
||||
BZ: {
|
||||
name: 'Belize',
|
||||
sms: 0.1529,
|
||||
},
|
||||
CA: {
|
||||
name: 'Canada',
|
||||
sms: 0.0052,
|
||||
},
|
||||
CD: {
|
||||
name: 'Congo RDC',
|
||||
sms: 0.1112,
|
||||
},
|
||||
CF: {
|
||||
name: 'Central African Republic',
|
||||
sms: 0.2,
|
||||
},
|
||||
CG: {
|
||||
name: 'Congo',
|
||||
sms: 0.2507,
|
||||
},
|
||||
CH: {
|
||||
name: 'Switzerland',
|
||||
sms: 0.0163,
|
||||
},
|
||||
CI: {
|
||||
name: "Cote d'Ivoire",
|
||||
sms: 0.19,
|
||||
},
|
||||
CK: {
|
||||
name: 'Cook Islands',
|
||||
sms: 0.1041,
|
||||
},
|
||||
CL: {
|
||||
name: 'Chile',
|
||||
sms: 0.0027,
|
||||
},
|
||||
CM: {
|
||||
name: 'Cameroon',
|
||||
sms: 0.1504,
|
||||
},
|
||||
CN: {
|
||||
name: 'China',
|
||||
sms: 0.0053,
|
||||
},
|
||||
CO: {
|
||||
name: 'Colombia',
|
||||
sms: 0.0008,
|
||||
},
|
||||
CR: {
|
||||
name: 'Costa Rica',
|
||||
sms: 0.0045,
|
||||
},
|
||||
CU: {
|
||||
name: 'Cuba',
|
||||
sms: 0.0512,
|
||||
},
|
||||
CV: {
|
||||
name: 'Cabo Verde',
|
||||
sms: 0.2031,
|
||||
},
|
||||
CW: {
|
||||
name: 'Curacao',
|
||||
sms: 0.0138,
|
||||
},
|
||||
CY: {
|
||||
name: 'Northern Cyprus',
|
||||
sms: 0.0065,
|
||||
},
|
||||
CZ: {
|
||||
name: 'Czech Republic',
|
||||
sms: 0.0299,
|
||||
},
|
||||
DE: {
|
||||
name: 'Germany',
|
||||
sms: 0.0598,
|
||||
},
|
||||
DJ: {
|
||||
name: 'Djibouti',
|
||||
sms: 0.0897,
|
||||
},
|
||||
DK: {
|
||||
name: 'Denmark',
|
||||
sms: 0.0301,
|
||||
},
|
||||
DM: {
|
||||
name: 'Dominica',
|
||||
sms: 0.0824,
|
||||
},
|
||||
DO: {
|
||||
name: 'Dominican Republic',
|
||||
sms: 0.0351,
|
||||
},
|
||||
DZ: {
|
||||
name: 'Algeria',
|
||||
sms: 0.196,
|
||||
},
|
||||
EC: {
|
||||
name: 'Ecuador',
|
||||
sms: 0.1041,
|
||||
},
|
||||
EE: {
|
||||
name: 'Estonia',
|
||||
sms: 0.0234,
|
||||
},
|
||||
EG: {
|
||||
name: 'Egypt',
|
||||
sms: 0.1561,
|
||||
},
|
||||
ER: {
|
||||
name: 'Eritrea',
|
||||
sms: 0.0659,
|
||||
},
|
||||
ES: {
|
||||
name: 'Spain',
|
||||
sms: 0.0193,
|
||||
},
|
||||
ET: {
|
||||
name: 'Ethiopia',
|
||||
sms: 0.2741,
|
||||
},
|
||||
FI: {
|
||||
name: 'Finland',
|
||||
sms: 0.043,
|
||||
},
|
||||
FJ: {
|
||||
name: 'Fiji',
|
||||
sms: 0.069,
|
||||
},
|
||||
FK: {
|
||||
name: 'Falkland Islands',
|
||||
sms: 0.0713,
|
||||
},
|
||||
FM: {
|
||||
name: 'Micronesia',
|
||||
sms: 0.0118,
|
||||
},
|
||||
FO: {
|
||||
name: 'Faroe Islands',
|
||||
sms: 0.0319,
|
||||
},
|
||||
FR: {
|
||||
name: 'France',
|
||||
sms: 0.03,
|
||||
},
|
||||
GA: {
|
||||
name: 'Gabon',
|
||||
sms: 0.15,
|
||||
},
|
||||
GB: {
|
||||
name: 'United Kingdom',
|
||||
sms: 0.026,
|
||||
},
|
||||
GD: {
|
||||
name: 'Grenada',
|
||||
sms: null,
|
||||
},
|
||||
GE: {
|
||||
name: 'Georgia',
|
||||
sms: 0.0788,
|
||||
},
|
||||
GF: {
|
||||
name: 'French Guiana',
|
||||
sms: 0.05,
|
||||
},
|
||||
GG: {
|
||||
name: 'Guernsey',
|
||||
sms: 0.025,
|
||||
},
|
||||
GH: {
|
||||
name: 'Ghana',
|
||||
sms: 0.15,
|
||||
},
|
||||
GI: {
|
||||
name: 'Gibraltar',
|
||||
sms: 0.0134,
|
||||
},
|
||||
GL: {
|
||||
name: 'Greenland',
|
||||
sms: 0.0048,
|
||||
},
|
||||
GM: {
|
||||
name: 'Gambia',
|
||||
sms: 0.1283,
|
||||
},
|
||||
GN: {
|
||||
name: 'Guinea',
|
||||
sms: 0.2,
|
||||
},
|
||||
GP: {
|
||||
name: 'Guadeloupe',
|
||||
sms: 0.0455,
|
||||
},
|
||||
GQ: {
|
||||
name: 'Equatorial Guinea',
|
||||
sms: 0.099,
|
||||
},
|
||||
GR: {
|
||||
name: 'Greece',
|
||||
sms: 0.0296,
|
||||
},
|
||||
GT: {
|
||||
name: 'Guatemala',
|
||||
sms: 0.1202,
|
||||
},
|
||||
GU: {
|
||||
name: 'Guam',
|
||||
sms: 0.02,
|
||||
},
|
||||
GW: {
|
||||
name: 'Guinea-Bissau',
|
||||
sms: 0.1566,
|
||||
},
|
||||
GY: {
|
||||
name: 'Guyana',
|
||||
sms: 0.2178,
|
||||
},
|
||||
HK: {
|
||||
name: 'Hong Kong',
|
||||
sms: 0.038,
|
||||
},
|
||||
HN: {
|
||||
name: 'Honduras',
|
||||
sms: 0.162,
|
||||
},
|
||||
HR: {
|
||||
name: 'Croatia',
|
||||
sms: 0.03,
|
||||
},
|
||||
HT: {
|
||||
name: 'Haiti',
|
||||
sms: 0.09,
|
||||
},
|
||||
HU: {
|
||||
name: 'Hungary',
|
||||
sms: 0.029,
|
||||
},
|
||||
ID: {
|
||||
name: 'Indonesia',
|
||||
sms: 0.243,
|
||||
},
|
||||
IE: {
|
||||
name: 'Ireland',
|
||||
sms: 0.0305,
|
||||
},
|
||||
IL: {
|
||||
name: 'Israel',
|
||||
sms: 0.01,
|
||||
},
|
||||
IM: {
|
||||
name: 'Isle of Man',
|
||||
sms: 0.0385,
|
||||
},
|
||||
IN: {
|
||||
name: 'India',
|
||||
sms: 0.0375,
|
||||
},
|
||||
IQ: {
|
||||
name: 'Iraq',
|
||||
sms: 0.151,
|
||||
},
|
||||
IR: {
|
||||
name: 'Iran',
|
||||
sms: 0.14,
|
||||
},
|
||||
IS: {
|
||||
name: 'Iceland',
|
||||
sms: 0.0493,
|
||||
},
|
||||
IT: {
|
||||
name: 'Italy',
|
||||
sms: 0.0245,
|
||||
},
|
||||
JE: {
|
||||
name: 'Jersey',
|
||||
sms: 0.025,
|
||||
},
|
||||
JM: {
|
||||
name: 'Jamaica',
|
||||
sms: 0.1525,
|
||||
},
|
||||
JO: {
|
||||
name: 'Jordan',
|
||||
sms: 0.2094,
|
||||
},
|
||||
JP: {
|
||||
name: 'Japan',
|
||||
sms: 0.017,
|
||||
},
|
||||
KE: {
|
||||
name: 'Kenya',
|
||||
sms: 0.125,
|
||||
},
|
||||
KG: {
|
||||
name: 'Kyrgyzstan',
|
||||
sms: 0.15,
|
||||
},
|
||||
KH: {
|
||||
name: 'Cambodia',
|
||||
sms: 0.1529,
|
||||
},
|
||||
KI: {
|
||||
name: 'Kiribati',
|
||||
sms: 0.025,
|
||||
},
|
||||
KM: {
|
||||
name: 'Comoros',
|
||||
sms: 0.15,
|
||||
},
|
||||
KN: {
|
||||
name: 'Saint Kitts and Nevis',
|
||||
sms: 0.1295,
|
||||
},
|
||||
KR: {
|
||||
name: 'South Korea',
|
||||
sms: 0.006,
|
||||
},
|
||||
KW: {
|
||||
name: 'Kuwait',
|
||||
sms: 0.145,
|
||||
},
|
||||
KY: {
|
||||
name: 'Cayman Islands',
|
||||
sms: 0.2172,
|
||||
},
|
||||
KZ: {
|
||||
name: 'Kazakhstan',
|
||||
sms: 0.202,
|
||||
},
|
||||
LA: {
|
||||
name: 'Laos',
|
||||
sms: 0.15,
|
||||
},
|
||||
LB: {
|
||||
name: 'Lebanon',
|
||||
sms: 0.153,
|
||||
},
|
||||
LC: {
|
||||
name: 'Saint Lucia',
|
||||
sms: 0.0892,
|
||||
},
|
||||
LI: {
|
||||
name: 'Liechtenstein',
|
||||
sms: null,
|
||||
},
|
||||
LK: {
|
||||
name: 'Sri Lanka',
|
||||
sms: 0.3593,
|
||||
},
|
||||
LR: {
|
||||
name: 'Liberia',
|
||||
sms: 0.1448,
|
||||
},
|
||||
LS: {
|
||||
name: 'Lesotho',
|
||||
sms: 0.0301,
|
||||
},
|
||||
LT: {
|
||||
name: 'Lithuania',
|
||||
sms: 0.0261,
|
||||
},
|
||||
LU: {
|
||||
name: 'Luxembourg',
|
||||
sms: 0.034,
|
||||
},
|
||||
LV: {
|
||||
name: 'Latvia',
|
||||
sms: 0.028,
|
||||
},
|
||||
LY: {
|
||||
name: 'Libya',
|
||||
sms: 0.1898,
|
||||
},
|
||||
MA: {
|
||||
name: 'Morocco',
|
||||
sms: 0.105,
|
||||
},
|
||||
MC: {
|
||||
name: 'Monaco',
|
||||
sms: 0.1219,
|
||||
},
|
||||
MD: {
|
||||
name: 'Moldova',
|
||||
sms: 0.065,
|
||||
},
|
||||
ME: {
|
||||
name: 'Montenegro',
|
||||
sms: 0.07,
|
||||
},
|
||||
MG: {
|
||||
name: 'Madagascar',
|
||||
sms: 0.24,
|
||||
},
|
||||
MH: {
|
||||
name: '',
|
||||
sms: 0.032,
|
||||
},
|
||||
MK: {
|
||||
name: 'Macedonia',
|
||||
sms: 0.0096,
|
||||
},
|
||||
ML: {
|
||||
name: 'Mali',
|
||||
sms: 0.136,
|
||||
},
|
||||
MM: {
|
||||
name: 'Myanmar',
|
||||
sms: 0.3037,
|
||||
},
|
||||
MN: {
|
||||
name: 'Mongolia',
|
||||
sms: 0.183,
|
||||
},
|
||||
MO: {
|
||||
name: 'Macao',
|
||||
sms: 0.005,
|
||||
},
|
||||
MP: {
|
||||
name: 'Northern Mariana Islands',
|
||||
sms: 0.0669,
|
||||
},
|
||||
MQ: {
|
||||
name: 'Martinique',
|
||||
sms: 0.0455,
|
||||
},
|
||||
MR: {
|
||||
name: 'Mauritania',
|
||||
sms: 0.161,
|
||||
},
|
||||
MS: {
|
||||
name: 'Montserrat',
|
||||
sms: 0.0725,
|
||||
},
|
||||
MT: {
|
||||
name: 'Malta',
|
||||
sms: 0.0343,
|
||||
},
|
||||
MU: {
|
||||
name: 'Mauritius',
|
||||
sms: 0.1431,
|
||||
},
|
||||
MV: {
|
||||
name: 'Maldives',
|
||||
sms: 0.145,
|
||||
},
|
||||
MW: {
|
||||
name: 'Malawi',
|
||||
sms: 0.19,
|
||||
},
|
||||
MX: {
|
||||
name: 'Mexico',
|
||||
sms: 0.0021,
|
||||
},
|
||||
MY: {
|
||||
name: 'Malaysia',
|
||||
sms: 0.08,
|
||||
},
|
||||
MZ: {
|
||||
name: 'Mozambique',
|
||||
sms: 0.2266,
|
||||
},
|
||||
NA: {
|
||||
name: 'Namibia',
|
||||
sms: 0.0173,
|
||||
},
|
||||
NC: {
|
||||
name: 'New Caledonia',
|
||||
sms: 0.052,
|
||||
},
|
||||
NE: {
|
||||
name: 'Niger',
|
||||
sms: 0.143,
|
||||
},
|
||||
NG: {
|
||||
name: 'Nigeria',
|
||||
sms: 0.198,
|
||||
},
|
||||
NI: {
|
||||
name: 'Nicaragua',
|
||||
sms: 0.1033,
|
||||
},
|
||||
NL: {
|
||||
name: 'Netherlands',
|
||||
sms: 0.046,
|
||||
},
|
||||
NO: {
|
||||
name: 'Norway',
|
||||
sms: 0.03,
|
||||
},
|
||||
NP: {
|
||||
name: 'Nepal',
|
||||
sms: 0.1645,
|
||||
},
|
||||
NR: {
|
||||
name: 'Nauru',
|
||||
sms: null,
|
||||
},
|
||||
NU: {
|
||||
name: 'Niue',
|
||||
sms: null,
|
||||
},
|
||||
NZ: {
|
||||
name: 'New Zealand',
|
||||
sms: 0.0372,
|
||||
},
|
||||
OM: {
|
||||
name: 'Oman',
|
||||
sms: 0.0713,
|
||||
},
|
||||
PA: {
|
||||
name: 'Panama',
|
||||
sms: 0.07,
|
||||
},
|
||||
PE: {
|
||||
name: 'Peru',
|
||||
sms: 0.13,
|
||||
},
|
||||
PF: {
|
||||
name: 'French Polynesia',
|
||||
sms: 0.0518,
|
||||
},
|
||||
PG: {
|
||||
name: 'Papua New Guinea',
|
||||
sms: 0.15,
|
||||
},
|
||||
PH: {
|
||||
name: 'Philippines',
|
||||
sms: 0.1237,
|
||||
},
|
||||
PK: {
|
||||
name: 'Pakistan',
|
||||
sms: 0.3548,
|
||||
},
|
||||
PL: {
|
||||
name: 'Poland',
|
||||
sms: 0.011,
|
||||
},
|
||||
PM: {
|
||||
name: 'Saint Pierre and Miquelon',
|
||||
sms: 0.0905,
|
||||
},
|
||||
PR: {
|
||||
name: 'Puerto Rico',
|
||||
sms: 0.02,
|
||||
},
|
||||
PS: {
|
||||
name: 'Palestine',
|
||||
sms: 0.2541,
|
||||
},
|
||||
PT: {
|
||||
name: 'Portugal',
|
||||
sms: 0.009,
|
||||
},
|
||||
PW: {
|
||||
name: 'Palau',
|
||||
sms: null,
|
||||
},
|
||||
PY: {
|
||||
name: 'Paraguay',
|
||||
sms: 0.0236,
|
||||
},
|
||||
QA: {
|
||||
name: 'Qatar',
|
||||
sms: 0.1393,
|
||||
},
|
||||
RE: {
|
||||
name: 'Reunion',
|
||||
sms: 0.034,
|
||||
},
|
||||
RO: {
|
||||
name: 'Romania',
|
||||
sms: 0.0255,
|
||||
},
|
||||
RS: {
|
||||
name: 'Serbia',
|
||||
sms: 0.1935,
|
||||
},
|
||||
RU: {
|
||||
name: 'Russia',
|
||||
sms: 0.2028,
|
||||
},
|
||||
RW: {
|
||||
name: 'Rwanda',
|
||||
sms: 0.1177,
|
||||
},
|
||||
SA: {
|
||||
name: 'Saudi Arabia',
|
||||
sms: 0.0638,
|
||||
},
|
||||
SB: {
|
||||
name: 'Solomon Islands',
|
||||
sms: 0.0357,
|
||||
},
|
||||
SC: {
|
||||
name: 'Seychelles',
|
||||
sms: 0.0404,
|
||||
},
|
||||
SD: {
|
||||
name: 'Sudan',
|
||||
sms: 0.224,
|
||||
},
|
||||
SE: {
|
||||
name: 'Sweden',
|
||||
sms: 0.026,
|
||||
},
|
||||
SG: {
|
||||
name: 'Singapore',
|
||||
sms: 0.0256,
|
||||
},
|
||||
SI: {
|
||||
name: 'Slovenia',
|
||||
sms: 0.1,
|
||||
},
|
||||
SK: {
|
||||
name: 'Slovakia',
|
||||
sms: 0.0245,
|
||||
},
|
||||
SL: {
|
||||
name: 'Sierra Leone',
|
||||
sms: 0.2322,
|
||||
},
|
||||
SM: {
|
||||
name: 'San Marino',
|
||||
sms: null,
|
||||
},
|
||||
SN: {
|
||||
name: 'Senegal',
|
||||
sms: 0.1151,
|
||||
},
|
||||
SO: {
|
||||
name: 'Somalia',
|
||||
sms: 0.06,
|
||||
},
|
||||
SR: {
|
||||
name: 'Suriname',
|
||||
sms: 0.119,
|
||||
},
|
||||
SS: {
|
||||
name: 'South Sudan',
|
||||
sms: 0.15,
|
||||
},
|
||||
ST: {
|
||||
name: 'Sao Tome and Principe',
|
||||
sms: 0.0133,
|
||||
},
|
||||
SV: {
|
||||
name: 'El Salvador',
|
||||
sms: 0.06,
|
||||
},
|
||||
SX: {
|
||||
name: 'Sint Maarten',
|
||||
sms: 0.0623,
|
||||
},
|
||||
SY: {
|
||||
name: 'Syria',
|
||||
sms: 0.221,
|
||||
},
|
||||
SZ: {
|
||||
name: 'Eswatini',
|
||||
sms: 0.12,
|
||||
},
|
||||
TC: {
|
||||
name: 'Turks and Caicos Islands',
|
||||
sms: null,
|
||||
},
|
||||
TD: {
|
||||
name: 'Chad',
|
||||
sms: 0.1711,
|
||||
},
|
||||
TG: {
|
||||
name: 'Togo',
|
||||
sms: 0.1806,
|
||||
},
|
||||
TH: {
|
||||
name: 'Thailand',
|
||||
sms: 0.003,
|
||||
},
|
||||
TJ: {
|
||||
name: 'Tajikistan',
|
||||
sms: 0.2626,
|
||||
},
|
||||
TL: {
|
||||
name: 'Timor-Leste',
|
||||
sms: 0.0675,
|
||||
},
|
||||
TM: {
|
||||
name: 'Turkmenistan',
|
||||
sms: 0.1677,
|
||||
},
|
||||
TN: {
|
||||
name: 'Tunisia',
|
||||
sms: 0.225,
|
||||
},
|
||||
TO: {
|
||||
name: 'Tonga',
|
||||
sms: 0.0902,
|
||||
},
|
||||
TR: {
|
||||
name: 'Turkey',
|
||||
sms: 0.0008,
|
||||
},
|
||||
TT: {
|
||||
name: 'Trinidad and Tobago',
|
||||
sms: 0.1556,
|
||||
},
|
||||
TW: {
|
||||
name: 'Taiwan',
|
||||
sms: 0.0165,
|
||||
},
|
||||
TZ: {
|
||||
name: 'Tanzania',
|
||||
sms: 0.2753,
|
||||
},
|
||||
UA: {
|
||||
name: 'Ukraine',
|
||||
sms: 0.094,
|
||||
},
|
||||
UG: {
|
||||
name: 'Uganda',
|
||||
sms: 0.1707,
|
||||
},
|
||||
US: {
|
||||
name: 'United States',
|
||||
sms: 0.0043,
|
||||
},
|
||||
UY: {
|
||||
name: 'Uruguay',
|
||||
sms: 0.0174,
|
||||
},
|
||||
UZ: {
|
||||
name: 'Uzbekistan',
|
||||
sms: 0.292,
|
||||
},
|
||||
VC: {
|
||||
name: 'Saint Vincent and The Grenadines',
|
||||
sms: 0.1216,
|
||||
},
|
||||
VE: {
|
||||
name: 'Venezuela',
|
||||
sms: 0.0427,
|
||||
},
|
||||
VG: {
|
||||
name: 'British Virgin Islands',
|
||||
sms: 0.083,
|
||||
},
|
||||
VI: {
|
||||
name: 'Virgin Island',
|
||||
sms: 0.0017,
|
||||
},
|
||||
VN: {
|
||||
name: 'Vietnam',
|
||||
sms: 0.0885,
|
||||
},
|
||||
VU: {
|
||||
name: 'Vanuatu',
|
||||
sms: 0.1347,
|
||||
},
|
||||
WF: {
|
||||
name: 'Wallis and Futuna',
|
||||
sms: 0.09,
|
||||
},
|
||||
WS: {
|
||||
name: 'Samoa',
|
||||
sms: 0.2,
|
||||
},
|
||||
XK: {
|
||||
name: 'Kosovo',
|
||||
sms: 0.1394,
|
||||
},
|
||||
YE: {
|
||||
name: 'Yemen',
|
||||
sms: 0.1486,
|
||||
},
|
||||
YT: {
|
||||
name: 'Mayotte',
|
||||
sms: 0.06,
|
||||
},
|
||||
ZA: {
|
||||
name: 'South Africa',
|
||||
sms: 0.039,
|
||||
},
|
||||
ZM: {
|
||||
name: 'Zambia',
|
||||
sms: 0.21,
|
||||
},
|
||||
ZW: {
|
||||
name: 'Zimbabwe',
|
||||
sms: 0.13,
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
@@ -124,6 +125,14 @@ const makeRes = () => {
|
||||
const withActor = async <T>(actor: Actor, fn: () => Promise<T>): Promise<T> =>
|
||||
runWithContext({ actor }, fn);
|
||||
|
||||
const streamToString = async (stream: Readable): Promise<string> => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk as Buffer));
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
};
|
||||
|
||||
// ── /startBatchWrite ────────────────────────────────────────────────
|
||||
|
||||
describe('FSController.startBatchWrites', () => {
|
||||
@@ -549,9 +558,7 @@ describe('FSController.completeBatchWrites', () => {
|
||||
.body as SignedWriteResponse[];
|
||||
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
let updatedCall:
|
||||
| (typeof emitSpy.mock.calls)[number]
|
||||
| undefined;
|
||||
let updatedCall: (typeof emitSpy.mock.calls)[number] | undefined;
|
||||
try {
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
@@ -726,9 +733,9 @@ describe('FSController.statEntry', () => {
|
||||
...makeReq({ body: { path: '/x' }, actor }),
|
||||
actor: undefined,
|
||||
} as unknown as Request;
|
||||
await expect(
|
||||
controller.statEntry(req, res),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
await expect(controller.statEntry(req, res)).rejects.toMatchObject({
|
||||
statusCode: 401,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -898,9 +905,7 @@ describe('FSController.searchEntries', () => {
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
results.some(
|
||||
(r) => r.path === `/${username}/Documents/${needle}`,
|
||||
),
|
||||
results.some((r) => r.path === `/${username}/Documents/${needle}`),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1318,10 +1323,6 @@ describe('FSController.copyEntry', () => {
|
||||
// ── /read (readEntry, full read) ────────────────────────────────────
|
||||
|
||||
describe('FSController.readEntry (file streaming)', () => {
|
||||
// makeRes here adds a real Writable surface so that
|
||||
// `pipeline(download.body, res)` inside the controller can pipe
|
||||
// the in-memory S3 stream into the test's response and capture
|
||||
// bytes for assertions.
|
||||
const makeStreamingRes = () => {
|
||||
const captured = {
|
||||
statusCode: 200,
|
||||
@@ -1329,7 +1330,8 @@ describe('FSController.readEntry (file streaming)', () => {
|
||||
bodyChunks: [] as Buffer[],
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { Writable } = require('node:stream') as typeof import('node:stream');
|
||||
const { Writable } =
|
||||
require('node:stream') as typeof import('node:stream');
|
||||
const writable = new Writable({
|
||||
write(chunk: Buffer, _enc, cb) {
|
||||
captured.bodyChunks.push(chunk);
|
||||
@@ -1389,7 +1391,9 @@ describe('FSController.readEntry (file streaming)', () => {
|
||||
// Pipeline awaits the stream-end on success.
|
||||
expect(captured.statusCode).toBe(200);
|
||||
expect(captured.headers['Content-Type']).toMatch(/text\/plain/);
|
||||
expect(captured.headers['Content-Length']).toBe(String(body.byteLength));
|
||||
expect(captured.headers['Content-Length']).toBe(
|
||||
String(body.byteLength),
|
||||
);
|
||||
expect(captured.headers['Content-Disposition']).toMatch(
|
||||
/inline; filename=/,
|
||||
);
|
||||
@@ -1833,3 +1837,266 @@ describe('FSController.mkshortcutEntry', () => {
|
||||
expect(body.isShortcut).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FSController metadata.objectKey injection', () => {
|
||||
const writeFile = async (
|
||||
actor: Actor,
|
||||
path: string,
|
||||
content: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
) => {
|
||||
await withActor(actor, () =>
|
||||
controller.write(
|
||||
makeReq({
|
||||
body: {
|
||||
fileMetadata: {
|
||||
path,
|
||||
size: Buffer.byteLength(content),
|
||||
contentType: 'text/plain',
|
||||
overwrite: true,
|
||||
...(metadata ? { metadata } : {}),
|
||||
},
|
||||
fileContent: content,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
actor,
|
||||
}) as unknown as Request<
|
||||
Record<string, never>,
|
||||
null,
|
||||
import('./requestTypes.js').WriteRequest
|
||||
>,
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(path, {
|
||||
skipCache: true,
|
||||
});
|
||||
if (!entry) throw new Error(`entry not found after write: ${path}`);
|
||||
return entry;
|
||||
};
|
||||
|
||||
it("does not stream another user's file when a client injects metadata.objectKey on write", async () => {
|
||||
const victim = await makeUser();
|
||||
const attacker = await makeUser();
|
||||
const victimSecret = 'VICTIM-TOP-SECRET-PAYLOAD';
|
||||
const attackerDecoy = 'attacker-own-decoy-bytes';
|
||||
|
||||
const victimEntry = await writeFile(
|
||||
victim.actor,
|
||||
`/${victim.actor.user!.username}/Documents/secret.txt`,
|
||||
victimSecret,
|
||||
);
|
||||
|
||||
const victimRead = await server.services.fs.readContent(victimEntry);
|
||||
expect(await streamToString(victimRead.body)).toBe(victimSecret);
|
||||
|
||||
const attackerEntry = await writeFile(
|
||||
attacker.actor,
|
||||
`/${attacker.actor.user!.username}/Documents/loot.txt`,
|
||||
attackerDecoy,
|
||||
{ objectKey: victimEntry.uuid },
|
||||
);
|
||||
|
||||
const persisted = attackerEntry.metadata
|
||||
? (JSON.parse(attackerEntry.metadata) as Record<string, unknown>)
|
||||
: {};
|
||||
expect(persisted.objectKey).toBeUndefined();
|
||||
|
||||
const attackerRead =
|
||||
await server.services.fs.readContent(attackerEntry);
|
||||
const got = await streamToString(attackerRead.body);
|
||||
expect(got).toBe(attackerDecoy);
|
||||
expect(got).not.toBe(victimSecret);
|
||||
});
|
||||
|
||||
it('read path ignores a divergent metadata.objectKey on an already-poisoned row', async () => {
|
||||
const victim = await makeUser();
|
||||
const attacker = await makeUser();
|
||||
const victimSecret = 'VICTIM-SECRET-FOR-POISON-TEST';
|
||||
const attackerDecoy = 'attacker-decoy-for-poison-test';
|
||||
|
||||
const victimEntry = await writeFile(
|
||||
victim.actor,
|
||||
`/${victim.actor.user!.username}/Documents/secret2.txt`,
|
||||
victimSecret,
|
||||
);
|
||||
const attackerEntry = await writeFile(
|
||||
attacker.actor,
|
||||
`/${attacker.actor.user!.username}/Documents/loot2.txt`,
|
||||
attackerDecoy,
|
||||
);
|
||||
|
||||
await server.stores.fsEntry.updateEntry(attackerEntry.uuid, {
|
||||
metadata: JSON.stringify({ objectKey: victimEntry.uuid }),
|
||||
});
|
||||
const poisoned = await server.stores.fsEntry.getEntryByPath(
|
||||
attackerEntry.path,
|
||||
{ skipCache: true },
|
||||
);
|
||||
if (!poisoned) throw new Error('poisoned entry not found');
|
||||
expect(
|
||||
(JSON.parse(poisoned.metadata!) as { objectKey: string }).objectKey,
|
||||
).toBe(victimEntry.uuid);
|
||||
|
||||
const read = await server.services.fs.readContent(poisoned);
|
||||
expect(await streamToString(read.body)).toBe(attackerDecoy);
|
||||
});
|
||||
|
||||
it('scrubs objectKey from move newMetadata while preserving legit trash metadata', async () => {
|
||||
const victim = await makeUser();
|
||||
const attacker = await makeUser();
|
||||
const victimSecret = 'VICTIM-SECRET-FOR-MOVE-TEST';
|
||||
const attackerDecoy = 'attacker-decoy-for-move-test';
|
||||
const username = attacker.actor.user!.username!;
|
||||
|
||||
const victimEntry = await writeFile(
|
||||
victim.actor,
|
||||
`/${victim.actor.user!.username}/Documents/secret3.txt`,
|
||||
victimSecret,
|
||||
);
|
||||
const attackerEntry = await writeFile(
|
||||
attacker.actor,
|
||||
`/${username}/Documents/loot3.txt`,
|
||||
attackerDecoy,
|
||||
);
|
||||
const documents = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Documents`,
|
||||
{ skipCache: true },
|
||||
);
|
||||
if (!documents) throw new Error('Documents dir not found');
|
||||
|
||||
const moved = await withActor(attacker.actor, () =>
|
||||
server.services.fs.move(attacker.userId, {
|
||||
source: attackerEntry,
|
||||
destinationParent: documents,
|
||||
newName: 'loot3-moved.txt',
|
||||
newMetadata: {
|
||||
original_path: `/${username}/Documents/loot3.txt`,
|
||||
trashed_ts: 1700000000,
|
||||
objectKey: victimEntry.uuid,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const persisted = JSON.parse(moved.metadata!) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(persisted.objectKey).toBeUndefined();
|
||||
expect(persisted.original_path).toBe(
|
||||
`/${username}/Documents/loot3.txt`,
|
||||
);
|
||||
expect(persisted.trashed_ts).toBe(1700000000);
|
||||
|
||||
const read = await server.services.fs.readContent(moved);
|
||||
expect(await streamToString(read.body)).toBe(attackerDecoy);
|
||||
});
|
||||
});
|
||||
|
||||
// ── associatedAppId entitlement gate ────────────────────────────────
|
||||
//
|
||||
// `associatedAppId` is client-supplied write metadata that's echoed back in
|
||||
// legacy FS responses. Binding a file to another tenant's private app would
|
||||
// turn `/stat` into an app-row enumeration oracle, so the write path drops
|
||||
// any association the actor isn't entitled to make.
|
||||
|
||||
describe('FSController associatedAppId entitlement gate', () => {
|
||||
// Seed an app row with a direct insert. `create` treats is_private as a
|
||||
// read-only column, and going through the store would prime the cache —
|
||||
// a raw insert leaves nothing cached so the gate's getById reads the DB.
|
||||
const makeApp = async (
|
||||
ownerUserId: number,
|
||||
opts: { is_private?: boolean } = {},
|
||||
): Promise<{ id: number }> => {
|
||||
const uid = `app-${uuidv4()}`;
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
uid,
|
||||
uid,
|
||||
'Gate App',
|
||||
'https://gate-app.puter.site/',
|
||||
ownerUserId,
|
||||
opts.is_private ? 1 : 0,
|
||||
],
|
||||
);
|
||||
const row = (
|
||||
await server.clients.db.read(
|
||||
'SELECT id FROM apps WHERE uid = ?',
|
||||
[uid],
|
||||
)
|
||||
)[0] as { id: number };
|
||||
return { id: row.id };
|
||||
};
|
||||
|
||||
// Write a file via the signed-write flow with the given associatedAppId
|
||||
// and return the committed entry's stored associatedAppId.
|
||||
const writeWithAssociation = async (
|
||||
actor: Actor,
|
||||
path: string,
|
||||
associatedAppId: number,
|
||||
): Promise<number | null> => {
|
||||
const startRes = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.startBatchWrites(
|
||||
makeReq<SignedWriteRequest[]>({
|
||||
body: [{ fileMetadata: { path, size: 3, associatedAppId } }],
|
||||
actor,
|
||||
}),
|
||||
startRes.res,
|
||||
),
|
||||
);
|
||||
const [started] = startRes.captured.body as SignedWriteResponse[];
|
||||
const completeRes = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: [{ uploadId: started.sessionId }],
|
||||
actor,
|
||||
}),
|
||||
completeRes.res,
|
||||
),
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(path);
|
||||
return entry?.associatedAppId ?? null;
|
||||
};
|
||||
|
||||
it("drops an association to another tenant's private app", async () => {
|
||||
const victim = await makeUser();
|
||||
const attacker = await makeUser();
|
||||
const victimApp = await makeApp(victim.userId, { is_private: true });
|
||||
|
||||
const stored = await writeWithAssociation(
|
||||
attacker.actor,
|
||||
`/${attacker.actor.user!.username}/Documents/probe.txt`,
|
||||
victimApp.id,
|
||||
);
|
||||
expect(stored).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps an association to a public app the actor does not own', async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const publicApp = await makeApp(owner.userId, { is_private: false });
|
||||
|
||||
const stored = await writeWithAssociation(
|
||||
other.actor,
|
||||
`/${other.actor.user!.username}/Documents/public-assoc.txt`,
|
||||
publicApp.id,
|
||||
);
|
||||
expect(stored).toBe(publicApp.id);
|
||||
});
|
||||
|
||||
it('keeps an association to the actor’s own private app', async () => {
|
||||
const owner = await makeUser();
|
||||
const ownApp = await makeApp(owner.userId, { is_private: true });
|
||||
|
||||
const stored = await writeWithAssociation(
|
||||
owner.actor,
|
||||
`/${owner.actor.user!.username}/Documents/own-assoc.txt`,
|
||||
ownApp.id,
|
||||
);
|
||||
expect(stored).toBe(ownApp.id);
|
||||
});
|
||||
});
|
||||
@@ -119,6 +119,8 @@ export class FSController extends PuterController {
|
||||
requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(
|
||||
requestBody.fileMetadata,
|
||||
requestBody,
|
||||
undefined,
|
||||
userId,
|
||||
);
|
||||
await this.#assertWriteAccess(req, requestBody.fileMetadata, {
|
||||
pathAlreadyNormalized: true,
|
||||
@@ -183,6 +185,7 @@ export class FSController extends PuterController {
|
||||
normalizedRequestBody.fileMetadata,
|
||||
normalizedRequestBody,
|
||||
appUidLookupCache,
|
||||
userId,
|
||||
);
|
||||
return normalizedRequestBody;
|
||||
}),
|
||||
@@ -364,6 +367,8 @@ export class FSController extends PuterController {
|
||||
requestBody.fileMetadata = await this.#resolveAssociatedAppMetadata(
|
||||
requestBody.fileMetadata,
|
||||
requestBody,
|
||||
undefined,
|
||||
userId,
|
||||
);
|
||||
await this.#assertWriteAccess(req, requestBody.fileMetadata, {
|
||||
pathAlreadyNormalized: true,
|
||||
@@ -497,6 +502,7 @@ export class FSController extends PuterController {
|
||||
item.fileMetadata,
|
||||
item,
|
||||
appUidLookupCache,
|
||||
userId,
|
||||
),
|
||||
})),
|
||||
),
|
||||
@@ -718,6 +724,7 @@ export class FSController extends PuterController {
|
||||
normalizedRequestBody.fileMetadata,
|
||||
normalizedRequestBody,
|
||||
appUidLookupCache,
|
||||
userId,
|
||||
);
|
||||
return normalizedRequestBody;
|
||||
}),
|
||||
@@ -1695,6 +1702,7 @@ export class FSController extends PuterController {
|
||||
fileMetadata: FSEntryWriteInput,
|
||||
fallbackSource?: unknown,
|
||||
appUidLookupCache?: Map<string, Promise<number | null>>,
|
||||
actorUserId?: number,
|
||||
): Promise<FSEntryWriteInput> {
|
||||
const metadataRecord = this.#toObjectRecord(fileMetadata);
|
||||
const fallbackRecord = this.#toObjectRecord(fallbackSource);
|
||||
@@ -1708,10 +1716,11 @@ export class FSController extends PuterController {
|
||||
),
|
||||
);
|
||||
if (associatedAppId !== undefined) {
|
||||
return {
|
||||
...fileMetadata,
|
||||
return this.#withAssociatedAppId(
|
||||
fileMetadata,
|
||||
associatedAppId,
|
||||
};
|
||||
actorUserId,
|
||||
);
|
||||
}
|
||||
|
||||
const appUid = this.#firstDefined(
|
||||
@@ -1745,9 +1754,46 @@ export class FSController extends PuterController {
|
||||
if (resolvedAppId === null) {
|
||||
return fileMetadata;
|
||||
}
|
||||
return this.#withAssociatedAppId(
|
||||
fileMetadata,
|
||||
resolvedAppId,
|
||||
actorUserId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind `associatedAppId` onto the write input only when the actor is
|
||||
* entitled to reference that app. `associatedAppId` is client-supplied
|
||||
* and never trusted for authz, but it's echoed back in legacy FS
|
||||
* responses — so an attacker could plant another tenant's private app id
|
||||
* to confirm the row exists (an enumeration oracle) and harvest its
|
||||
* metadata. Allow binding to public apps (their existence isn't secret)
|
||||
* or to apps the actor owns; drop the association otherwise so the file
|
||||
* simply carries no associated app.
|
||||
*
|
||||
* `#resolveWriteFileMetadata` has already copied the raw client value onto
|
||||
* `fileMetadata`, so a dropped association must be actively stripped — not
|
||||
* merely left unset.
|
||||
*/
|
||||
async #withAssociatedAppId(
|
||||
fileMetadata: FSEntryWriteInput,
|
||||
appId: number,
|
||||
actorUserId?: number,
|
||||
): Promise<FSEntryWriteInput> {
|
||||
const app = await this.stores.app.getById(appId);
|
||||
const isPrivate =
|
||||
!!app && (Boolean(app.is_private) || Boolean(app.protected));
|
||||
const isOwner =
|
||||
actorUserId !== undefined &&
|
||||
this.#toNumber(app?.owner_user_id) === actorUserId;
|
||||
if (!app || (isPrivate && !isOwner)) {
|
||||
const { associatedAppId: _dropped, ...rest } =
|
||||
fileMetadata as unknown as Record<string, unknown>;
|
||||
return rest as unknown as FSEntryWriteInput;
|
||||
}
|
||||
return {
|
||||
...fileMetadata,
|
||||
associatedAppId: resolvedAppId,
|
||||
associatedAppId: appId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -765,6 +765,121 @@ describe('LegacyFSController.copy', () => {
|
||||
`/${username}/Pictures/renamed-copy`,
|
||||
);
|
||||
});
|
||||
|
||||
it('copies an empty file (no backing S3 object) without erroring', async () => {
|
||||
// Empty files created via /touch have size 0 and no S3 object —
|
||||
// bucket is null. Copy must clone them as empty-file entries rather
|
||||
// than issuing a CopyObject, which would throw S3 NoSuchKey.
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const src = `/${username}/Documents/empty.txt`;
|
||||
await withActor(actor, () =>
|
||||
controller.touch(
|
||||
makeReq({ body: { path: src }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.copy(
|
||||
makeReq({
|
||||
body: { source: src, destination: `/${username}/Pictures` },
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
|
||||
const body = captured.body as Array<{ copied: { path: string } }>;
|
||||
expect(body[0].copied.path).toBe(`/${username}/Pictures/empty.txt`);
|
||||
const copied = await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Pictures/empty.txt`,
|
||||
);
|
||||
expect(copied).not.toBeNull();
|
||||
expect(copied!.size).toBe(0);
|
||||
// Original is untouched.
|
||||
expect(await server.stores.fsEntry.getEntryByPath(src)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('copying a ghost file (S3 object missing) 404s and cleans up the orphan', async () => {
|
||||
// A real file whose backing S3 object has vanished keeps a non-null
|
||||
// bucket, so it isn't an empty file. CopyObject would throw S3
|
||||
// NoSuchKey; copy must surface a clean 404 and remove the orphan row
|
||||
// rather than bubbling a 500. Mirrors readContent's ghost handling.
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const src = `/${username}/Documents/ghost.txt`;
|
||||
const content = Buffer.from('i will be deleted from s3');
|
||||
await server.services.fs.write(userId, {
|
||||
fileMetadata: {
|
||||
path: src,
|
||||
size: content.byteLength,
|
||||
contentType: 'text/plain',
|
||||
},
|
||||
fileContent: content,
|
||||
});
|
||||
|
||||
// Delete the backing S3 object directly, leaving the DB row behind.
|
||||
const entry = (await server.stores.fsEntry.getEntryByPath(src))!;
|
||||
await server.stores.s3Object.deleteObject(
|
||||
server.stores.s3Object.resolveBucket(entry.bucket),
|
||||
entry.uuid,
|
||||
server.stores.s3Object.resolveRegion(entry.bucketRegion),
|
||||
);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
controller.copy(
|
||||
makeReq({
|
||||
body: {
|
||||
source: src,
|
||||
destination: `/${username}/Pictures`,
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
|
||||
// The ghost handler removed the orphaned source row.
|
||||
expect(await server.stores.fsEntry.getEntryByPath(src)).toBeNull();
|
||||
// No partial copy was left behind.
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(
|
||||
`/${username}/Pictures/ghost.txt`,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('reads an empty file as empty content without deleting it', async () => {
|
||||
// Reading an empty file (no S3 object) must not throw NoSuchKey nor
|
||||
// trip the ghost-file cleanup, which would delete the entry.
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const src = `/${username}/Documents/readme-empty.txt`;
|
||||
await withActor(actor, () =>
|
||||
controller.touch(
|
||||
makeReq({ body: { path: src }, actor }),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const entry = (await server.stores.fsEntry.getEntryByPath(src))!;
|
||||
|
||||
const download = await server.services.fs.readContent(entry);
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of download.body) {
|
||||
chunks.push(Buffer.from(chunk as Uint8Array));
|
||||
}
|
||||
expect(Buffer.concat(chunks).byteLength).toBe(0);
|
||||
expect(download.contentLength).toBe(0);
|
||||
// The entry must still exist — the ghost handler must NOT have run.
|
||||
expect(
|
||||
await server.stores.fsEntry.getEntryByPath(src),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── move ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -46,14 +46,6 @@ import {
|
||||
} from './legacyFsHelpers.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
|
||||
/**
|
||||
* Legacy FS routes, implemented as thin shims over `FSService`.
|
||||
*
|
||||
* Each shim parses the request shape (FSNodeParam-style `{ path, uid, id }`
|
||||
* or `{ parent, name }`), invokes the service method, and returns the
|
||||
* snake_case response clients expect.
|
||||
*/
|
||||
|
||||
type RouterCache = Map<string, RequestHandler | null>;
|
||||
|
||||
const additionalRoutePaths: Record<string, string> = {};
|
||||
@@ -166,8 +158,7 @@ export class LegacyFSController extends PuterController {
|
||||
|
||||
router.get('/get-launch-apps', apiOptions, async (req, res) => {
|
||||
const recommendedSvc = this.services.recommendedApps as unknown as
|
||||
| { getRecommendedApps?: () => Promise<unknown[]> }
|
||||
| undefined;
|
||||
{ getRecommendedApps?: () => Promise<unknown[]> } | undefined;
|
||||
const recommended = recommendedSvc?.getRecommendedApps
|
||||
? await recommendedSvc.getRecommendedApps()
|
||||
: [];
|
||||
@@ -202,6 +193,11 @@ export class LegacyFSController extends PuterController {
|
||||
godmode: Boolean(app.godmode),
|
||||
maximize_on_start: Boolean(app.maximize_on_start),
|
||||
index_url: app.index_url,
|
||||
// An app with no owner isn't owned by a Puter user —
|
||||
// it's an "external" (origin-bootstrapped) app.
|
||||
external:
|
||||
app.owner_user_id == null ||
|
||||
app.owner_user_id === '',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -563,9 +559,7 @@ export class LegacyFSController extends PuterController {
|
||||
// Trash, and `null`/`{}` when restoring. See
|
||||
// `src/gui/src/helpers.js` → `window.move_items`.
|
||||
newMetadata: (body.new_metadata ?? undefined) as
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined,
|
||||
Record<string, unknown> | null | undefined,
|
||||
});
|
||||
const oldPath = source.path;
|
||||
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
|
||||
@@ -981,8 +975,7 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
type SignedOrEmpty =
|
||||
| (SignedFile & { path?: string })
|
||||
| Record<string, never>;
|
||||
(SignedFile & { path?: string }) | Record<string, never>;
|
||||
const result: { signatures: SignedOrEmpty[]; token?: string } = {
|
||||
signatures: [],
|
||||
};
|
||||
@@ -1523,10 +1516,7 @@ export class LegacyFSController extends PuterController {
|
||||
const subjectRef = body.subject;
|
||||
const appRef = body.app;
|
||||
const mode = (getString(body, 'mode') ?? 'read') as
|
||||
| 'see'
|
||||
| 'list'
|
||||
| 'read'
|
||||
| 'write';
|
||||
'see' | 'list' | 'read' | 'write';
|
||||
if (!subjectRef || !appRef)
|
||||
throw new HttpError(400, '`subject` and `app` are required', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -1631,17 +1621,6 @@ export class LegacyFSController extends PuterController {
|
||||
download.body.pipe(res);
|
||||
};
|
||||
|
||||
// Helpers for writeFile
|
||||
// -- GUI event emission -------------------------------------------
|
||||
//
|
||||
// Fire-and-forget `outer.gui.item.*` events so SocketService,
|
||||
// BroadcastService, WorkerDriver (hot-reload), and cache-invalidation
|
||||
// listeners pick up mutations made through the legacy (bare-path) routes.
|
||||
// FSController (v2-native /fs/* routes) emits these from its own handlers;
|
||||
// LegacyFSController delegates to the same FSService but needs its
|
||||
// own emissions because the service layer deliberately doesn't emit GUI
|
||||
// events (that's a controller concern).
|
||||
|
||||
async #emitGuiEvent(
|
||||
eventName:
|
||||
| 'outer.gui.item.added'
|
||||
@@ -2215,120 +2194,6 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
// -- Helpers ---------------------------------------------------------
|
||||
|
||||
#parsePositiveIntegerQuery(
|
||||
query: Record<string, unknown>,
|
||||
key: string,
|
||||
message: string,
|
||||
): number | undefined {
|
||||
const value = query[key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new HttpError(400, message, { legacyCode: 'bad_request' });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
#parseNonNegativeIntegerQuery(
|
||||
query: Record<string, unknown>,
|
||||
key: string,
|
||||
message: string,
|
||||
): number | undefined {
|
||||
const value = query[key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(String(value), 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
throw new HttpError(400, message, { legacyCode: 'bad_request' });
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
#normalizeRangeHeader(rangeHeader: string): string | undefined {
|
||||
const firstRange = rangeHeader.includes(',')
|
||||
? rangeHeader.split(',')[0]?.trim()
|
||||
: rangeHeader.trim();
|
||||
if (!firstRange) return undefined;
|
||||
|
||||
const matches = firstRange.match(/^bytes=(\d+)-(\d*)$/);
|
||||
if (!matches) return undefined;
|
||||
|
||||
const [, start, end] = matches;
|
||||
return end ? `bytes=${start}-${end}` : `bytes=${start}-`;
|
||||
}
|
||||
|
||||
#pipeLimitedLines(
|
||||
source: NodeJS.ReadableStream,
|
||||
res: Response,
|
||||
lineCount: number,
|
||||
): void {
|
||||
let remainingLines = lineCount;
|
||||
let isClosed = false;
|
||||
|
||||
const closeSource = () => {
|
||||
if (isClosed) return;
|
||||
isClosed = true;
|
||||
if ('destroy' in source && typeof source.destroy === 'function') {
|
||||
source.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
source.on('error', (err) => {
|
||||
if (!isClosed) {
|
||||
isClosed = true;
|
||||
res.destroy(err);
|
||||
}
|
||||
});
|
||||
|
||||
res.on('close', () => {
|
||||
closeSource();
|
||||
});
|
||||
|
||||
source.on('data', (chunk: Buffer | string) => {
|
||||
if (isClosed) return;
|
||||
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
let endIndex = buffer.length;
|
||||
|
||||
for (let index = 0; index < buffer.length; index++) {
|
||||
if (buffer[index] !== 0x0a) continue;
|
||||
remainingLines -= 1;
|
||||
if (remainingLines === 0) {
|
||||
endIndex = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex > 0) {
|
||||
const canContinue = res.write(buffer.subarray(0, endIndex));
|
||||
if (!canContinue) {
|
||||
source.pause();
|
||||
res.once('drain', () => {
|
||||
if (!isClosed) {
|
||||
source.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex !== buffer.length) {
|
||||
res.end();
|
||||
closeSource();
|
||||
}
|
||||
});
|
||||
|
||||
source.on('end', () => {
|
||||
if (!isClosed) {
|
||||
isClosed = true;
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#requireActor(req: Request) {
|
||||
const actor = req.actor;
|
||||
if (!actor) {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import { loadLegacyAssociatedApps } from './legacyFsHelpers.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
|
||||
const entryWithApp = (associatedAppId: number): FSEntry =>
|
||||
({ associatedAppId }) as unknown as FSEntry;
|
||||
|
||||
const appRow = (overrides: Record<string, unknown>): Record<string, unknown> => ({
|
||||
id: 1,
|
||||
uid: 'app-1',
|
||||
owner_user_id: 99,
|
||||
app_owner: 99,
|
||||
icon: 'icon',
|
||||
name: 'an-app',
|
||||
title: 'An App',
|
||||
description: 'desc',
|
||||
godmode: 1,
|
||||
maximize_on_start: 1,
|
||||
index_url: 'https://an-app.puter.site/',
|
||||
background: 1,
|
||||
metadata: { secret: true },
|
||||
is_private: 0,
|
||||
protected: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const fakeStore = (row: Record<string, unknown>) => ({
|
||||
getByIds: async (ids: number[]) =>
|
||||
new Map(ids.map((id) => [id, { ...row, id }])),
|
||||
});
|
||||
|
||||
describe('loadLegacyAssociatedApps associated_app redaction', () => {
|
||||
it('never leaks owner identifiers, even for public apps', async () => {
|
||||
const out = await loadLegacyAssociatedApps(
|
||||
fakeStore(appRow({ is_private: 0, protected: 0 })),
|
||||
[entryWithApp(1)],
|
||||
);
|
||||
const app = out.get(1)!;
|
||||
expect(app).not.toHaveProperty('owner_user_id');
|
||||
expect(app).not.toHaveProperty('app_owner');
|
||||
});
|
||||
|
||||
it('passes through hosting + capability fields for public apps', async () => {
|
||||
const out = await loadLegacyAssociatedApps(
|
||||
fakeStore(appRow({ is_private: 0, protected: 0, godmode: 1 })),
|
||||
[entryWithApp(1)],
|
||||
);
|
||||
const app = out.get(1)!;
|
||||
expect(app.index_url).toBe('https://an-app.puter.site/');
|
||||
expect(app.godmode).toBe(1);
|
||||
expect(app.maximize_on_start).toBe(1);
|
||||
});
|
||||
|
||||
it('redacts hosting + capability fields for private apps', async () => {
|
||||
const out = await loadLegacyAssociatedApps(
|
||||
fakeStore(appRow({ is_private: 1, godmode: 1 })),
|
||||
[entryWithApp(1)],
|
||||
);
|
||||
const app = out.get(1)!;
|
||||
// Existence + display fields still surface...
|
||||
expect(app.uid).toBe('app-1');
|
||||
expect(app.name).toBe('an-app');
|
||||
expect(app.is_private).toBe(1);
|
||||
// ...but the sensitive bits are stripped.
|
||||
expect(app.index_url).toBeNull();
|
||||
expect(app.godmode).toBe(0);
|
||||
expect(app.maximize_on_start).toBe(0);
|
||||
expect(app.background).toBe(0);
|
||||
expect(app.metadata).toBeNull();
|
||||
expect(app).not.toHaveProperty('owner_user_id');
|
||||
});
|
||||
|
||||
it('redacts the same fields for protected apps', async () => {
|
||||
const out = await loadLegacyAssociatedApps(
|
||||
fakeStore(appRow({ is_private: 0, protected: 1, godmode: 1 })),
|
||||
[entryWithApp(1)],
|
||||
);
|
||||
const app = out.get(1)!;
|
||||
expect(app.protected).toBe(1);
|
||||
expect(app.index_url).toBeNull();
|
||||
expect(app.godmode).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -274,21 +274,33 @@ const toIntBool = (v: unknown): number => (v ? 1 : 0);
|
||||
* embedded in legacy FS entries. Booleans round-trip back to integers (0/1)
|
||||
* because the v1 wire contract emits them that way and existing clients key
|
||||
* off it. Other columns pass through as-is — `metadata` is already parsed.
|
||||
*
|
||||
* Two redactions, because `associatedAppId` can name an app the actor does
|
||||
* not own (the column is client-writable display metadata, never trusted for
|
||||
* authz — see FSController) and may point cross-tenant:
|
||||
* - Owner identifiers (`owner_user_id`, `app_owner`) are never emitted —
|
||||
* they're internal user references with no client-side display use.
|
||||
* - For private or protected apps, the direct hosting URL and launch
|
||||
* capability flags (`index_url`, `godmode`, `maximize_on_start`,
|
||||
* `background`, `metadata`) are dropped. This mirrors AppDriver, which
|
||||
* withholds `index_url` from callers without read entitlement. Display
|
||||
* fields (name, icon, title) still pass through so the GUI can label the
|
||||
* file's associated app.
|
||||
*/
|
||||
function mapAppForLegacyAssociatedApp(
|
||||
app: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const gated = Boolean(app.is_private) || Boolean(app.protected);
|
||||
return {
|
||||
id: app.id,
|
||||
uid: app.uid,
|
||||
owner_user_id: app.owner_user_id,
|
||||
icon: app.icon,
|
||||
name: app.name,
|
||||
title: app.title,
|
||||
description: app.description,
|
||||
godmode: toIntBool(app.godmode),
|
||||
maximize_on_start: toIntBool(app.maximize_on_start),
|
||||
index_url: app.index_url,
|
||||
godmode: gated ? 0 : toIntBool(app.godmode),
|
||||
maximize_on_start: gated ? 0 : toIntBool(app.maximize_on_start),
|
||||
index_url: gated ? null : app.index_url,
|
||||
approved_for_listing: toIntBool(app.approved_for_listing),
|
||||
approved_for_opening_items: toIntBool(app.approved_for_opening_items),
|
||||
approved_for_incentive_program: toIntBool(
|
||||
@@ -297,9 +309,8 @@ function mapAppForLegacyAssociatedApp(
|
||||
timestamp: app.timestamp ?? null,
|
||||
last_review: app.last_review ?? null,
|
||||
tags: app.tags ?? null,
|
||||
app_owner: app.app_owner ?? null,
|
||||
background: toIntBool(app.background),
|
||||
metadata: app.metadata ?? null,
|
||||
background: gated ? 0 : toIntBool(app.background),
|
||||
metadata: gated ? null : (app.metadata ?? null),
|
||||
protected: toIntBool(app.protected),
|
||||
is_private: toIntBool(app.is_private),
|
||||
};
|
||||
|
||||
@@ -165,6 +165,43 @@ describe('HomepageController shell routes', () => {
|
||||
expect(html).toContain('Puter');
|
||||
});
|
||||
|
||||
it('exposes disable_temp_users to the GUI when signups are disabled', async () => {
|
||||
const homepageConfig = server.controllers.homepage.config as {
|
||||
disable_user_signup?: boolean;
|
||||
};
|
||||
const prev = homepageConfig.disable_user_signup;
|
||||
homepageConfig.disable_user_signup = true;
|
||||
const { res, captured } = makeRes();
|
||||
try {
|
||||
await callRoute('get', '/', makeReq({ path: '/' }), res);
|
||||
} finally {
|
||||
homepageConfig.disable_user_signup = prev;
|
||||
}
|
||||
expect(String(captured.body)).toContain('"disable_temp_users":true');
|
||||
});
|
||||
|
||||
it('keeps an operator-set gui_params.disable_temp_users when the flag is off', async () => {
|
||||
const homepageConfig = server.controllers.homepage.config as {
|
||||
disable_user_signup?: boolean;
|
||||
gui_params?: Record<string, unknown>;
|
||||
};
|
||||
const prevFlag = homepageConfig.disable_user_signup;
|
||||
const prevGuiParams = homepageConfig.gui_params;
|
||||
homepageConfig.disable_user_signup = false;
|
||||
homepageConfig.gui_params = {
|
||||
...prevGuiParams,
|
||||
disable_temp_users: true,
|
||||
};
|
||||
const { res, captured } = makeRes();
|
||||
try {
|
||||
await callRoute('get', '/', makeReq({ path: '/' }), res);
|
||||
} finally {
|
||||
homepageConfig.disable_user_signup = prevFlag;
|
||||
homepageConfig.gui_params = prevGuiParams;
|
||||
}
|
||||
expect(String(captured.body)).toContain('"disable_temp_users":true');
|
||||
});
|
||||
|
||||
it('still serves the shell when an authenticated actor is present', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
@@ -173,12 +210,14 @@ describe('HomepageController shell routes', () => {
|
||||
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
|
||||
});
|
||||
|
||||
it('serves the shell on /settings, /dashboard, /action, /@:username', async () => {
|
||||
it('serves the shell on /settings, /dashboard, /desktop, /action, /@:username', async () => {
|
||||
for (const path of [
|
||||
'/settings',
|
||||
'/settings/*splat',
|
||||
'/dashboard',
|
||||
'/dashboard/',
|
||||
'/desktop',
|
||||
'/desktop/',
|
||||
'/action/*splat',
|
||||
'/@:username',
|
||||
]) {
|
||||
|
||||
@@ -82,6 +82,9 @@ export class HomepageController extends PuterController {
|
||||
router.get('/dashboard', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/dashboard/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/desktop', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/desktop/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/action/*splat', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
router.get('/@:username', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
@@ -139,6 +139,19 @@ export class OIDCController extends PuterController {
|
||||
|
||||
let appRedirectUri = flowRedirects[flow] ?? (origin || '/');
|
||||
|
||||
// Optional GUI return path so login started from /desktop or
|
||||
// /dashboard lands back there. Strict whitelist — never a
|
||||
// client-supplied URL (no open redirect).
|
||||
const rawReturnTo = Array.isArray(req.query.return_to)
|
||||
? req.query.return_to[0]
|
||||
: req.query.return_to;
|
||||
if (
|
||||
(flow === 'login' || flow === 'signup') &&
|
||||
(rawReturnTo === '/desktop' || rawReturnTo === '/dashboard')
|
||||
) {
|
||||
appRedirectUri = `${origin}${rawReturnTo}`;
|
||||
}
|
||||
|
||||
// Popup support
|
||||
const rawPopup = Array.isArray(req.query.embedded_in_popup)
|
||||
? req.query.embedded_in_popup[0]
|
||||
|
||||
@@ -1506,6 +1506,212 @@ describe('PuterAIController.anthropicMessages streaming + helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Inline compaction ───────────────────────────────────────────────
|
||||
|
||||
describe('PuterAIController inline compaction', () => {
|
||||
// Extract the single `event: compaction\ndata: {...}` SSE frame.
|
||||
const compactionFrame = (out: string): string | null => {
|
||||
const m = out.match(/event: compaction\ndata: [^\n]*\n\n/);
|
||||
return m ? m[0] : null;
|
||||
};
|
||||
|
||||
it('forwards the compaction opt-in to the driver (/responses)', async () => {
|
||||
const spy = stubChatComplete({
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
await controller.openaiResponses(
|
||||
makeReq({
|
||||
body: { model: 'gpt-test', input: 'hi', compaction: true },
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
makeRes().res,
|
||||
);
|
||||
expect(spy.mock.calls[0]![0].compaction).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trips a compaction `input` item into a messages compaction item', async () => {
|
||||
const spy = stubChatComplete({
|
||||
message: { role: 'assistant', content: 'ack' },
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
await controller.openaiResponses(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'gpt-test',
|
||||
input: [
|
||||
{
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
},
|
||||
],
|
||||
},
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
makeRes().res,
|
||||
);
|
||||
expect(spy.mock.calls[0]![0].messages).toContainEqual({
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a canonical compaction SSE event and output item (/responses stream)', async () => {
|
||||
stubChatComplete({
|
||||
dataType: 'stream',
|
||||
content_type: 'application/x-ndjson',
|
||||
stream: ndjsonStreamFrom([
|
||||
{ type: 'text', text: 'hi' },
|
||||
{ type: 'compaction', id: 'cmpct_9', encrypted_content: 'ENC9' },
|
||||
{ type: 'usage', usage: { prompt_tokens: 1, completion_tokens: 1 } },
|
||||
]),
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await controller.openaiResponses(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'gpt-test',
|
||||
input: 'hi',
|
||||
stream: true,
|
||||
compaction: true,
|
||||
},
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
res,
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
const out = captured.written.join('');
|
||||
expect(compactionFrame(out)).toBe(
|
||||
'event: compaction\ndata: {"type":"compaction","id":"cmpct_9","encrypted_content":"ENC9"}\n\n',
|
||||
);
|
||||
// Native shape also lands in the final response.completed output[].
|
||||
const completed = out
|
||||
.split('event: response.completed\n')[1]
|
||||
?.split('\n\n')[0];
|
||||
expect(completed).toContain('"type":"compaction"');
|
||||
expect(completed).toContain('"encrypted_content":"ENC9"');
|
||||
});
|
||||
|
||||
it('emits the compaction item in non-streaming /responses output', async () => {
|
||||
stubChatComplete({
|
||||
message: { role: 'assistant', content: 'done' },
|
||||
finish_reason: 'stop',
|
||||
compaction: { id: 'cmpct_n', encrypted_content: 'ENCN' },
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await controller.openaiResponses(
|
||||
makeReq({
|
||||
body: { model: 'gpt-test', input: 'hi', compaction: true },
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
res,
|
||||
);
|
||||
const body = captured.body as { output: Array<Record<string, unknown>> };
|
||||
expect(body.output).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: 'compaction',
|
||||
encrypted_content: 'ENCN',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an identical canonical compaction SSE event on the Anthropic surface', async () => {
|
||||
// /responses frame
|
||||
stubChatComplete({
|
||||
dataType: 'stream',
|
||||
content_type: 'application/x-ndjson',
|
||||
stream: ndjsonStreamFrom([
|
||||
{ type: 'compaction', id: 'cmpct_x', encrypted_content: 'ENCX' },
|
||||
]),
|
||||
});
|
||||
const r1 = makeRes();
|
||||
await controller.openaiResponses(
|
||||
makeReq({
|
||||
body: { model: 'gpt-test', input: 'hi', stream: true },
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
r1.res,
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
// /anthropic/v1/messages frame
|
||||
stubChatComplete({
|
||||
dataType: 'stream',
|
||||
content_type: 'application/x-ndjson',
|
||||
stream: ndjsonStreamFrom([
|
||||
{ type: 'compaction', id: 'cmpct_x', encrypted_content: 'ENCX' },
|
||||
]),
|
||||
});
|
||||
const r2 = makeRes();
|
||||
await controller.anthropicMessages(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'claude-test',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
},
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
r2.res,
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
const a = compactionFrame(r1.captured.written.join(''));
|
||||
const b = compactionFrame(r2.captured.written.join(''));
|
||||
expect(a).not.toBeNull();
|
||||
expect(a).toBe(b); // byte-identical streaming shape across providers
|
||||
});
|
||||
|
||||
it('renders a native compaction content block in non-streaming /messages', async () => {
|
||||
stubChatComplete({
|
||||
message: { role: 'assistant', content: 'done' },
|
||||
finish_reason: 'stop',
|
||||
compaction: { id: 'cmpct_m', encrypted_content: 'ENCM' },
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await controller.anthropicMessages(
|
||||
makeReq({
|
||||
body: {
|
||||
model: 'claude-test',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
},
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
res,
|
||||
);
|
||||
const body = captured.body as { content: Array<Record<string, unknown>> };
|
||||
expect(body.content).toContainEqual({
|
||||
type: 'compaction',
|
||||
id: 'cmpct_m',
|
||||
encrypted_content: 'ENCM',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not emit compaction frames for a normal stream (regression)', async () => {
|
||||
stubChatComplete({
|
||||
dataType: 'stream',
|
||||
content_type: 'application/x-ndjson',
|
||||
stream: ndjsonStreamFrom([
|
||||
{ type: 'text', text: 'hello' },
|
||||
{ type: 'usage', usage: { prompt_tokens: 1, completion_tokens: 1 } },
|
||||
]),
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await controller.openaiResponses(
|
||||
makeReq({
|
||||
body: { model: 'gpt-test', input: 'hi', stream: true },
|
||||
actor: makeUserActor(),
|
||||
}),
|
||||
res,
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(captured.written.join('')).not.toContain('event: compaction');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Model details listing ───────────────────────────────────────────
|
||||
|
||||
describe('PuterAIController model details', () => {
|
||||
|
||||
@@ -587,6 +587,15 @@ export class PuterAIController extends PuterController {
|
||||
? { metadata: body.metadata as Record<string, string> }
|
||||
: {}),
|
||||
...(body.conversation ? { conversation: body.conversation } : {}),
|
||||
...(body.context_management !== undefined
|
||||
? { context_management: body.context_management }
|
||||
: {}),
|
||||
...(body.compaction !== undefined
|
||||
? {
|
||||
compaction:
|
||||
body.compaction as ICompleteArguments['compaction'],
|
||||
}
|
||||
: {}),
|
||||
...(body.previous_response_id
|
||||
? { previous_response_id: String(body.previous_response_id) }
|
||||
: {}),
|
||||
@@ -750,6 +759,30 @@ export class PuterAIController extends PuterController {
|
||||
output_index: outputIndex,
|
||||
item,
|
||||
});
|
||||
} else if (ev.type === 'compaction') {
|
||||
// Native shape in the final `output[]`, plus the
|
||||
// canonical SSE event shared with the Anthropic surface.
|
||||
const item = {
|
||||
type: 'compaction',
|
||||
...(ev.id !== undefined ? { id: ev.id } : {}),
|
||||
encrypted_content: ev.encrypted_content,
|
||||
};
|
||||
const outputIndex = output.length;
|
||||
output.push(item);
|
||||
sendEvent({
|
||||
type: 'response.output_item.added',
|
||||
output_index: outputIndex,
|
||||
item,
|
||||
});
|
||||
sendEvent({
|
||||
type: 'response.output_item.done',
|
||||
output_index: outputIndex,
|
||||
item,
|
||||
});
|
||||
writeCompactionEvent(res, {
|
||||
id: ev.id,
|
||||
encrypted_content: ev.encrypted_content,
|
||||
});
|
||||
} else if (ev.type === 'usage') {
|
||||
usage = buildResponsesUsage(
|
||||
ev.usage as Record<string, unknown>,
|
||||
@@ -869,6 +902,15 @@ export class PuterAIController extends PuterController {
|
||||
...(body.max_tokens !== undefined
|
||||
? { max_tokens: Number(body.max_tokens) }
|
||||
: {}),
|
||||
...(body.context_management !== undefined
|
||||
? { context_management: body.context_management }
|
||||
: {}),
|
||||
...(body.compaction !== undefined
|
||||
? {
|
||||
compaction:
|
||||
body.compaction as ICompleteArguments['compaction'],
|
||||
}
|
||||
: {}),
|
||||
...(body.provider
|
||||
? { provider: toStringOrEmpty(body.provider) }
|
||||
: { provider: DEFAULTS.anthropic }),
|
||||
@@ -968,6 +1010,14 @@ export class PuterAIController extends PuterController {
|
||||
},
|
||||
});
|
||||
closeBlock();
|
||||
} else if (ev.type === 'compaction') {
|
||||
// Close any open content block, then emit the canonical
|
||||
// compaction SSE event — byte-identical to /responses.
|
||||
closeBlock();
|
||||
writeCompactionEvent(res, {
|
||||
id: ev.id,
|
||||
encrypted_content: ev.encrypted_content,
|
||||
});
|
||||
} else if (ev.type === 'usage') {
|
||||
usage = ev.usage as Record<string, unknown>;
|
||||
}
|
||||
@@ -1026,6 +1076,18 @@ export class PuterAIController extends PuterController {
|
||||
if (textContent)
|
||||
contentBlocks.push({ type: 'text', text: textContent });
|
||||
contentBlocks.push(...toolUseBlocks);
|
||||
// Native Anthropic-shaped compaction block (non-streaming bodies stay
|
||||
// provider-native, unlike the unified streaming event).
|
||||
const compaction = (
|
||||
messageResult as { compaction?: Record<string, unknown> }
|
||||
).compaction;
|
||||
if (compaction) {
|
||||
contentBlocks.push({
|
||||
type: 'compaction',
|
||||
...(compaction.id !== undefined ? { id: compaction.id } : {}),
|
||||
encrypted_content: compaction.encrypted_content,
|
||||
});
|
||||
}
|
||||
if (contentBlocks.length === 0)
|
||||
contentBlocks.push({ type: 'text', text: '' });
|
||||
|
||||
@@ -1082,6 +1144,25 @@ const setSseHeaders = (res: Response): void => {
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
};
|
||||
|
||||
/**
|
||||
* Inline-compaction is emitted in a single canonical SSE shape that is
|
||||
* byte-identical across the `/responses` and `/anthropic/v1/messages` streaming
|
||||
* surfaces, so a streaming client parses compaction the same way regardless of
|
||||
* which upstream served the request. (Non-streaming bodies stay provider-native.)
|
||||
*/
|
||||
const writeCompactionEvent = (
|
||||
res: Response,
|
||||
compaction: { id?: unknown; encrypted_content?: unknown },
|
||||
): void => {
|
||||
const payload = {
|
||||
type: 'compaction',
|
||||
...(compaction.id !== undefined ? { id: compaction.id } : {}),
|
||||
encrypted_content: compaction.encrypted_content,
|
||||
};
|
||||
res.write(`event: compaction\n`);
|
||||
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
|
||||
/**
|
||||
* The chat driver returns either a stream-result envelope or a plain
|
||||
* message result. Proxy routes invoked with `stream: true` expect the
|
||||
@@ -1325,6 +1406,17 @@ const responseInputToMessages = (input: unknown): unknown[] => {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const it = item as Record<string, unknown>;
|
||||
|
||||
if (it.type === 'compaction') {
|
||||
// Round-tripped compaction artifact: preserve it as a bare item so
|
||||
// `normalize_single_message` wraps it into an internal compaction
|
||||
// content block (providers map it back to their native input shape).
|
||||
messages.push({
|
||||
type: 'compaction',
|
||||
...(it.id !== undefined ? { id: it.id } : {}),
|
||||
encrypted_content: it.encrypted_content,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (it.type === 'function_call_output') {
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
@@ -1416,6 +1508,16 @@ const responseOutputFromResult = (
|
||||
});
|
||||
}
|
||||
|
||||
const compaction = (result as { compaction?: Record<string, unknown> })
|
||||
.compaction;
|
||||
if (compaction) {
|
||||
output.push({
|
||||
id: (compaction.id as string | undefined) || generateId('cmpct'),
|
||||
type: 'compaction',
|
||||
encrypted_content: compaction.encrypted_content,
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ export class SystemController extends PuterController {
|
||||
name: 'Puter',
|
||||
version: this.config.version ?? null,
|
||||
environment: this.config.env ?? 'prod',
|
||||
disable_user_signup: Boolean(this.config.disable_user_signup),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -233,6 +233,7 @@ describe('SystemController GET /whoarewe', () => {
|
||||
expect(captured.body).toMatchObject({
|
||||
name: 'Puter',
|
||||
environment: 'dev',
|
||||
disable_user_signup: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Request, Response } from 'express';
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { hash as bcryptHash } from 'bcrypt';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
@@ -228,6 +229,112 @@ describe('WebDAVController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('pending-verification gate', () => {
|
||||
// WebDAV must enforce the same gate every other authenticated route
|
||||
// gets from requireVerifiedAccount — it dispatches off a single use()
|
||||
// with no route options, so the middleware is never wired in and it
|
||||
// has to call assertVerifiedAccount itself. Without it, an account
|
||||
// still pending email/phone/card verification could read/write its
|
||||
// whole filesystem over the `dav` subdomain.
|
||||
const gatedActor = (flags: Record<string, unknown>) => ({
|
||||
user: {
|
||||
id: 1,
|
||||
uuid: 'gated-uuid',
|
||||
username: 'gated',
|
||||
...flags,
|
||||
},
|
||||
});
|
||||
|
||||
it('rejects a session actor pending phone verification with 403', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
actor: gatedActor({ requires_phone_verification: true }),
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a session actor pending card verification with 403', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'GET',
|
||||
actor: gatedActor({ requires_card_verification: true }),
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a session actor with an unconfirmed email with 403', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
actor: gatedActor({
|
||||
requires_email_confirmation: true,
|
||||
email_confirmed: false,
|
||||
}),
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('enforces the gate on the Basic-auth path (flags carried onto the built actor)', async () => {
|
||||
const username = `webdav-gated-${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 server.stores.user.update(created.id, {
|
||||
requires_phone_verification: 1,
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
authorization: basicAuth(username, 'correct-horse'),
|
||||
},
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('lets a fully-verified session actor through the gate', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'OPTIONS',
|
||||
actor: gatedActor({
|
||||
requires_phone_verification: false,
|
||||
requires_card_verification: false,
|
||||
requires_email_confirmation: false,
|
||||
}),
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported methods', () => {
|
||||
it('returns 405 for unknown HTTP methods', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -23,6 +23,7 @@ import { posix as pathPosix } from 'node:path';
|
||||
import { EventMap } from '../../clients/event/types.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { assertVerifiedAccount } from '../../core/http/middleware/gates.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { verify as verifyOtp } from '../../services/auth/OTPUtil.js';
|
||||
import { expandTildePath } from '../../services/fs/resolveNode.js';
|
||||
@@ -89,6 +90,16 @@ export class WebDAVController extends PuterController {
|
||||
const actor = await this.#resolveActor(req, res);
|
||||
if (!actor) return; // 401 already sent
|
||||
|
||||
// Apply the same pending-verification gate every other authenticated
|
||||
// route gets from `requireVerifiedAccount`. WebDAV dispatches every
|
||||
// method off a single `router.use` with no route options, so that
|
||||
// middleware is never inserted into its chain — without this call an
|
||||
// account still pending email / phone / card verification could read,
|
||||
// write, and delete its entire filesystem over the `dav` subdomain,
|
||||
// bypassing the gate. Throws a 403 HttpError, surfaced by the catch in
|
||||
// registerRoutes.
|
||||
assertVerifiedAccount(actor.user);
|
||||
|
||||
// Expand `~`/`~/...` against the authenticated actor's username.
|
||||
// WebDAV doesn't standardize `~`, but some clients do — and the
|
||||
// pre-existing behaviour silently expanded it via the FS store.
|
||||
@@ -233,6 +244,15 @@ export class WebDAVController extends PuterController {
|
||||
email_confirmed: user.email_confirmed ?? false,
|
||||
requires_email_confirmation:
|
||||
user.requires_email_confirmation ?? false,
|
||||
// Carry the signup-time verification flags so the
|
||||
// assertVerifiedAccount() gate in #dispatch can see them on
|
||||
// the Basic-auth path too (the cookie / `-token` paths get
|
||||
// them from AuthService#actorUserFromRow). Omitting these is
|
||||
// what let phone/card-gated accounts through WebDAV.
|
||||
requires_phone_verification:
|
||||
user.requires_phone_verification ?? false,
|
||||
requires_card_verification:
|
||||
user.requires_card_verification ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,6 +42,13 @@ declare global {
|
||||
|
||||
tokenAuthFailed?: boolean;
|
||||
|
||||
/**
|
||||
* Set when a token authenticated but its app is on the origin
|
||||
* blocklist. The auth probe leaves `actor` unset; gates translate
|
||||
* this into a 403 `app_blocked`.
|
||||
*/
|
||||
appBlocked?: { reason?: string };
|
||||
|
||||
requiresReauth?: {
|
||||
reason: 'token_v1' | 'session_revoked' | 'session_expired';
|
||||
auth_id?: string;
|
||||
@@ -66,6 +73,21 @@ declare global {
|
||||
/** True when the request's Host is a custom domain (not one of the configured Puter domains). */
|
||||
is_custom_domain?: boolean;
|
||||
|
||||
/**
|
||||
* Coarse server-derived request fingerprint (IP + UA + accept
|
||||
* headers), populated by the global fingerprint middleware. Always
|
||||
* present; the same value the rate limiter keys on.
|
||||
*/
|
||||
networkFingerprint?: string;
|
||||
|
||||
/**
|
||||
* Client-supplied device fingerprint (ThumbmarkJS hash) from the
|
||||
* body or `x-puter-device-fingerprint` header, populated by the
|
||||
* global fingerprint middleware. Present only when the client sent a
|
||||
* well-shaped value; spoofable but stable per device across IPs.
|
||||
*/
|
||||
deviceFingerprint?: string;
|
||||
|
||||
/** Parsed cookies, populated by the global `cookie-parser` middleware. */
|
||||
cookies?: Record<string, string>;
|
||||
}
|
||||
|
||||
@@ -545,30 +545,10 @@ describe('createAuthProbe — actor attachment + failure tracking', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Reauth signal + KV counters ─────────────────────────────────────
|
||||
// ── Reauth signal ───────────────────────────────────────────────────
|
||||
|
||||
describe('createAuthProbe — reauth signal', () => {
|
||||
/** Capture KV increments without a real store. */
|
||||
const makeKvStub = () => {
|
||||
const calls: Array<{
|
||||
key: string;
|
||||
pathAndAmountMap: Record<string, number>;
|
||||
}> = [];
|
||||
return {
|
||||
calls,
|
||||
store: {
|
||||
incr: async (args: {
|
||||
key: string;
|
||||
pathAndAmountMap: Record<string, number>;
|
||||
}) => {
|
||||
calls.push(args);
|
||||
return { res: {} as Record<string, number>, usage: 0 };
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it('sets requiresReauth and counts v1 + reauth.token_v1 for a legacy v1 token', async () => {
|
||||
it('sets requiresReauth with a signed token for a legacy v1 token', async () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNextResult({
|
||||
reauth: { reason: 'token_v1', auth_id: 'u-legacy' },
|
||||
@@ -576,11 +556,7 @@ describe('createAuthProbe — reauth signal', () => {
|
||||
// The probe must still set requiresReauth; gate emits 401.
|
||||
actor: { user: { uuid: 'u-legacy' } },
|
||||
} as never);
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer v1-tok' } }),
|
||||
@@ -590,109 +566,36 @@ describe('createAuthProbe — reauth signal', () => {
|
||||
auth_id: 'u-legacy',
|
||||
reauth_token: 'reauth-jwt:u-legacy',
|
||||
});
|
||||
// Both increments fire under the same day-bucketed key.
|
||||
expect(kv.calls).toHaveLength(1);
|
||||
expect(kv.calls[0].key).toMatch(/^auth-v2:metrics:\d{4}-\d{2}-\d{2}$/);
|
||||
expect(kv.calls[0].pathAndAmountMap).toEqual({
|
||||
v1: 1,
|
||||
'reauth.token_v1': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets requiresReauth and counts reauth.session_revoked', async () => {
|
||||
it('sets requiresReauth for a revoked session', async () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNextResult({
|
||||
reauth: { reason: 'session_revoked', auth_id: 'u-1' },
|
||||
});
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer tok' } }),
|
||||
);
|
||||
expect(req.requiresReauth?.reason).toBe('session_revoked');
|
||||
expect(kv.calls[0].pathAndAmountMap).toEqual({
|
||||
v1: 1,
|
||||
'reauth.session_revoked': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets requiresReauth and counts reauth.session_expired', async () => {
|
||||
it('sets requiresReauth for an expired session', async () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNextResult({
|
||||
reauth: { reason: 'session_expired', auth_id: 'u-1' },
|
||||
});
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer tok' } }),
|
||||
);
|
||||
expect(req.requiresReauth?.reason).toBe('session_expired');
|
||||
expect(kv.calls[0].pathAndAmountMap).toEqual({
|
||||
v1: 1,
|
||||
'reauth.session_expired': 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts v2 verify (no reauth) for a healthy token', async () => {
|
||||
it('attaches an actor and no reauth for a healthy v2 token', async () => {
|
||||
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
|
||||
);
|
||||
expect(req.actor).toBeTruthy();
|
||||
expect(req.requiresReauth).toBeUndefined();
|
||||
expect(kv.calls[0].pathAndAmountMap).toEqual({ v2: 1 });
|
||||
});
|
||||
|
||||
it('does not increment counters when no token is presented', async () => {
|
||||
const stub = makeStubAuth();
|
||||
const kv = makeKvStub();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: kv.store,
|
||||
});
|
||||
await runProbe(probe, makeReq({}));
|
||||
// Anonymous-OK routes pay no metrics cost.
|
||||
expect(kv.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('absorbs KV failures — never rejects on the hot path', async () => {
|
||||
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
|
||||
// Failing KV: increments throw. Probe must still succeed.
|
||||
const failingKv = {
|
||||
incr: async () => {
|
||||
throw new Error('kv unavailable');
|
||||
},
|
||||
};
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
kvStore: failingKv,
|
||||
});
|
||||
const { req, next } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
|
||||
);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.actor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('works without a kvStore (counters become no-ops)', async () => {
|
||||
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
|
||||
// Production may not always pass a KV — fresh installs without
|
||||
// dynamo wired still need a functioning probe.
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
@@ -702,6 +605,17 @@ describe('createAuthProbe — reauth signal', () => {
|
||||
expect(req.requiresReauth).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never rejects on the hot path', async () => {
|
||||
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
const { req, next } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
|
||||
);
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
expect(req.actor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('logs `[auth-v2] reauth reason=<r> auth_id=<id>` per event', async () => {
|
||||
// The log line is the human-facing forensic counterpart to the
|
||||
// KV counter. Asserting the exact shape so ops can `grep
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
AuthService,
|
||||
ReauthReason,
|
||||
} from '../../../services/auth/AuthService';
|
||||
import type { SystemKVStore } from '../../../stores/systemKv/SystemKVStore';
|
||||
|
||||
// Ensure the `Request.actor` / `Request.token` augmentation is in scope
|
||||
// wherever this middleware is imported.
|
||||
@@ -32,25 +31,8 @@ interface AuthProbeOptions {
|
||||
authService: AuthService;
|
||||
/** Name of the session cookie to inspect. Falls back to `config.cookie_name`. */
|
||||
cookieName?: string;
|
||||
kvStore?: SystemKVStore;
|
||||
}
|
||||
|
||||
const authV2MetricsKey = (): string => {
|
||||
const now = new Date();
|
||||
const yyyy = now.getUTCFullYear();
|
||||
const mm = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(now.getUTCDate()).padStart(2, '0');
|
||||
return `auth-v2:metrics:${yyyy}-${mm}-${dd}`;
|
||||
};
|
||||
|
||||
const bumpCounter = (
|
||||
kv: Pick<SystemKVStore, 'incr'> | undefined,
|
||||
pathAndAmountMap: Record<string, number>,
|
||||
): void => {
|
||||
if (!kv) return;
|
||||
kv.incr({ key: authV2MetricsKey(), pathAndAmountMap }).catch(() => {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Non-enforcing auth probe. Runs globally (installed by `PuterServer`) on
|
||||
* every request, tries to locate a token in the usual places, and — if one
|
||||
@@ -70,7 +52,7 @@ const bumpCounter = (
|
||||
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
|
||||
*/
|
||||
export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
const { authService, cookieName, kvStore } = opts;
|
||||
const { authService, cookieName } = opts;
|
||||
return async (req, _res, next): Promise<void> => {
|
||||
// If something upstream already attached an actor, respect it.
|
||||
if (req.actor) {
|
||||
@@ -94,10 +76,6 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
});
|
||||
|
||||
if (result.reauth) {
|
||||
bumpCounter(kvStore, {
|
||||
v1: 1,
|
||||
[`reauth.${result.reauth.reason}`]: 1,
|
||||
});
|
||||
// Bind a short-lived JWT proving the rejected session
|
||||
// identified this auth_id. The GUI echoes this back on
|
||||
// /login or /signup; the raw auth_id is informational
|
||||
@@ -113,8 +91,13 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
console.info(
|
||||
`[auth-v2] reauth reason=${result.reauth.reason} auth_id=${result.reauth.auth_id ?? '-'}`,
|
||||
);
|
||||
} else if (result.actor) {
|
||||
bumpCounter(kvStore, { v2: 1 });
|
||||
}
|
||||
|
||||
if (result.blocked) {
|
||||
// App is on the origin blocklist: leave `actor` unset so gates
|
||||
// reject. `appBlocked` lets the gate emit a clear 403 instead
|
||||
// of the generic "token failed" 401.
|
||||
req.appBlocked = { reason: result.blocked.reason };
|
||||
}
|
||||
|
||||
if (result.actor) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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 type { Request, Response } from 'express';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createFingerprintMiddleware } from './fingerprint';
|
||||
|
||||
const VALID_FP = 'dev-fingerprint-abc123';
|
||||
|
||||
const run = (reqPartial: Partial<Request> & Record<string, unknown>) => {
|
||||
const middleware = createFingerprintMiddleware();
|
||||
const req = {
|
||||
headers: {},
|
||||
...reqPartial,
|
||||
} as unknown as Request;
|
||||
const next = vi.fn();
|
||||
middleware(req, {} as Response, next);
|
||||
return { req, next };
|
||||
};
|
||||
|
||||
describe('createFingerprintMiddleware', () => {
|
||||
it('always sets a well-formed networkFingerprint and calls next', () => {
|
||||
const { req, next } = run({ ip: '203.0.113.1' });
|
||||
|
||||
expect(req.networkFingerprint).toMatch(/^[A-Za-z0-9_-]{16}$/);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('networkFingerprint is stable for identical inputs and varies by UA', () => {
|
||||
const headers = { 'user-agent': 'UA/1.0' };
|
||||
const a = run({ ip: '203.0.113.1', headers }).req.networkFingerprint;
|
||||
const b = run({ ip: '203.0.113.1', headers }).req.networkFingerprint;
|
||||
const c = run({
|
||||
ip: '203.0.113.1',
|
||||
headers: { 'user-agent': 'UA/2.0' },
|
||||
}).req.networkFingerprint;
|
||||
|
||||
expect(a).toBe(b);
|
||||
expect(a).not.toBe(c);
|
||||
});
|
||||
|
||||
it('reads a well-shaped device fingerprint from the body', () => {
|
||||
const { req } = run({ body: { fingerprint: VALID_FP } });
|
||||
expect(req.deviceFingerprint).toBe(VALID_FP);
|
||||
});
|
||||
|
||||
it('falls back to the x-puter-device-fingerprint header', () => {
|
||||
const { req } = run({
|
||||
headers: { 'x-puter-device-fingerprint': VALID_FP },
|
||||
});
|
||||
expect(req.deviceFingerprint).toBe(VALID_FP);
|
||||
});
|
||||
|
||||
it('prefers the body fingerprint over the header', () => {
|
||||
const { req } = run({
|
||||
body: { fingerprint: VALID_FP },
|
||||
headers: { 'x-puter-device-fingerprint': 'other-fingerprint-xyz' },
|
||||
});
|
||||
expect(req.deviceFingerprint).toBe(VALID_FP);
|
||||
});
|
||||
|
||||
it('drops a malformed fingerprint to undefined', () => {
|
||||
expect(run({ body: { fingerprint: 'bad fp!' } }).req.deviceFingerprint)
|
||||
.toBeUndefined();
|
||||
expect(run({ body: { fingerprint: 'short' } }).req.deviceFingerprint)
|
||||
.toBeUndefined();
|
||||
expect(
|
||||
run({ body: { fingerprint: 123 as unknown as string } }).req
|
||||
.deviceFingerprint,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves deviceFingerprint undefined when nothing was supplied', () => {
|
||||
const { req } = run({ ip: '203.0.113.1' });
|
||||
expect(req.deviceFingerprint).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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 type { RequestHandler } from 'express';
|
||||
import { computeNetworkFingerprint } from './rateLimit.js';
|
||||
import '../expressAugmentation';
|
||||
|
||||
/**
|
||||
* Conservative charset/length for the client-supplied device fingerprint. It
|
||||
* becomes part of KV keys downstream (abuse extension), so anything outside
|
||||
* this shape is dropped to `undefined` rather than trusted. Kept in sync with
|
||||
* the abuse extension's `DEVICE_FINGERPRINT_SHAPE`; the core only enforces the
|
||||
* shape, never any abuse policy built on the value.
|
||||
*/
|
||||
const DEVICE_FINGERPRINT_SHAPE = /^[A-Za-z0-9._-]{8,128}$/;
|
||||
|
||||
/** Header the GUI may send the device fingerprint on for non-signup requests. */
|
||||
const DEVICE_FINGERPRINT_HEADER = 'x-puter-device-fingerprint';
|
||||
|
||||
function readDeviceFingerprint(req: {
|
||||
body?: unknown;
|
||||
headers?: Record<string, unknown>;
|
||||
}): string | undefined {
|
||||
// Body first (what /signup already sends), then a header fallback so
|
||||
// authenticated, bodyless-or-different-shape requests can still carry it.
|
||||
const body = req.body as { fingerprint?: unknown } | undefined;
|
||||
const candidate =
|
||||
(typeof body?.fingerprint === 'string'
|
||||
? body.fingerprint
|
||||
: undefined) ??
|
||||
(typeof req.headers?.[DEVICE_FINGERPRINT_HEADER] === 'string'
|
||||
? (req.headers[DEVICE_FINGERPRINT_HEADER] as string)
|
||||
: undefined);
|
||||
if (candidate && DEVICE_FINGERPRINT_SHAPE.test(candidate)) return candidate;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp request-scoped fingerprints onto `req` so any downstream gate, handler,
|
||||
* or service (via the ALS `Context.get('req')`) can read them without recomputing:
|
||||
*
|
||||
* - `req.networkFingerprint` — always set; a coarse IP+headers hash (the same
|
||||
* value the rate limiter keys on). Server-derived, so it can't be forged
|
||||
* away, but it's coarse (shared behind NAT/VPN, rotates with UA).
|
||||
* - `req.deviceFingerprint` — set only when the client supplied a well-shaped
|
||||
* device fingerprint (ThumbmarkJS hash) in the body or the
|
||||
* `x-puter-device-fingerprint` header; `undefined` otherwise. Client-supplied
|
||||
* and spoofable, but stable per real device across IP rotation.
|
||||
*
|
||||
* Install AFTER the body parsers (so the body fingerprint is readable) and
|
||||
* before `requestContext` (so the snapshot into ALS already carries them).
|
||||
* Never rejects — a missing/invalid device fingerprint is simply absent.
|
||||
*/
|
||||
export const createFingerprintMiddleware = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
req.networkFingerprint = computeNetworkFingerprint(req);
|
||||
req.deviceFingerprint = readDeviceFingerprint(req);
|
||||
next();
|
||||
};
|
||||
};
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
adminOnlyGate,
|
||||
allowedAppIdsGate,
|
||||
requireAuthGate,
|
||||
requireEmailConfirmedGate,
|
||||
requireVerifiedAccount,
|
||||
requireNonAccessTokenGate,
|
||||
requireUserActorGate,
|
||||
requireVerifiedGate,
|
||||
@@ -440,11 +440,11 @@ describe('requireVerifiedGate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireEmailConfirmedGate ───────────────────────────────────────
|
||||
// ── requireVerifiedAccount ──────────────────────────────────────────
|
||||
|
||||
describe('requireEmailConfirmedGate', () => {
|
||||
describe('requireVerifiedAccount', () => {
|
||||
it('passes through users that do not require confirmation (e.g. legacy/temp)', () => {
|
||||
const got = runGate(requireEmailConfirmedGate(), {
|
||||
const got = runGate(requireVerifiedAccount(), {
|
||||
actor: {
|
||||
user: {
|
||||
uuid: 'u-1',
|
||||
@@ -457,7 +457,7 @@ describe('requireEmailConfirmedGate', () => {
|
||||
});
|
||||
|
||||
it('passes through confirmed users even when confirmation is required', () => {
|
||||
const got = runGate(requireEmailConfirmedGate(), {
|
||||
const got = runGate(requireVerifiedAccount(), {
|
||||
actor: {
|
||||
user: {
|
||||
uuid: 'u-1',
|
||||
@@ -470,7 +470,7 @@ describe('requireEmailConfirmedGate', () => {
|
||||
});
|
||||
|
||||
it('returns 403 email_confirmation_required for pending-confirmation users', () => {
|
||||
const got = runGate(requireEmailConfirmedGate(), {
|
||||
const got = runGate(requireVerifiedAccount(), {
|
||||
actor: {
|
||||
user: {
|
||||
uuid: 'u-1',
|
||||
@@ -482,8 +482,51 @@ describe('requireEmailConfirmedGate', () => {
|
||||
expectHttpError(got, 403, 'email_confirmation_required');
|
||||
});
|
||||
|
||||
it('returns 403 phone_verification_required while the phone gate is set', () => {
|
||||
const got = runGate(requireVerifiedAccount(), {
|
||||
actor: {
|
||||
user: {
|
||||
uuid: 'u-1',
|
||||
requires_email_confirmation: false,
|
||||
email_confirmed: true,
|
||||
requires_phone_verification: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expectHttpError(got, 403, 'phone_verification_required');
|
||||
});
|
||||
|
||||
it('returns 403 card_verification_required while the card gate is set', () => {
|
||||
const got = runGate(requireVerifiedAccount(), {
|
||||
actor: {
|
||||
user: {
|
||||
uuid: 'u-1',
|
||||
requires_email_confirmation: false,
|
||||
email_confirmed: true,
|
||||
requires_card_verification: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expectHttpError(got, 403, 'card_verification_required');
|
||||
});
|
||||
|
||||
it('passes through once every gate is cleared', () => {
|
||||
const got = runGate(requireVerifiedAccount(), {
|
||||
actor: {
|
||||
user: {
|
||||
uuid: 'u-1',
|
||||
requires_email_confirmation: true,
|
||||
email_confirmed: true,
|
||||
requires_phone_verification: false,
|
||||
requires_card_verification: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes through when there's no actor (auth gate handled it)", () => {
|
||||
const got = runGate(requireEmailConfirmedGate(), {});
|
||||
const got = runGate(requireVerifiedAccount(), {});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,6 +79,16 @@ export const subdomainGate = (allowed: string | string[]): RequestHandler => {
|
||||
*/
|
||||
export const requireAuthGate = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
if (req.appBlocked) {
|
||||
next(
|
||||
new HttpError(
|
||||
403,
|
||||
'This app is not allowed to access Puter resources',
|
||||
{ legacyCode: 'app_blocked' },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!req.actor) {
|
||||
next(rejectAuth(req));
|
||||
return;
|
||||
@@ -224,29 +234,84 @@ export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject authenticated users whose account is pending email confirmation
|
||||
* (`requires_email_confirmation && !email_confirmed`). Runs on every
|
||||
* authenticated route by default; routes that set `allowUnconfirmed: true`
|
||||
* skip this gate.
|
||||
* Reject authenticated users whose account is still pending any signup-time
|
||||
* verification — email confirmation, SMS phone verification, or credit-card
|
||||
* verification. The abuse harness sets the phone/card flags on low-reputation
|
||||
* signups (in place of a hard block), and this gate is what actually keeps
|
||||
* those accounts out of the product until the flag clears: the flags live on
|
||||
* `req.actor.user`, so every authenticated route enforces them, not just the
|
||||
* GUI modal.
|
||||
*
|
||||
* Returns 403 with `email_confirmation_required` so clients can show the
|
||||
* confirmation prompt instead of a generic error.
|
||||
* Runs on every authenticated route by default; routes that set
|
||||
* `allowUnconfirmed: true` opt out (the verification endpoints themselves,
|
||||
* plus essential flows like whoami / logout / save-account so a pending
|
||||
* account can still reach the screens that clear the gate).
|
||||
*
|
||||
* Returns 403 with a per-gate legacy code (`email_confirmation_required` /
|
||||
* `phone_verification_required` / `card_verification_required`) so clients can
|
||||
* show the right prompt instead of a generic error. There is no state where a
|
||||
* user should be allowed in with one verification pending, so any pending gate
|
||||
* rejects.
|
||||
*/
|
||||
export const requireEmailConfirmedGate = (): RequestHandler => {
|
||||
export const requireVerifiedAccount = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
const user = req.actor?.user;
|
||||
if (user?.requires_email_confirmation && !user?.email_confirmed) {
|
||||
next(
|
||||
new HttpError(403, 'Please confirm your email to continue', {
|
||||
legacyCode: 'email_confirmation_required',
|
||||
}),
|
||||
);
|
||||
try {
|
||||
assertVerifiedAccount(req.actor?.user);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The pending-verification check, factored out of {@link requireVerifiedAccount}
|
||||
* so auth paths that build their own actor outside the route-option machinery
|
||||
* can enforce the exact same gate. The WebDAV controller is the motivating
|
||||
* case: it dispatches every method off a single `router.use`, so
|
||||
* `requireVerifiedAccount` is never wired into its chain — it has to call this
|
||||
* directly. Keeping one implementation is the point: a verification gate added
|
||||
* here is picked up by every caller, so the paths can't drift (which is how
|
||||
* WebDAV came to bypass the phone/card gate to begin with).
|
||||
*
|
||||
* Throws 403 with a per-gate legacy code (`email_confirmation_required` /
|
||||
* `phone_verification_required` / `card_verification_required`) so clients can
|
||||
* show the right prompt instead of a generic error. There is no state where a
|
||||
* user should be let in with any verification pending, so the first pending
|
||||
* gate rejects.
|
||||
*/
|
||||
export const assertVerifiedAccount = (
|
||||
user:
|
||||
| {
|
||||
requires_email_confirmation?: unknown;
|
||||
email_confirmed?: unknown;
|
||||
requires_phone_verification?: unknown;
|
||||
requires_card_verification?: unknown;
|
||||
}
|
||||
| undefined,
|
||||
): void => {
|
||||
if (user?.requires_email_confirmation && !user?.email_confirmed) {
|
||||
throw new HttpError(403, 'Please confirm your email to continue', {
|
||||
legacyCode: 'email_confirmation_required',
|
||||
});
|
||||
}
|
||||
if (user?.requires_phone_verification) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'Please verify your phone number to continue',
|
||||
{
|
||||
legacyCode: 'phone_verification_required' as never,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (user?.requires_card_verification) {
|
||||
throw new HttpError(403, 'Please verify your card to continue', {
|
||||
legacyCode: 'card_verification_required' as never,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject unless the actor is acting through one of the named apps.
|
||||
* App-under-user actors are permitted iff `actor.app.uid` is in the allowList;
|
||||
|
||||
@@ -264,6 +264,79 @@ describe('createUserSubdomainRedirect', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
const selfHosted = {
|
||||
domain: 'puter.localhost',
|
||||
static_hosting_domain: 'site.puter.localhost',
|
||||
static_hosting_domain_alt: 'host.puter.localhost',
|
||||
private_app_hosting_domain: 'app.puter.localhost',
|
||||
private_app_hosting_domain_alt: 'dev.puter.localhost',
|
||||
} as IConfig;
|
||||
|
||||
it('still redirects a bare subdomain on the main domain to the hosting domain (self-hosted)', () => {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(selfHosted),
|
||||
makeReq({
|
||||
subdomains: ['localhost', 'puter', 'foo'],
|
||||
host: 'foo.puter.localhost',
|
||||
originalUrl: '/bar?x=1',
|
||||
protocol: 'http',
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toEqual([
|
||||
302,
|
||||
'http://foo.site.puter.localhost/bar?x=1',
|
||||
]);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes through hosts already on the static hosting domain (no redirect loop)', () => {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(selfHosted),
|
||||
makeReq({
|
||||
subdomains: ['localhost', 'puter', 'site', 'foo'],
|
||||
host: 'foo.site.puter.localhost',
|
||||
originalUrl: '/',
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes through hosts on the alt / private-app hosting domains too', () => {
|
||||
for (const host of [
|
||||
'foo.host.puter.localhost',
|
||||
'foo.app.puter.localhost',
|
||||
'foo.dev.puter.localhost',
|
||||
]) {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(selfHosted),
|
||||
makeReq({
|
||||
subdomains: [
|
||||
'localhost',
|
||||
'puter',
|
||||
host.split('.')[1],
|
||||
'foo',
|
||||
],
|
||||
host,
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('passes through the hosting-domain root itself (exact match, no loop)', () => {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainRedirect(selfHosted),
|
||||
makeReq({
|
||||
subdomains: ['localhost', 'puter', 'site'],
|
||||
host: 'site.puter.localhost',
|
||||
}),
|
||||
);
|
||||
expect(out.redirectArgs).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes through when the request host has a port the configured domain doesn't", () => {
|
||||
// Edge case worth pinning: the suffix check is exact-`endsWith`,
|
||||
// so a port mismatch silently bypasses the redirect. Documenting
|
||||
|
||||
@@ -88,6 +88,15 @@ export const createUserSubdomainRedirect = (
|
||||
if (!domain || !target) {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
|
||||
const hostingDomains = [
|
||||
config.static_hosting_domain,
|
||||
config.static_hosting_domain_alt,
|
||||
config.private_app_hosting_domain,
|
||||
config.private_app_hosting_domain_alt,
|
||||
]
|
||||
.map((d) => (d ?? '').toLowerCase().split(':')[0])
|
||||
.filter((d) => d.length > 0);
|
||||
return (req, res, next) => {
|
||||
const active = (
|
||||
req.subdomains?.[req.subdomains.length - 1] ?? ''
|
||||
@@ -95,6 +104,14 @@ export const createUserSubdomainRedirect = (
|
||||
if (active === '' || RESERVED_SUBDOMAINS.has(active)) return next();
|
||||
|
||||
const host = (req.headers.host ?? '').toLowerCase();
|
||||
const hostName = host.split(':')[0];
|
||||
if (
|
||||
hostingDomains.some(
|
||||
(d) => hostName === d || hostName.endsWith(`.${d}`),
|
||||
)
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
if (!host.endsWith(domain)) return next();
|
||||
|
||||
// host ends in domain — swap the domain suffix for the hosting one,
|
||||
|
||||
@@ -534,6 +534,51 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
expect(piped!.equals(body)).toBe(true);
|
||||
});
|
||||
|
||||
it('emits site.htmlServed with the original URL target including query string', async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
|
||||
const sub = `serveq-${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('<html>hi</html>'),
|
||||
'text/html',
|
||||
);
|
||||
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
try {
|
||||
const mw = buildMiddleware();
|
||||
const { res } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/index.html',
|
||||
originalUrl: '/index.html?brand=paypal',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
const htmlServedCall = emitSpy.mock.calls.find(
|
||||
(call) => call[0] === 'site.htmlServed',
|
||||
);
|
||||
expect(htmlServedCall?.[1]).toMatchObject({
|
||||
subdomain: sub,
|
||||
requestPath: '/index.html',
|
||||
requestUrl: '/index.html?brand=paypal',
|
||||
mime: 'text/html',
|
||||
});
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 206 status when a Range header is supplied', async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
|
||||
@@ -555,8 +555,12 @@ export const createPuterSiteMiddleware = (
|
||||
res.setHeader('Content-Type', mime);
|
||||
|
||||
// Fire-and-forget signal for downstream extensions
|
||||
if (mime === 'text/html' || mime === 'application/xhtml+xml') {
|
||||
const mimeBase = mime.split(';', 1)[0].trim().toLowerCase();
|
||||
if (mimeBase === 'text/html' || mimeBase === 'application/xhtml+xml') {
|
||||
try {
|
||||
const requestUrl = (req.originalUrl || '/').startsWith('/')
|
||||
? req.originalUrl || '/'
|
||||
: `/${req.originalUrl}`;
|
||||
layers.clients.event.emit(
|
||||
'site.htmlServed',
|
||||
{
|
||||
@@ -564,7 +568,8 @@ export const createPuterSiteMiddleware = (
|
||||
entry,
|
||||
host,
|
||||
requestPath: req.path,
|
||||
mime,
|
||||
requestUrl,
|
||||
mime: mimeBase,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -392,7 +392,14 @@ function ip(req) {
|
||||
return req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
function fingerprint(req) {
|
||||
/**
|
||||
* A coarse network fingerprint for a request: a short hash of the (proxy-aware)
|
||||
* IP plus the headers a client can't trivially vary per-request without also
|
||||
* changing how the request looks. Used as the default rate-limit key here, and
|
||||
* exported so the global fingerprint middleware can stamp the identical value
|
||||
* on `req.networkFingerprint` (one key space shared by both).
|
||||
*/
|
||||
export function computeNetworkFingerprint(req) {
|
||||
const parts = [
|
||||
ip(req),
|
||||
req.headers?.['user-agent'] || '',
|
||||
@@ -406,6 +413,10 @@ function fingerprint(req) {
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
function fingerprint(req) {
|
||||
return computeNetworkFingerprint(req);
|
||||
}
|
||||
|
||||
// -- Route middleware ------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,10 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ
|
||||
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
|
||||
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
|
||||
import type { IChatProvider, ICompleteArguments } from '../../types.js';
|
||||
import {
|
||||
messagesHaveCompaction,
|
||||
wantsCompaction,
|
||||
} from '../../utils/compaction.js';
|
||||
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
|
||||
import { processPuterPathUploads } from '../openai/fileUpload.js';
|
||||
import { AZURE_MODELS } from './models.js';
|
||||
@@ -141,6 +145,20 @@ export class AzureChatProvider implements IChatProvider {
|
||||
}
|
||||
return await this.#responsesProvider.complete(params);
|
||||
}
|
||||
// Inline compaction is a Responses-API feature; chat.completions can't
|
||||
// express `context_management` or a `compaction` content block.
|
||||
// Delegate to the Responses provider when the caller opted in OR when
|
||||
// the messages carry a round-tripped compaction artifact.
|
||||
if (wantsCompaction(params) || messagesHaveCompaction(messages)) {
|
||||
if (!this.#responsesProvider) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'compaction requires the OpenAI Responses provider, which is not configured',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await this.#responsesProvider.complete(params);
|
||||
}
|
||||
// Validate messages
|
||||
if (!Array.isArray(messages)) {
|
||||
throw new HttpError(400, '`messages` must be an array', {
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ
|
||||
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
|
||||
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
|
||||
import type { IChatProvider, ICompleteArguments } from '../../types.js';
|
||||
import { toOpenAiContextManagement } from '../../utils/compaction.js';
|
||||
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
|
||||
import { processPuterPathUploads } from '../openai/fileUpload.js';
|
||||
import { AZURE_MODELS } from './models.js';
|
||||
@@ -109,6 +110,8 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
parallel_tool_calls,
|
||||
include,
|
||||
conversation,
|
||||
compaction,
|
||||
context_management,
|
||||
previous_response_id,
|
||||
instructions,
|
||||
metadata,
|
||||
@@ -182,6 +185,13 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
const supportsReasoningControls =
|
||||
typeof model === 'string' && model.startsWith('gpt-5');
|
||||
|
||||
// Translate the neutral compaction opt-in (or pass a raw
|
||||
// `context_management` payload through) to OpenAI's Responses shape.
|
||||
const contextManagement = toOpenAiContextManagement({
|
||||
compaction,
|
||||
context_management,
|
||||
});
|
||||
|
||||
const completionParams: ResponseCreateParams = {
|
||||
user: userIdentifier,
|
||||
safety_identifier: userIdentifier,
|
||||
@@ -193,6 +203,9 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
? { parallel_tool_calls }
|
||||
: {}),
|
||||
...(include !== undefined ? { include } : {}),
|
||||
...(contextManagement !== undefined
|
||||
? { context_management: contextManagement }
|
||||
: {}),
|
||||
...(conversation !== undefined ? { conversation } : {}),
|
||||
...(previous_response_id !== undefined
|
||||
? { previous_response_id }
|
||||
|
||||
@@ -486,6 +486,52 @@ describe('ClaudeProvider.complete non-stream output', () => {
|
||||
10 * Number(haiku.costs.cache_read_input_tokens),
|
||||
);
|
||||
});
|
||||
|
||||
it('bills the compaction pass by summing usage.iterations', async () => {
|
||||
const { provider } = makeProvider();
|
||||
// Per Anthropic: top-level input/output reflect only the message pass;
|
||||
// the compaction pass lives in `iterations` and must be summed to bill.
|
||||
const msg = {
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
usage: {
|
||||
input_tokens: 23000, // message pass only (NOT the total)
|
||||
output_tokens: 1000,
|
||||
iterations: [
|
||||
{
|
||||
type: 'compaction',
|
||||
input_tokens: 180000,
|
||||
output_tokens: 3500,
|
||||
},
|
||||
{ type: 'message', input_tokens: 23000, output_tokens: 1000 },
|
||||
],
|
||||
},
|
||||
};
|
||||
messagesCreateMock.mockResolvedValueOnce(msg);
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
compaction: true,
|
||||
}),
|
||||
)) as { usage: Record<string, number> };
|
||||
|
||||
// Totals are the SUM across iterations, not the top-level fields.
|
||||
expect(result.usage.input_tokens).toBe(203000); // 180000 + 23000
|
||||
expect(result.usage.output_tokens).toBe(4500); // 3500 + 1000
|
||||
|
||||
const haiku = CLAUDE_MODELS.find(
|
||||
(m) => m.id === 'claude-haiku-4-5-20251001',
|
||||
)!;
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage.input_tokens).toBe(203000);
|
||||
expect(overrides.input_tokens).toBe(
|
||||
203000 * Number(haiku.costs.input_tokens),
|
||||
);
|
||||
expect(overrides.output_tokens).toBe(
|
||||
4500 * Number(haiku.costs.output_tokens),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Streaming deltas ────────────────────────────────────────────────
|
||||
@@ -618,6 +664,220 @@ describe('ClaudeProvider.complete streaming', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Inline compaction ───────────────────────────────────────────────
|
||||
|
||||
describe('ClaudeProvider.complete compaction', () => {
|
||||
it('translates the neutral opt-in to context_management + the compaction beta', async () => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
compaction: { trigger_tokens: 50000 },
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = messagesCreateMock.mock.calls[0]!;
|
||||
expect(args.context_management).toEqual({
|
||||
edits: [
|
||||
{
|
||||
type: 'compact_20260112',
|
||||
trigger: { type: 'input_tokens', value: 50000 },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(args.betas).toContain('compact-2026-01-12');
|
||||
});
|
||||
|
||||
it('emits a canonical compaction event from a streamed compaction block', async () => {
|
||||
const { provider } = makeProvider();
|
||||
messagesStreamMock.mockReturnValueOnce(
|
||||
makeStreamLike(
|
||||
[
|
||||
{ type: 'message_start' },
|
||||
{
|
||||
type: 'content_block_start',
|
||||
content_block: {
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
content: 'ENC', // Anthropic carries the summary here
|
||||
},
|
||||
},
|
||||
{ type: 'content_block_stop' },
|
||||
{
|
||||
type: 'message_delta',
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
{ type: 'message_stop' },
|
||||
],
|
||||
{ input_tokens: 1, output_tokens: 1 },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
compaction: true,
|
||||
}),
|
||||
);
|
||||
const harness = makeCapturingChatStream();
|
||||
await (
|
||||
result as {
|
||||
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
|
||||
}
|
||||
).init_chat_stream({ chatStream: harness.chatStream });
|
||||
|
||||
const compaction = harness
|
||||
.events()
|
||||
.find((e) => e.type === 'compaction');
|
||||
expect(compaction).toEqual({
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
});
|
||||
});
|
||||
|
||||
it('enables the compaction beta when a round-tripped compaction block is resent (no opt-in)', async () => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
messages: [
|
||||
{ role: 'user', content: 'continue' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// note: no `compaction`/`context_management` opt-in
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = messagesCreateMock.mock.calls[0]!;
|
||||
expect(args.betas).toContain('compact-2026-01-12');
|
||||
// No new compaction was requested, so no context_management is sent.
|
||||
expect(args.context_management).toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces a compaction block from a non-streaming response as result.compaction', async () => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce({
|
||||
content: [
|
||||
{ type: 'text', text: 'done' },
|
||||
{
|
||||
type: 'compaction',
|
||||
id: 'cmpct_2',
|
||||
content: 'ENC2', // Anthropic carries the summary here
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
});
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
compaction: true,
|
||||
}),
|
||||
)) as { compaction?: { id?: string; encrypted_content: string } };
|
||||
|
||||
// Anthropic's `content` is surfaced under the unified `encrypted_content`.
|
||||
expect(result.compaction).toEqual({
|
||||
type: 'compaction',
|
||||
id: 'cmpct_2',
|
||||
encrypted_content: 'ENC2',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a round-tripped compaction block back to Anthropic `content` on input', async () => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
messages: [
|
||||
{ role: 'user', content: 'continue' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'compaction', encrypted_content: 'SUMMARY' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = messagesCreateMock.mock.calls[0]!;
|
||||
const compactionBlock = args.messages
|
||||
.flatMap((m: { content?: unknown[] }) =>
|
||||
Array.isArray(m.content) ? m.content : [],
|
||||
)
|
||||
.find((c: { type?: string }) => c?.type === 'compaction');
|
||||
// Internal `encrypted_content` carrier → Anthropic native `content`.
|
||||
expect(compactionBlock).toEqual({
|
||||
type: 'compaction',
|
||||
content: 'SUMMARY',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the reply text when an assistant turn carries compaction + text', async () => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'compaction', encrypted_content: 'SUMMARY' },
|
||||
{ type: 'text', text: 'earlier reply' },
|
||||
],
|
||||
},
|
||||
{ role: 'user', content: 'continue' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = messagesCreateMock.mock.calls[0]!;
|
||||
const blocks = args.messages.flatMap((m: { content?: unknown[] }) =>
|
||||
Array.isArray(m.content) ? m.content : [],
|
||||
);
|
||||
// Compaction mapped to Anthropic `content`, AND the reply text kept.
|
||||
expect(blocks).toContainEqual({
|
||||
type: 'compaction',
|
||||
content: 'SUMMARY',
|
||||
});
|
||||
expect(blocks).toContainEqual({ type: 'text', text: 'earlier reply' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Moderation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('ClaudeProvider.checkModeration', () => {
|
||||
|
||||
@@ -34,6 +34,10 @@ import type {
|
||||
ICompleteArguments,
|
||||
IChatCompleteResult,
|
||||
} from '../../types.js';
|
||||
import {
|
||||
messagesHaveCompaction,
|
||||
toAnthropicContextManagement,
|
||||
} from '../../utils/compaction.js';
|
||||
import { make_claude_tools } from '../../utils/FunctionCalling.js';
|
||||
import { extract_and_remove_system_messages } from '../../utils/Messages.js';
|
||||
import type {
|
||||
@@ -44,6 +48,11 @@ import type {
|
||||
import { FILES_API_BETA, processPuterPathUploads } from './fileUpload.js';
|
||||
import { CLAUDE_MODELS } from './models.js';
|
||||
|
||||
// Anthropic inline-compaction beta. The vendored SDK (0.68.0) doesn't type the
|
||||
// `compact_20260112` edit or the `compaction` content block, so the request
|
||||
// params and streamed/returned blocks are handled with `as any` casts.
|
||||
const COMPACTION_BETA = 'compact-2026-01-12';
|
||||
|
||||
export class ClaudeProvider implements IChatProvider {
|
||||
anthropic: Anthropic;
|
||||
|
||||
@@ -97,9 +106,18 @@ export class ClaudeProvider implements IChatProvider {
|
||||
temperature,
|
||||
reasoning,
|
||||
reasoning_effort,
|
||||
compaction,
|
||||
context_management,
|
||||
}: ICompleteArguments): Promise<IChatCompleteResult> {
|
||||
tools = make_claude_tools(tools);
|
||||
|
||||
// Translate the neutral compaction opt-in (or pass a raw
|
||||
// `context_management` payload through) to Anthropic's beta shape.
|
||||
const contextManagement = toAnthropicContextManagement({
|
||||
compaction,
|
||||
context_management,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let system_prompts: string | any[];
|
||||
[system_prompts, messages] =
|
||||
@@ -218,6 +236,23 @@ export class ClaudeProvider implements IChatProvider {
|
||||
return message;
|
||||
});
|
||||
|
||||
// Map round-tripped compaction blocks back to Anthropic's native shape.
|
||||
// The internal/unified carrier field is `encrypted_content`; Anthropic's
|
||||
// compaction block uses `content` (a plaintext summary).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
messages = messages.map((message: any) => {
|
||||
if (!Array.isArray(message.content)) return message;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
message.content = message.content.map((part: any) => {
|
||||
if (part?.type !== 'compaction') return part;
|
||||
return {
|
||||
type: 'compaction',
|
||||
content: part.content ?? part.encrypted_content ?? '',
|
||||
};
|
||||
});
|
||||
return message;
|
||||
});
|
||||
|
||||
const modelUsed =
|
||||
this.models().find((m) =>
|
||||
[m.id, ...(m.aliases || [])].includes(model),
|
||||
@@ -233,6 +268,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
// Other models require temperature=1 when thinking is enabled.
|
||||
const omitsTemperature = [
|
||||
'claude-fable-5',
|
||||
'claude-sonnet-5',
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-8',
|
||||
].includes(modelUsed.id);
|
||||
@@ -243,6 +279,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
: (temperature ?? 0);
|
||||
const supportsEffort = [
|
||||
'claude-fable-5',
|
||||
'claude-sonnet-5',
|
||||
'claude-opus-4-8',
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-6',
|
||||
@@ -262,6 +299,18 @@ export class ClaudeProvider implements IChatProvider {
|
||||
actor,
|
||||
);
|
||||
const usesBetaFiles = uploadedFileIds.length > 0;
|
||||
// The compaction beta is needed both to *request* compaction
|
||||
// (contextManagement) and to *accept a round-tripped* compaction block
|
||||
// back as input (messagesHaveCompaction).
|
||||
const usesCompaction =
|
||||
!!contextManagement || messagesHaveCompaction(messages);
|
||||
// Compaction and Files API both require the beta endpoint; combine their
|
||||
// beta headers and route through `beta.messages.*` if either is active.
|
||||
const betas = [
|
||||
...(usesBetaFiles ? [FILES_API_BETA] : []),
|
||||
...(usesCompaction ? [COMPACTION_BETA] : []),
|
||||
];
|
||||
const usesBeta = betas.length > 0;
|
||||
|
||||
const sdkParams: MessageCreateParams & {
|
||||
betas?: string[];
|
||||
@@ -291,7 +340,11 @@ export class ClaudeProvider implements IChatProvider {
|
||||
...(supportsEffort && requestedReasoningEffort
|
||||
? { output_config: { effort: requestedReasoningEffort } }
|
||||
: {}),
|
||||
...(usesBetaFiles ? { betas: [FILES_API_BETA] } : {}),
|
||||
// Cast: `context_management` compaction edits aren't typed in SDK 0.68.0.
|
||||
...(contextManagement
|
||||
? { context_management: contextManagement as any }
|
||||
: {}),
|
||||
...(usesBeta ? { betas } : {}),
|
||||
} as MessageCreateParams & { betas?: string[] };
|
||||
|
||||
const cleanupUploads = async () => {
|
||||
@@ -315,13 +368,23 @@ export class ClaudeProvider implements IChatProvider {
|
||||
}: {
|
||||
chatStream: AIChatStream;
|
||||
}) => {
|
||||
const completion = usesBetaFiles
|
||||
const completion = usesBeta
|
||||
? this.anthropic.beta.messages.stream(sdkParams)
|
||||
: this.anthropic.messages.stream(sdkParams);
|
||||
const usageSum: Record<string, number> = {};
|
||||
|
||||
let message, contentBlock;
|
||||
let currentContentBlockType: string | null = null;
|
||||
// Inline-compaction block is an untyped beta block; capture its
|
||||
// artifact across start/delta and emit on stop. Anthropic carries
|
||||
// the summary in `content` (plaintext) — unlike OpenAI's
|
||||
// `encrypted_content` — so read `content` first.
|
||||
let compactionData: {
|
||||
id?: string;
|
||||
payload: string;
|
||||
buffer: string;
|
||||
} | null = null;
|
||||
let emittedCompaction = false;
|
||||
for await (const event of completion) {
|
||||
if (event.type === 'message_delta') {
|
||||
const meteredData = this.#usageFormatterUtil(
|
||||
@@ -345,6 +408,24 @@ export class ClaudeProvider implements IChatProvider {
|
||||
}
|
||||
if (event.type === 'content_block_start') {
|
||||
currentContentBlockType = event.content_block.type;
|
||||
if (
|
||||
(event.content_block.type as string) ===
|
||||
'compaction'
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const block = event.content_block as any;
|
||||
compactionData = {
|
||||
id: block.id,
|
||||
// Anthropic uses `content`; fall back to
|
||||
// `encrypted_content` in case the field varies.
|
||||
payload:
|
||||
block.content ??
|
||||
block.encrypted_content ??
|
||||
'',
|
||||
buffer: '',
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (event.content_block.type === 'tool_use') {
|
||||
contentBlock = message!.contentBlock({
|
||||
type: event.content_block.type,
|
||||
@@ -363,12 +444,42 @@ export class ClaudeProvider implements IChatProvider {
|
||||
continue;
|
||||
}
|
||||
if (event.type === 'content_block_stop') {
|
||||
if (currentContentBlockType === 'compaction') {
|
||||
const encrypted_content =
|
||||
compactionData?.payload ||
|
||||
compactionData?.buffer ||
|
||||
'';
|
||||
// Only emit (and mark done) if we actually captured
|
||||
// the summary; otherwise let the finalMessage
|
||||
// fallback recover it from the complete block.
|
||||
if (encrypted_content) {
|
||||
chatStream.compaction({
|
||||
id: compactionData?.id,
|
||||
encrypted_content,
|
||||
});
|
||||
emittedCompaction = true;
|
||||
}
|
||||
compactionData = null;
|
||||
currentContentBlockType = null;
|
||||
continue;
|
||||
}
|
||||
contentBlock!.end();
|
||||
contentBlock = null;
|
||||
currentContentBlockType = null;
|
||||
continue;
|
||||
}
|
||||
if (event.type === 'content_block_delta') {
|
||||
if (currentContentBlockType === 'compaction') {
|
||||
// Capture any streamed payload for the artifact.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = event.delta as any;
|
||||
const chunk =
|
||||
d.partial_json ?? d.text ?? d.data ?? '';
|
||||
if (typeof chunk === 'string' && compactionData) {
|
||||
compactionData.buffer += chunk;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (event.delta.type === 'input_json_delta') {
|
||||
(contentBlock as AIChatToolUseStream)!.addPartialJSON(
|
||||
event.delta.partial_json,
|
||||
@@ -391,18 +502,37 @@ export class ClaudeProvider implements IChatProvider {
|
||||
// signature_delta — ignored
|
||||
}
|
||||
}
|
||||
const finalUsage = await completion
|
||||
const finalMessage = await completion
|
||||
.finalMessage()
|
||||
.then((msg) =>
|
||||
this.#usageFormatterUtil(
|
||||
msg.usage as Usage | BetaUsage,
|
||||
),
|
||||
)
|
||||
.catch(() => null);
|
||||
if (finalUsage) {
|
||||
if (finalMessage) {
|
||||
const finalUsage = this.#usageFormatterUtil(
|
||||
finalMessage.usage as Usage | BetaUsage,
|
||||
);
|
||||
for (const [key, value] of Object.entries(finalUsage)) {
|
||||
usageSum[key] = value;
|
||||
}
|
||||
// Fallback: some SDK versions surface the compaction block
|
||||
// only in the final message, not as a streamed block.
|
||||
if (!emittedCompaction) {
|
||||
const block = (
|
||||
(finalMessage.content as unknown[]) ?? []
|
||||
).find(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(c: any) => c?.type === 'compaction',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any;
|
||||
if (block) {
|
||||
chatStream.compaction({
|
||||
id: block.id,
|
||||
encrypted_content:
|
||||
block.content ??
|
||||
block.encrypted_content ??
|
||||
'',
|
||||
});
|
||||
emittedCompaction = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
chatStream.end(usageSum);
|
||||
const costsOverrideFromModel =
|
||||
@@ -423,7 +553,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = await (usesBetaFiles
|
||||
const msg = await (usesBeta
|
||||
? this.anthropic.beta.messages.create(sdkParams)
|
||||
: this.anthropic.messages.create(sdkParams));
|
||||
const usage = this.#usageFormatterUtil(
|
||||
@@ -440,7 +570,37 @@ export class ClaudeProvider implements IChatProvider {
|
||||
costsOverrideFromModel,
|
||||
);
|
||||
|
||||
return { message: msg, usage, finish_reason: 'stop' };
|
||||
// Surface any inline-compaction artifact for stateless round-trip.
|
||||
const compactionBlock = (
|
||||
((msg as Message).content as unknown[]) ?? []
|
||||
).find(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(c: any) => c?.type === 'compaction',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) as any;
|
||||
|
||||
return {
|
||||
message: msg,
|
||||
usage,
|
||||
finish_reason: 'stop',
|
||||
...(compactionBlock
|
||||
? {
|
||||
compaction: {
|
||||
// `type` makes the artifact a drop-in `messages`
|
||||
// item for the round-trip (symmetric with the
|
||||
// streaming compaction chunk).
|
||||
type: 'compaction' as const,
|
||||
...(compactionBlock.id !== undefined
|
||||
? { id: compactionBlock.id }
|
||||
: {}),
|
||||
encrypted_content:
|
||||
compactionBlock.content ??
|
||||
compactionBlock.encrypted_content ??
|
||||
'',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
} finally {
|
||||
await cleanupUploads();
|
||||
}
|
||||
@@ -448,8 +608,31 @@ export class ClaudeProvider implements IChatProvider {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
#usageFormatterUtil(usage: any) {
|
||||
// Compaction responses report per-pass usage in `usage.iterations` (the
|
||||
// compaction pass + the message pass). Per Anthropic's docs the
|
||||
// top-level `input_tokens`/`output_tokens` reflect ONLY the
|
||||
// non-compaction passes, so to bill the (often large) compaction pass we
|
||||
// must sum across all iterations. Compaction is billed at normal token
|
||||
// rates, and iterations don't break out cache fields — those stay
|
||||
// top-level (the message pass's), which bills the compaction input at
|
||||
// the full input rate.
|
||||
const iterations = Array.isArray(usage?.iterations)
|
||||
? usage.iterations
|
||||
: null;
|
||||
const inputTokens = iterations
|
||||
? iterations.reduce(
|
||||
(sum: number, it: any) => sum + (it?.input_tokens || 0),
|
||||
0,
|
||||
)
|
||||
: usage?.input_tokens || 0;
|
||||
const outputTokens = iterations
|
||||
? iterations.reduce(
|
||||
(sum: number, it: any) => sum + (it?.output_tokens || 0),
|
||||
0,
|
||||
)
|
||||
: usage?.output_tokens || 0;
|
||||
return {
|
||||
input_tokens: usage?.input_tokens || 0,
|
||||
input_tokens: inputTokens,
|
||||
ephemeral_5m_input_tokens:
|
||||
usage?.cache_creation?.ephemeral_5m_input_tokens ||
|
||||
usage?.cache_creation_input_tokens ||
|
||||
@@ -457,7 +640,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
ephemeral_1h_input_tokens:
|
||||
usage?.cache_creation?.ephemeral_1h_input_tokens || 0,
|
||||
cache_read_input_tokens: usage?.cache_read_input_tokens || 0,
|
||||
output_tokens: usage?.output_tokens || 0,
|
||||
output_tokens: outputTokens,
|
||||
thinking_tokens:
|
||||
usage?.thinking_tokens ||
|
||||
usage?.output_tokens_details?.thinking_tokens ||
|
||||
|
||||
@@ -50,6 +50,35 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
context: 1000000,
|
||||
max_tokens: 128000,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-sonnet-5',
|
||||
id: 'claude-sonnet-5',
|
||||
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
release_date: '2026-06-30',
|
||||
aliases: [
|
||||
'claude-sonnet',
|
||||
'claude-sonnet-latest',
|
||||
'claude-sonnet-5-latest',
|
||||
'claude-sonnet-5',
|
||||
'anthropic/claude-sonnet-5',
|
||||
],
|
||||
name: 'Claude Sonnet 5',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'input_tokens',
|
||||
output_cost_key: 'output_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
input_tokens: 300,
|
||||
ephemeral_5m_input_tokens: 300 * 1.25,
|
||||
ephemeral_1h_input_tokens: 300 * 2,
|
||||
cache_read_input_tokens: 300 * 0.1,
|
||||
output_tokens: 1500,
|
||||
},
|
||||
context: 1000000,
|
||||
max_tokens: 64000,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-opus-4-8',
|
||||
id: 'claude-opus-4-8',
|
||||
@@ -119,8 +148,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
knowledge: '2025-08',
|
||||
release_date: '2026-02-17',
|
||||
aliases: [
|
||||
'claude-sonnet-latest',
|
||||
'claude-sonnet',
|
||||
'claude-sonnet-4-6-latest',
|
||||
'claude-sonnet-4.6',
|
||||
'claude-sonnet-4-6',
|
||||
|
||||
@@ -355,6 +355,8 @@ describe('GeminiChatProvider.complete non-stream output', () => {
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
thinking_tokens: 0,
|
||||
grounding_requests: 0,
|
||||
});
|
||||
|
||||
// gemini-2.5-flash costs: prompt=30, completion=250, cached=3.
|
||||
@@ -367,11 +369,15 @@ describe('GeminiChatProvider.complete non-stream output', () => {
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 10,
|
||||
thinking_tokens: 0,
|
||||
grounding_requests: 0,
|
||||
});
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 90 * Number(flash.costs.prompt_tokens),
|
||||
completion_tokens: 50 * Number(flash.costs.completion_tokens),
|
||||
cached_tokens: 10 * Number(flash.costs.cached_tokens ?? 0),
|
||||
thinking_tokens: 0,
|
||||
grounding_requests: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -446,6 +452,8 @@ describe('GeminiChatProvider.complete streaming', () => {
|
||||
prompt_tokens: 3, // 4 - 1 cached
|
||||
completion_tokens: 2,
|
||||
cached_tokens: 1,
|
||||
thinking_tokens: 0,
|
||||
grounding_requests: 0,
|
||||
});
|
||||
|
||||
const flash = GEMINI_MODELS.find((m) => m.id === 'gemini-2.5-flash')!;
|
||||
@@ -456,10 +464,192 @@ describe('GeminiChatProvider.complete streaming', () => {
|
||||
prompt_tokens: 3 * Number(flash.costs.prompt_tokens),
|
||||
completion_tokens: 2 * Number(flash.costs.completion_tokens),
|
||||
cached_tokens: 1 * Number(flash.costs.cached_tokens ?? 0),
|
||||
thinking_tokens: 0,
|
||||
grounding_requests: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Thinking-token metering ─────────────────────────────────────────
|
||||
|
||||
describe('GeminiChatProvider.complete thinking token metering', () => {
|
||||
it('splits thinking tokens out of completion_tokens and bills each at its own rate', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'result', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 60,
|
||||
completion_tokens_details: { reasoning_tokens: 20 },
|
||||
prompt_tokens_details: { cached_tokens: 10 },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [{ role: 'user', content: 'think hard' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect((result as { usage: unknown }).usage).toEqual({
|
||||
prompt_tokens: 90, // 100 - 10 cached
|
||||
completion_tokens: 40, // 60 - 20 thinking
|
||||
cached_tokens: 10,
|
||||
thinking_tokens: 20,
|
||||
grounding_requests: 0,
|
||||
});
|
||||
|
||||
const flash = GEMINI_MODELS.find((m) => m.id === 'gemini-2.5-flash')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage.thinking_tokens).toBe(20);
|
||||
expect(usage.completion_tokens).toBe(40);
|
||||
expect(overrides).toMatchObject({
|
||||
thinking_tokens: 20 * Number(flash.costs.thinking_tokens),
|
||||
completion_tokens: 40 * Number(flash.costs.completion_tokens),
|
||||
});
|
||||
});
|
||||
|
||||
it('sets thinking_tokens to 0 when completion_tokens_details is absent', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 3 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [usage] = recordSpy.mock.calls[0]!;
|
||||
expect(usage.thinking_tokens).toBe(0);
|
||||
expect(usage.completion_tokens).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Grounding-request metering ───────────────────────────────────────
|
||||
|
||||
describe('GeminiChatProvider.complete grounding request metering', () => {
|
||||
it('charges one grounding request when non-stream response contains grounding_metadata', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: 'result',
|
||||
role: 'assistant',
|
||||
extra_content: {
|
||||
grounding_metadata: { web_search_queries: ['foo'] },
|
||||
},
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [{ role: 'user', content: 'search for foo' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const flash = GEMINI_MODELS.find((m) => m.id === 'gemini-2.5-flash')!;
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage.grounding_requests).toBe(1);
|
||||
expect(overrides!.grounding_requests).toBe(
|
||||
1 * Number(flash.costs.grounding_requests),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not charge a grounding request when no grounding_metadata is present', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 3 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [usage] = recordSpy.mock.calls[0]!;
|
||||
expect(usage.grounding_requests).toBe(0);
|
||||
});
|
||||
|
||||
it('charges one grounding request when streaming response contains grounding extra_content', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockReturnValueOnce(
|
||||
asAsyncIterable([
|
||||
{ choices: [{ delta: { content: 'ans' } }] },
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
extra_content: {
|
||||
grounding_metadata: {
|
||||
web_search_queries: ['bar'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
choices: [{ delta: {} }],
|
||||
usage: { prompt_tokens: 8, completion_tokens: 4 },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'gemini-2.5-flash',
|
||||
messages: [{ role: 'user', content: 'search bar' }],
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const harness = makeCapturingChatStream();
|
||||
await (
|
||||
result as {
|
||||
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
|
||||
}
|
||||
).init_chat_stream({ chatStream: harness.chatStream });
|
||||
|
||||
const flash = GEMINI_MODELS.find((m) => m.id === 'gemini-2.5-flash')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage.grounding_requests).toBe(1);
|
||||
expect(overrides!.grounding_requests).toBe(
|
||||
1 * Number(flash.costs.grounding_requests),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error mapping ───────────────────────────────────────────────────
|
||||
|
||||
describe('GeminiChatProvider.complete error mapping', () => {
|
||||
|
||||
@@ -102,24 +102,52 @@ export class GeminiChatProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
return handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
usage_calculator: (args) => {
|
||||
// Cast to access Gemini-specific extras passed alongside usage:
|
||||
// - choices: non-stream grounding metadata lives in choices[0].message.extra_content
|
||||
// - extra_content: streaming grounding metadata accumulated by the stream handler
|
||||
const { usage, choices, extra_content } = args as {
|
||||
usage: typeof args.usage;
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
extra_content?: { grounding_metadata?: unknown };
|
||||
};
|
||||
}>;
|
||||
extra_content?: { grounding_metadata?: unknown };
|
||||
};
|
||||
|
||||
const cached_tokens =
|
||||
usage?.prompt_tokens_details?.cached_tokens ?? 0;
|
||||
|
||||
// Thinking tokens are a subset of completion_tokens billed at a different rate
|
||||
const thinking_tokens =
|
||||
usage?.completion_tokens_details?.reasoning_tokens ?? 0;
|
||||
|
||||
const trackedUsage = {
|
||||
prompt_tokens:
|
||||
(usage.prompt_tokens ?? 0) -
|
||||
(usage.prompt_tokens_details?.cached_tokens ?? 0),
|
||||
completion_tokens: usage.completion_tokens ?? 0,
|
||||
cached_tokens:
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
prompt_tokens: (usage?.prompt_tokens ?? 0) - cached_tokens,
|
||||
completion_tokens: Math.max(
|
||||
0,
|
||||
(usage?.completion_tokens ?? 0) - thinking_tokens,
|
||||
),
|
||||
cached_tokens,
|
||||
thinking_tokens,
|
||||
// Grounding search is a per-request fee not reflected in token counts
|
||||
grounding_requests:
|
||||
(choices?.[0]?.message?.extra_content
|
||||
?.grounding_metadata ??
|
||||
extra_content?.grounding_metadata)
|
||||
? 1
|
||||
: 0,
|
||||
};
|
||||
|
||||
const costsOverrideFromModel = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([k, v]) => {
|
||||
return [k, v * modelUsed.costs[k]];
|
||||
return [k, v * (modelUsed.costs[k] ?? 0)];
|
||||
}),
|
||||
);
|
||||
this.meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor,
|
||||
actor!,
|
||||
`gemini:${modelUsed?.id}`,
|
||||
costsOverrideFromModel,
|
||||
);
|
||||
|
||||
@@ -43,7 +43,10 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 150,
|
||||
completion_tokens: 900,
|
||||
thinking_tokens: 900,
|
||||
cached_tokens: 15,
|
||||
// Gemini 3.x grounding is $14 / 1,000 requests
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -68,6 +71,8 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 40,
|
||||
cached_tokens: 3,
|
||||
// Gemini 2.x grounding is $35 / 1,000 requests
|
||||
grounding_requests: 3_500_000,
|
||||
},
|
||||
max_tokens: 8192,
|
||||
},
|
||||
@@ -115,8 +120,13 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30,
|
||||
// Output is $2.50/M; thinking tokens bill at the same output rate
|
||||
completion_tokens: 250,
|
||||
thinking_tokens: 250,
|
||||
// Cache read is $0.03/M (10% of input)
|
||||
cached_tokens: 3,
|
||||
// Gemini 2.x grounding is $35 / 1,000 requests
|
||||
grounding_requests: 3_500_000,
|
||||
},
|
||||
max_tokens: 65536,
|
||||
},
|
||||
@@ -141,7 +151,10 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 40,
|
||||
thinking_tokens: 40,
|
||||
cached_tokens: 1,
|
||||
// Gemini 2.x grounding is $35 / 1,000 requests
|
||||
grounding_requests: 3_500_000,
|
||||
},
|
||||
max_tokens: 65536,
|
||||
},
|
||||
@@ -166,7 +179,10 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 125,
|
||||
completion_tokens: 1000,
|
||||
cached_tokens: 13,
|
||||
thinking_tokens: 1000,
|
||||
cached_tokens: 31,
|
||||
// Gemini 2.x grounding is $35 / 1,000 requests
|
||||
grounding_requests: 3_500_000,
|
||||
},
|
||||
max_tokens: 200_000,
|
||||
},
|
||||
@@ -191,7 +207,9 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 1200,
|
||||
thinking_tokens: 1200,
|
||||
cached_tokens: 20,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
max_tokens: 65536,
|
||||
},
|
||||
@@ -216,7 +234,9 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 300,
|
||||
thinking_tokens: 300,
|
||||
cached_tokens: 5,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
max_tokens: 65536,
|
||||
},
|
||||
@@ -245,7 +265,9 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 25,
|
||||
completion_tokens: 150,
|
||||
thinking_tokens: 150,
|
||||
cached_tokens: 2.5,
|
||||
grounding_requests: 1_400_000,
|
||||
},
|
||||
max_tokens: 65536,
|
||||
},
|
||||
|
||||
@@ -143,6 +143,11 @@ describe('MiniMaxProvider model catalog', () => {
|
||||
|
||||
it('uses MiniMax completion-token limits instead of the context window', () => {
|
||||
for (const model of MINIMAX_MODELS) {
|
||||
if (model.id === 'minimax-m3') {
|
||||
expect(model.context).toBe(1_048_576);
|
||||
expect(model.max_tokens).toBe(512_000);
|
||||
continue;
|
||||
}
|
||||
expect(model.context).toBe(204_800);
|
||||
expect(model.max_tokens).toBe(196_608);
|
||||
}
|
||||
|
||||
@@ -18,60 +18,215 @@
|
||||
*/
|
||||
|
||||
import type { IChatModel } from '../../types.js';
|
||||
import { usdPerMToken } from '../../utils/pricing.js';
|
||||
|
||||
const CONTEXT_WINDOW = 204_800;
|
||||
const MAX_OUTPUT_TOKENS = 196_608;
|
||||
|
||||
type MiniMaxChatModel = IChatModel & {
|
||||
apiModel: string;
|
||||
};
|
||||
|
||||
const minimaxModel = (
|
||||
apiModel: string,
|
||||
name: string,
|
||||
costs: IChatModel['costs'],
|
||||
): MiniMaxChatModel => {
|
||||
const id = apiModel.toLowerCase();
|
||||
return {
|
||||
puterId: `minimax:minimax/${id}`,
|
||||
id,
|
||||
apiModel,
|
||||
name,
|
||||
aliases: [`minimax/${id}`, apiModel, `minimax/${apiModel}`],
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
context: CONTEXT_WINDOW,
|
||||
max_tokens: MAX_OUTPUT_TOKENS,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs,
|
||||
};
|
||||
};
|
||||
|
||||
// Hardcoded from MiniMax OpenAI-compatible API docs and pay-as-you-go pricing:
|
||||
// https://platform.minimax.io/docs/api-reference/text-openai-api
|
||||
// https://platform.minimax.io/docs/guides/pricing-paygo
|
||||
export const MINIMAX_MODELS: MiniMaxChatModel[] = [
|
||||
minimaxModel('MiniMax-M2.7', 'MiniMax M2.7', usdPerMToken(0.3, 1.2, 0.06)),
|
||||
minimaxModel(
|
||||
'MiniMax-M2.7-highspeed',
|
||||
'MiniMax M2.7 Highspeed',
|
||||
usdPerMToken(0.6, 2.4, 0.06),
|
||||
),
|
||||
minimaxModel('MiniMax-M2.5', 'MiniMax M2.5', usdPerMToken(0.3, 1.2, 0.03)),
|
||||
minimaxModel(
|
||||
'MiniMax-M2.5-highspeed',
|
||||
'MiniMax M2.5 Highspeed',
|
||||
usdPerMToken(0.6, 2.4, 0.03),
|
||||
),
|
||||
minimaxModel('MiniMax-M2.1', 'MiniMax M2.1', usdPerMToken(0.3, 1.2, 0.03)),
|
||||
minimaxModel(
|
||||
'MiniMax-M2.1-highspeed',
|
||||
'MiniMax M2.1 Highspeed',
|
||||
usdPerMToken(0.6, 2.4, 0.03),
|
||||
),
|
||||
minimaxModel('MiniMax-M2', 'MiniMax M2', usdPerMToken(0.3, 1.2, 0.03)),
|
||||
// -- MiniMax M3 (Flagship) --------------------------------------
|
||||
// MiniMax Sparse Attention, 1M context, native multimodal input.
|
||||
{
|
||||
puterId: 'minimax:minimax/minimax-m3',
|
||||
id: 'minimax-m3',
|
||||
apiModel: 'MiniMax-M3',
|
||||
name: 'MiniMax M3',
|
||||
aliases: ['minimax/minimax-m3', 'MiniMax-M3', 'minimax/MiniMax-M3'],
|
||||
modalities: { input: ['text', 'image', 'video'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
context: 1_048_576,
|
||||
max_tokens: 512_000,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30, // $0.30 per 1M (input <= 512k tokens)
|
||||
completion_tokens: 120, // $1.20 per 1M
|
||||
cached_tokens: 6, // $0.06 per 1M
|
||||
},
|
||||
},
|
||||
|
||||
// -- MiniMax M2.7 -----------------------------------------------
|
||||
{
|
||||
puterId: 'minimax:minimax/minimax-m2.7',
|
||||
id: 'minimax-m2.7',
|
||||
apiModel: 'MiniMax-M2.7',
|
||||
name: 'MiniMax M2.7',
|
||||
aliases: [
|
||||
'minimax/minimax-m2.7',
|
||||
'MiniMax-M2.7',
|
||||
'minimax/MiniMax-M2.7',
|
||||
],
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
context: 204_800,
|
||||
max_tokens: 196_608,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30, // $0.30 per 1M
|
||||
completion_tokens: 120, // $1.20 per 1M
|
||||
cached_tokens: 6, // $0.06 per 1M
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'minimax:minimax/minimax-m2.7-highspeed',
|
||||
id: 'minimax-m2.7-highspeed',
|
||||
apiModel: 'MiniMax-M2.7-highspeed',
|
||||
name: 'MiniMax M2.7 Highspeed',
|
||||
aliases: [
|
||||
'minimax/minimax-m2.7-highspeed',
|
||||
'MiniMax-M2.7-highspeed',
|
||||
'minimax/MiniMax-M2.7-highspeed',
|
||||
],
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
context: 204_800,
|
||||
max_tokens: 196_608,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 60, // $0.60 per 1M
|
||||
completion_tokens: 240, // $2.40 per 1M
|
||||
cached_tokens: 6, // $0.06 per 1M
|
||||
},
|
||||
},
|
||||
|
||||
// -- MiniMax M2.5 -----------------------------------------------
|
||||
{
|
||||
puterId: 'minimax:minimax/minimax-m2.5',
|
||||
id: 'minimax-m2.5',
|
||||
apiModel: 'MiniMax-M2.5',
|
||||
name: 'MiniMax M2.5',
|
||||
aliases: [
|
||||
'minimax/minimax-m2.5',
|
||||
'MiniMax-M2.5',
|
||||
'minimax/MiniMax-M2.5',
|
||||
],
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
context: 204_800,
|
||||
max_tokens: 196_608,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30, // $0.30 per 1M
|
||||
completion_tokens: 120, // $1.20 per 1M
|
||||
cached_tokens: 3, // $0.03 per 1M
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'minimax:minimax/minimax-m2.5-highspeed',
|
||||
id: 'minimax-m2.5-highspeed',
|
||||
apiModel: 'MiniMax-M2.5-highspeed',
|
||||
name: 'MiniMax M2.5 Highspeed',
|
||||
aliases: [
|
||||
'minimax/minimax-m2.5-highspeed',
|
||||
'MiniMax-M2.5-highspeed',
|
||||
'minimax/MiniMax-M2.5-highspeed',
|
||||
],
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
context: 204_800,
|
||||
max_tokens: 196_608,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 60, // $0.60 per 1M
|
||||
completion_tokens: 240, // $2.40 per 1M
|
||||
cached_tokens: 3, // $0.03 per 1M
|
||||
},
|
||||
},
|
||||
|
||||
// -- MiniMax M2.1 -----------------------------------------------
|
||||
{
|
||||
puterId: 'minimax:minimax/minimax-m2.1',
|
||||
id: 'minimax-m2.1',
|
||||
apiModel: 'MiniMax-M2.1',
|
||||
name: 'MiniMax M2.1',
|
||||
aliases: [
|
||||
'minimax/minimax-m2.1',
|
||||
'MiniMax-M2.1',
|
||||
'minimax/MiniMax-M2.1',
|
||||
],
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
context: 204_800,
|
||||
max_tokens: 196_608,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30, // $0.30 per 1M
|
||||
completion_tokens: 120, // $1.20 per 1M
|
||||
cached_tokens: 3, // $0.03 per 1M
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'minimax:minimax/minimax-m2.1-highspeed',
|
||||
id: 'minimax-m2.1-highspeed',
|
||||
apiModel: 'MiniMax-M2.1-highspeed',
|
||||
name: 'MiniMax M2.1 Highspeed',
|
||||
aliases: [
|
||||
'minimax/minimax-m2.1-highspeed',
|
||||
'MiniMax-M2.1-highspeed',
|
||||
'minimax/MiniMax-M2.1-highspeed',
|
||||
],
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
context: 204_800,
|
||||
max_tokens: 196_608,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 60, // $0.60 per 1M
|
||||
completion_tokens: 240, // $2.40 per 1M
|
||||
cached_tokens: 3, // $0.03 per 1M
|
||||
},
|
||||
},
|
||||
|
||||
// -- MiniMax M2 -------------------------------------------------
|
||||
{
|
||||
puterId: 'minimax:minimax/minimax-m2',
|
||||
id: 'minimax-m2',
|
||||
apiModel: 'MiniMax-M2',
|
||||
name: 'MiniMax M2',
|
||||
aliases: ['minimax/minimax-m2', 'MiniMax-M2', 'minimax/MiniMax-M2'],
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
context: 204_800,
|
||||
max_tokens: 196_608,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30, // $0.30 per 1M
|
||||
completion_tokens: 120, // $1.20 per 1M
|
||||
cached_tokens: 3, // $0.03 per 1M
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -26,6 +26,10 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ
|
||||
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
|
||||
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
|
||||
import type { IChatProvider, ICompleteArguments } from '../../types.js';
|
||||
import {
|
||||
messagesHaveCompaction,
|
||||
wantsCompaction,
|
||||
} from '../../utils/compaction.js';
|
||||
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
|
||||
import { processPuterPathUploads } from './fileUpload.js';
|
||||
import { OPEN_AI_MODELS } from './models.js';
|
||||
@@ -125,6 +129,20 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
}
|
||||
return await this.#responsesProvider.complete(params);
|
||||
}
|
||||
// Inline compaction is a Responses-API feature; chat.completions can't
|
||||
// express `context_management` or a `compaction` content block.
|
||||
// Delegate to the sibling Responses provider when the caller opted in
|
||||
// OR when the messages carry a round-tripped compaction artifact.
|
||||
if (wantsCompaction(params) || messagesHaveCompaction(messages)) {
|
||||
if (!this.#responsesProvider) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'compaction requires the OpenAI Responses provider, which is not configured',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await this.#responsesProvider.complete(params);
|
||||
}
|
||||
// Validate messages
|
||||
if (!Array.isArray(messages)) {
|
||||
throw new HttpError(400, '`messages` must be an array', {
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ
|
||||
import type { FSEntryStore } from '../../../../stores/fs/FSEntryStore.js';
|
||||
import type { S3ObjectStore } from '../../../../stores/fs/S3ObjectStore.js';
|
||||
import type { IChatProvider, ICompleteArguments } from '../../types.js';
|
||||
import { toOpenAiContextManagement } from '../../utils/compaction.js';
|
||||
import * as OpenAiUtil from '../../utils/OpenAIUtil.js';
|
||||
import { processPuterPathUploads } from './fileUpload.js';
|
||||
import { OPEN_AI_MODELS } from './models.js';
|
||||
@@ -100,6 +101,8 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
parallel_tool_calls,
|
||||
include,
|
||||
conversation,
|
||||
compaction,
|
||||
context_management,
|
||||
previous_response_id,
|
||||
instructions,
|
||||
metadata,
|
||||
@@ -177,6 +180,13 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
const supportsReasoningControls =
|
||||
typeof model === 'string' && model.startsWith('gpt-5');
|
||||
|
||||
// Translate the neutral compaction opt-in (or pass a raw
|
||||
// `context_management` payload through) to OpenAI's Responses shape.
|
||||
const contextManagement = toOpenAiContextManagement({
|
||||
compaction,
|
||||
context_management,
|
||||
});
|
||||
|
||||
const completionParams: ResponseCreateParams = {
|
||||
user: userIdentifier,
|
||||
safety_identifier: userIdentifier,
|
||||
@@ -188,6 +198,9 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
? { parallel_tool_calls }
|
||||
: {}),
|
||||
...(include !== undefined ? { include } : {}),
|
||||
...(contextManagement !== undefined
|
||||
? { context_management: contextManagement }
|
||||
: {}),
|
||||
...(conversation !== undefined ? { conversation } : {}),
|
||||
...(previous_response_id !== undefined
|
||||
? { previous_response_id }
|
||||
|
||||
@@ -150,21 +150,7 @@ export class TogetherAIProvider implements IChatProvider {
|
||||
messages,
|
||||
stream,
|
||||
...(tools ? { tools } : {}),
|
||||
// TODO: make this better but togetherai doesn't handle max tokens properly at all
|
||||
...(max_tokens
|
||||
? {
|
||||
max_tokens:
|
||||
max_tokens -
|
||||
messages.reduce((acc, curr) => {
|
||||
return (
|
||||
acc +
|
||||
(curr.type === 'text'
|
||||
? curr.text.length / 2
|
||||
: 200)
|
||||
);
|
||||
}, 0),
|
||||
}
|
||||
: {}),
|
||||
...(max_tokens ? { max_tokens } : {}),
|
||||
...(temperature ? { temperature } : {}),
|
||||
...(stream ? { stream_options: { include_usage: true } } : {}),
|
||||
} as Together.Chat.Completions.CompletionCreateParamsNonStreaming);
|
||||
|
||||
@@ -69,6 +69,13 @@ const visionModel = (
|
||||
// Hardcoded from https://docs.z.ai/api-reference/llm/chat-completion and
|
||||
// https://docs.z.ai/guides/overview/pricing.
|
||||
export const ZAI_MODELS: IChatModel[] = [
|
||||
textModel(
|
||||
'glm-5.2',
|
||||
'GLM-5.2',
|
||||
1_000 * K,
|
||||
128 * K,
|
||||
usdPerMToken(1.4, 4.4, 0.26),
|
||||
),
|
||||
textModel(
|
||||
'glm-5.1',
|
||||
'GLM-5.1',
|
||||
|
||||
@@ -67,6 +67,19 @@ export interface ICompleteArguments {
|
||||
parallel_tool_calls?: boolean;
|
||||
include?: unknown[];
|
||||
conversation?: unknown;
|
||||
/**
|
||||
* Provider-neutral inline-compaction opt-in. `true` enables compaction with
|
||||
* provider defaults; `{ trigger_tokens }` sets the token threshold at which
|
||||
* the upstream summarizes earlier context. Each provider translates this to
|
||||
* its own SDK shape (OpenAI `context_management:[{type:'compaction',...}]`,
|
||||
* Anthropic `context_management:{edits:[{type:'compact_20260112'}]}`).
|
||||
*/
|
||||
compaction?: boolean | { trigger_tokens?: number };
|
||||
/**
|
||||
* Escape hatch: provider-native `context_management` payload, passed through
|
||||
* untouched (used by `/responses` callers sending the OpenAI-native array).
|
||||
*/
|
||||
context_management?: unknown;
|
||||
previous_response_id?: string;
|
||||
instructions?: string | PuterMessage[];
|
||||
metadata?: Record<string, string>;
|
||||
@@ -116,6 +129,13 @@ export interface IChatMessageResult {
|
||||
finally_fn?: never;
|
||||
normalized?: boolean;
|
||||
via_ai_chat_service?: boolean;
|
||||
/**
|
||||
* Inline-compaction artifact, present when the upstream compacted earlier
|
||||
* context during this (non-streaming) response. Carries `type:'compaction'`
|
||||
* so it's a drop-in `messages` item — the caller resends it on the next turn
|
||||
* in place of the summarized history. See [[ICompleteArguments]].
|
||||
*/
|
||||
compaction?: { type: 'compaction'; id?: string; encrypted_content: string };
|
||||
}
|
||||
|
||||
export type IChatCompleteResult = IChatStreamResult | IChatMessageResult;
|
||||
|
||||
@@ -49,6 +49,23 @@ export const normalize_single_message = (message, params = {}) => {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
// A round-tripped inline-compaction artifact may be supplied as a bare
|
||||
// top-level item (the shape the client received from the stream). Wrap it
|
||||
// into an internal compaction content block so it survives normalization;
|
||||
// each provider maps it back to its native input shape (OpenAI top-level
|
||||
// input item, Anthropic content block).
|
||||
if (!message.role && !message.content && message.type === 'compaction') {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'compaction',
|
||||
...(message.id !== undefined ? { id: message.id } : {}),
|
||||
encrypted_content: message.encrypted_content,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (!message.role) {
|
||||
message.role = params.role;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,22 @@ describe('normalize_single_message', () => {
|
||||
expect(result.role).toBe('system');
|
||||
});
|
||||
|
||||
it('wraps a bare round-tripped compaction item into a compaction content block', () => {
|
||||
const result = normalize_single_message({
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
});
|
||||
expect(result.role).toBe('assistant');
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws 400 when message is null/undefined/array', () => {
|
||||
expect(() => normalize_single_message(null)).toThrow(
|
||||
expect.objectContaining({ statusCode: 400 }),
|
||||
|
||||
@@ -84,6 +84,39 @@ export const process_input_messages = async (messages) => {
|
||||
};
|
||||
|
||||
export const process_input_messages_responses_api = async (messages) => {
|
||||
// Pre-split round-tripped compaction blocks into standalone Responses
|
||||
// compaction input items, preserving any sibling content (e.g. the
|
||||
// assistant's reply text) as its own message. A compaction item represents
|
||||
// prior history, so it precedes the message it was attached to. This avoids
|
||||
// collapsing the whole message into a single compaction item and dropping
|
||||
// the rest of its content.
|
||||
const expanded = [];
|
||||
for (const msg of messages) {
|
||||
if (msg && Array.isArray(msg.content)) {
|
||||
const compactionBlocks = msg.content.filter(
|
||||
(c) => c && c.type === 'compaction',
|
||||
);
|
||||
if (compactionBlocks.length > 0) {
|
||||
for (const block of compactionBlocks) {
|
||||
expanded.push({
|
||||
type: 'compaction',
|
||||
...(block.id !== undefined ? { id: block.id } : {}),
|
||||
encrypted_content: block.encrypted_content,
|
||||
});
|
||||
}
|
||||
const rest = msg.content.filter(
|
||||
(c) => !(c && c.type === 'compaction'),
|
||||
);
|
||||
if (rest.length > 0) {
|
||||
expanded.push({ ...msg, content: rest });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
expanded.push(msg);
|
||||
}
|
||||
messages = expanded;
|
||||
|
||||
for (const msg of messages) {
|
||||
const content_as_string = (content) => {
|
||||
if (content === undefined || content === null) return '';
|
||||
@@ -253,6 +286,7 @@ export const create_chat_stream_handler =
|
||||
const tool_call_blocks = [];
|
||||
|
||||
let last_usage = null;
|
||||
let last_extra_content = null;
|
||||
for await (let chunk of completion) {
|
||||
chunk = deviations.chunk_but_like_actually(chunk);
|
||||
const chunk_usage = deviations.index_usage_from_stream_chunk(chunk);
|
||||
@@ -285,6 +319,14 @@ export const create_chat_stream_handler =
|
||||
// Apps have to choose to handle extra_content themselves, it doesn't seem like theres a way we can do it in a backwards
|
||||
// compatible fashion since most streaming apps will handle chat history by continuously updating content themselves
|
||||
// This doesn't present us a chance to add in an extra object for gemini's chat continuing features
|
||||
// Don't let a later extra_content chunk without grounding clobber an
|
||||
// earlier one that carried grounding_metadata (used for metering).
|
||||
if (
|
||||
choice.delta.extra_content.grounding_metadata ||
|
||||
!last_extra_content?.grounding_metadata
|
||||
) {
|
||||
last_extra_content = choice.delta.extra_content;
|
||||
}
|
||||
textblock.addExtraContent(choice.delta.extra_content);
|
||||
}
|
||||
|
||||
@@ -315,7 +357,10 @@ export const create_chat_stream_handler =
|
||||
}
|
||||
|
||||
// TODO DS: this is a bit too abstracted... this is basically just doing the metering now
|
||||
const usage = usage_calculator({ usage: last_usage });
|
||||
const usage = usage_calculator({
|
||||
usage: last_usage,
|
||||
extra_content: last_extra_content,
|
||||
});
|
||||
|
||||
if (mode === 'text') textblock.end();
|
||||
if (mode === 'tool') toolblock.end();
|
||||
@@ -355,6 +400,19 @@ export const create_chat_stream_handler_responses_api =
|
||||
last_usage = chunk.response.usage;
|
||||
}
|
||||
|
||||
if (
|
||||
chunk.type === 'response.output_item.done' &&
|
||||
chunk.item?.type === 'compaction'
|
||||
) {
|
||||
// Inline compaction fired mid-response — normalize the artifact
|
||||
// into the canonical internal compaction event.
|
||||
chatStream.compaction({
|
||||
id: chunk.item.id,
|
||||
encrypted_content: chunk.item.encrypted_content,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
chunk.type === 'response.output_item.done' &&
|
||||
chunk.item?.type === 'function_call'
|
||||
@@ -488,10 +546,14 @@ export const handle_completion_output_responses_api = async ({
|
||||
...(item.id ? { canonical_id: item.id } : {}),
|
||||
}));
|
||||
|
||||
// Inline-compaction artifact, if the upstream compacted this turn.
|
||||
const compactionItem = output.find((item) => item?.type === 'compaction');
|
||||
|
||||
const is_empty = completion.output_text.trim() === '';
|
||||
if (is_empty && responseToolCalls.length < 1) {
|
||||
if (is_empty && responseToolCalls.length < 1 && !compactionItem) {
|
||||
// GPT refuses to generate an empty response if you ask it to,
|
||||
// so this will probably only happen on an error condition.
|
||||
// A compaction-only output is legitimate, so don't reject it.
|
||||
throw new HttpError(400, 'an empty response was generated', {
|
||||
legacyCode: 'bad_response',
|
||||
});
|
||||
@@ -523,6 +585,18 @@ export const handle_completion_output_responses_api = async ({
|
||||
};
|
||||
ret.role = output.find((item) => item?.role)?.role ?? 'assistant';
|
||||
|
||||
if (compactionItem) {
|
||||
// Include `type` so the artifact is a drop-in `messages` item for the
|
||||
// stateless round-trip — symmetric with the streaming compaction chunk.
|
||||
ret.compaction = {
|
||||
type: 'compaction',
|
||||
...(compactionItem.id !== undefined
|
||||
? { id: compactionItem.id }
|
||||
: {}),
|
||||
encrypted_content: compactionItem.encrypted_content,
|
||||
};
|
||||
}
|
||||
|
||||
delete ret.type;
|
||||
|
||||
ret.usage = usage_calculator
|
||||
|
||||
@@ -232,6 +232,66 @@ describe('process_input_messages_responses_api', () => {
|
||||
expect(out!.output).toBe('part-apart-bpart-c');
|
||||
});
|
||||
|
||||
it('rewrites a round-tripped compaction content block into a top-level compaction item', async () => {
|
||||
const messages: Array<Record<string, unknown>> = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const [out] = (await process_input_messages_responses_api(
|
||||
messages,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
expect(out!.type).toBe('compaction');
|
||||
expect(out!.encrypted_content).toBe('ENC');
|
||||
expect(out!.id).toBe('cmpct_1');
|
||||
expect(out!.role).toBeUndefined();
|
||||
expect(out!.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it('splits a compaction block + reply text into a compaction item plus a preserved message', async () => {
|
||||
// Round-tripping an assistant turn that carries BOTH the compaction
|
||||
// artifact and the reply text must keep the reply, not drop it.
|
||||
const messages: Array<Record<string, unknown>> = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
},
|
||||
{ type: 'text', text: 'earlier reply' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const out = (await process_input_messages_responses_api(
|
||||
messages,
|
||||
)) as Array<Record<string, unknown>>;
|
||||
|
||||
// Compaction item comes first (it represents prior history)...
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0]).toEqual({
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
});
|
||||
// ...followed by the assistant message with the reply preserved
|
||||
// (assistant text upgraded to output_text).
|
||||
expect(out[1]!.role).toBe('assistant');
|
||||
expect(out[1]!.content).toEqual([
|
||||
{ type: 'output_text', text: 'earlier reply' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('upgrades user/system text blocks to input_text', async () => {
|
||||
const messages: Array<Record<string, unknown>> = [
|
||||
{
|
||||
@@ -539,6 +599,39 @@ describe('create_chat_stream_handler_responses_api', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a compaction event when a compaction output_item completes', async () => {
|
||||
const completion = asAsyncIterable([
|
||||
{
|
||||
type: 'response.output_item.done',
|
||||
item: {
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'response.completed',
|
||||
response: { usage: { input_tokens: 1, output_tokens: 2 } },
|
||||
},
|
||||
]);
|
||||
const init = create_chat_stream_handler_responses_api({
|
||||
deviations: undefined,
|
||||
completion,
|
||||
usage_calculator: () => ({}),
|
||||
});
|
||||
const harness = makeCapturingChatStream();
|
||||
await init({ chatStream: harness.chatStream });
|
||||
|
||||
const compaction = harness
|
||||
.events()
|
||||
.find((e: { type: string }) => e.type === 'compaction');
|
||||
expect(compaction).toEqual({
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a tool_use block when a function_call output_item completes', async () => {
|
||||
const completion = asAsyncIterable([
|
||||
{
|
||||
@@ -765,6 +858,31 @@ describe('handle_completion_output_responses_api non-stream', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('attaches a compaction artifact and allows a compaction-only output', async () => {
|
||||
const completion = {
|
||||
output: [
|
||||
{
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
},
|
||||
],
|
||||
output_text: ' ',
|
||||
usage: { input_tokens: 1, output_tokens: 0 },
|
||||
};
|
||||
const result = await handle_completion_output_responses_api({
|
||||
deviations: undefined,
|
||||
stream: false,
|
||||
completion,
|
||||
});
|
||||
// Compaction-only output is not rejected as "empty".
|
||||
expect(result.compaction).toEqual({
|
||||
type: 'compaction',
|
||||
id: 'cmpct_1',
|
||||
encrypted_content: 'ENC',
|
||||
});
|
||||
});
|
||||
|
||||
it('runs moderation against output_text when a moderate fn is supplied', async () => {
|
||||
const completion = {
|
||||
output: [{ role: 'assistant' }],
|
||||
|
||||
@@ -103,6 +103,25 @@ export class AIChatStream {
|
||||
this.stream.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a canonical compaction event into the NDJSON stream. Both the OpenAI
|
||||
* and Anthropic providers normalize their native inline-compaction artifact
|
||||
* to this single shape, so downstream consumers (controllers, puter.js) see
|
||||
* an identical `{ type: 'compaction', id, encrypted_content }` chunk
|
||||
* regardless of which upstream served the request.
|
||||
*
|
||||
* @param {{ id?: string, encrypted_content: string }} compaction
|
||||
*/
|
||||
compaction({ id, encrypted_content }) {
|
||||
this.stream.write(
|
||||
`${JSON.stringify({
|
||||
type: 'compaction',
|
||||
...(id !== undefined ? { id } : {}),
|
||||
encrypted_content,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
message() {
|
||||
return new AIChatMessageStream(this);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Inline-compaction translation helpers.
|
||||
*
|
||||
* The driver-facing surface exposes a single provider-neutral opt-in
|
||||
* (`compaction: boolean | { trigger_tokens }` on `ICompleteArguments`), plus a
|
||||
* raw `context_management` escape hatch for callers hitting `/responses` with
|
||||
* the OpenAI-native shape. These helpers map that neutral opt-in to each
|
||||
* provider's SDK shape so the providers stay free of opt-in-parsing logic.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {boolean | { trigger_tokens?: number } | undefined} compaction
|
||||
* @returns {{ enabled: boolean, trigger_tokens?: number }}
|
||||
*/
|
||||
const readCompaction = (compaction) => {
|
||||
if (compaction === true) return { enabled: true };
|
||||
if (compaction && typeof compaction === 'object') {
|
||||
return {
|
||||
enabled: true,
|
||||
...(typeof compaction.trigger_tokens === 'number'
|
||||
? { trigger_tokens: compaction.trigger_tokens }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return { enabled: false };
|
||||
};
|
||||
|
||||
/**
|
||||
* Build OpenAI Responses `context_management` from the neutral opt-in. A raw
|
||||
* `context_management` passthrough (already in OpenAI shape) wins.
|
||||
*
|
||||
* @param {{ compaction?: boolean | { trigger_tokens?: number }, context_management?: unknown }} args
|
||||
* @returns {Array<{ type: 'compaction', compact_threshold?: number }> | undefined}
|
||||
*/
|
||||
export const toOpenAiContextManagement = (args) => {
|
||||
if (args.context_management !== undefined) {
|
||||
return /** @type {any} */ (args.context_management);
|
||||
}
|
||||
const { enabled, trigger_tokens } = readCompaction(args.compaction);
|
||||
if (!enabled) return undefined;
|
||||
return [
|
||||
{
|
||||
type: 'compaction',
|
||||
...(trigger_tokens !== undefined
|
||||
? { compact_threshold: trigger_tokens }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build Anthropic `context_management` (beta `compact-2026-01-12`) from the
|
||||
* neutral opt-in. A raw `context_management` passthrough wins.
|
||||
*
|
||||
* @param {{ compaction?: boolean | { trigger_tokens?: number }, context_management?: unknown }} args
|
||||
* @returns {{ edits: Array<Record<string, unknown>> } | undefined}
|
||||
*/
|
||||
export const toAnthropicContextManagement = (args) => {
|
||||
if (args.context_management !== undefined) {
|
||||
return /** @type {any} */ (args.context_management);
|
||||
}
|
||||
const { enabled, trigger_tokens } = readCompaction(args.compaction);
|
||||
if (!enabled) return undefined;
|
||||
return {
|
||||
edits: [
|
||||
{
|
||||
type: 'compact_20260112',
|
||||
...(trigger_tokens !== undefined
|
||||
? {
|
||||
trigger: {
|
||||
type: 'input_tokens',
|
||||
value: trigger_tokens,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the request opted into inline compaction by any route.
|
||||
*
|
||||
* @param {{ compaction?: boolean | { trigger_tokens?: number }, context_management?: unknown }} args
|
||||
*/
|
||||
export const wantsCompaction = (args) =>
|
||||
args.context_management !== undefined ||
|
||||
readCompaction(args.compaction).enabled;
|
||||
|
||||
/**
|
||||
* Whether the (normalized) message list carries a round-tripped compaction
|
||||
* artifact. Such a request must route through a compaction-capable surface even
|
||||
* if it didn't request *new* compaction — chat.completions can't represent a
|
||||
* compaction content block, and Anthropic needs its compaction beta to accept
|
||||
* one as input.
|
||||
*
|
||||
* @param {unknown} messages
|
||||
*/
|
||||
export const messagesHaveCompaction = (messages) =>
|
||||
Array.isArray(messages) &&
|
||||
messages.some(
|
||||
(m) =>
|
||||
Array.isArray(m?.content) &&
|
||||
m.content.some((c) => c && c.type === 'compaction'),
|
||||
);
|
||||
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
messagesHaveCompaction,
|
||||
toAnthropicContextManagement,
|
||||
toOpenAiContextManagement,
|
||||
wantsCompaction,
|
||||
} from './compaction.js';
|
||||
|
||||
describe('toOpenAiContextManagement', () => {
|
||||
it('returns undefined when compaction is off', () => {
|
||||
expect(toOpenAiContextManagement({})).toBeUndefined();
|
||||
expect(
|
||||
toOpenAiContextManagement({ compaction: false }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds a compaction entry from `true`', () => {
|
||||
expect(toOpenAiContextManagement({ compaction: true })).toEqual([
|
||||
{ type: 'compaction' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps trigger_tokens to compact_threshold', () => {
|
||||
expect(
|
||||
toOpenAiContextManagement({ compaction: { trigger_tokens: 1000 } }),
|
||||
).toEqual([{ type: 'compaction', compact_threshold: 1000 }]);
|
||||
});
|
||||
|
||||
it('passes a raw context_management payload through verbatim', () => {
|
||||
const raw = [{ type: 'compaction', compact_threshold: 5 }];
|
||||
expect(
|
||||
toOpenAiContextManagement({
|
||||
compaction: true,
|
||||
context_management: raw,
|
||||
}),
|
||||
).toBe(raw);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAnthropicContextManagement', () => {
|
||||
it('returns undefined when compaction is off', () => {
|
||||
expect(toAnthropicContextManagement({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds a compact_20260112 edit from `true`', () => {
|
||||
expect(toAnthropicContextManagement({ compaction: true })).toEqual({
|
||||
edits: [{ type: 'compact_20260112' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps trigger_tokens to an input_tokens trigger', () => {
|
||||
expect(
|
||||
toAnthropicContextManagement({
|
||||
compaction: { trigger_tokens: 2000 },
|
||||
}),
|
||||
).toEqual({
|
||||
edits: [
|
||||
{
|
||||
type: 'compact_20260112',
|
||||
trigger: { type: 'input_tokens', value: 2000 },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a raw context_management payload through verbatim', () => {
|
||||
const raw = { edits: [{ type: 'compact_20260112' }] };
|
||||
expect(
|
||||
toAnthropicContextManagement({ context_management: raw }),
|
||||
).toBe(raw);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wantsCompaction', () => {
|
||||
it('is false without opt-in', () => {
|
||||
expect(wantsCompaction({})).toBe(false);
|
||||
expect(wantsCompaction({ compaction: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('is true for the neutral opt-in or a raw passthrough', () => {
|
||||
expect(wantsCompaction({ compaction: true })).toBe(true);
|
||||
expect(wantsCompaction({ compaction: { trigger_tokens: 1 } })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(wantsCompaction({ context_management: [] })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('messagesHaveCompaction', () => {
|
||||
it('detects a round-tripped compaction content block', () => {
|
||||
expect(
|
||||
messagesHaveCompaction([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'compaction', encrypted_content: 'ENC' },
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for ordinary messages or non-arrays', () => {
|
||||
expect(
|
||||
messagesHaveCompaction([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||
]),
|
||||
).toBe(false);
|
||||
expect(messagesHaveCompaction(undefined)).toBe(false);
|
||||
expect(messagesHaveCompaction('nope')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -55,20 +55,30 @@ import type { ImageGenerationDriver } from './ImageGenerationDriver.js';
|
||||
// driver-level tests — what we care about is the driver's routing.
|
||||
// Each `generate` mock resolves to a sentinel URL the test inspects.
|
||||
|
||||
const { openaiImagesGenerateMock } = vi.hoisted(() => ({
|
||||
const { openaiImagesGenerateMock, openaiImagesEditMock } = vi.hoisted(() => ({
|
||||
openaiImagesGenerateMock: vi.fn(),
|
||||
openaiImagesEditMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('openai', () => {
|
||||
const OpenAICtor = vi.fn().mockImplementation(function (
|
||||
this: Record<string, unknown>,
|
||||
) {
|
||||
this.images = { generate: openaiImagesGenerateMock };
|
||||
this.images = {
|
||||
generate: openaiImagesGenerateMock,
|
||||
edit: openaiImagesEditMock,
|
||||
};
|
||||
// xAI provider reaches its JSON edit endpoint through the SDK's post().
|
||||
this.post = vi.fn();
|
||||
this.chat = { completions: { create: vi.fn() } };
|
||||
this.moderations = { create: vi.fn() };
|
||||
this.responses = { create: vi.fn() };
|
||||
});
|
||||
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
|
||||
return {
|
||||
OpenAI: OpenAICtor,
|
||||
default: { OpenAI: OpenAICtor },
|
||||
toFile: vi.fn(async () => ({ __file: true })),
|
||||
};
|
||||
});
|
||||
|
||||
const { googleAIGenerateContentMock, googleAIGenerateImagesMock } = vi.hoisted(
|
||||
@@ -148,6 +158,7 @@ afterAll(async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
openaiImagesGenerateMock.mockReset();
|
||||
openaiImagesEditMock.mockReset();
|
||||
googleAIGenerateContentMock.mockReset();
|
||||
googleAIGenerateImagesMock.mockReset();
|
||||
togetherImagesGenerateMock.mockReset();
|
||||
@@ -176,9 +187,9 @@ describe('ImageGenerationDriver.generate authentication', () => {
|
||||
it('throws 401 when no actor is on the request context', async () => {
|
||||
await expect(
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
@@ -206,10 +217,10 @@ describe('ImageGenerationDriver model catalog', () => {
|
||||
const all = await driver.models();
|
||||
// Every catalog id from at least one provider must be reachable.
|
||||
const ids = all.map((m) => m.id);
|
||||
// OpenAI catalog: dall-e-2 should be present (lowercased by buildModelMap).
|
||||
expect(ids).toContain('dall-e-2');
|
||||
// xAI catalog: grok-2-image should be present.
|
||||
expect(ids).toContain('grok-2-image');
|
||||
// OpenAI catalog: gpt-image-1-mini should be present (lowercased by buildModelMap).
|
||||
expect(ids).toContain('gpt-image-1-mini');
|
||||
// xAI catalog: grok-imagine-image should be present.
|
||||
expect(ids).toContain('grok-imagine-image');
|
||||
});
|
||||
|
||||
it('list() returns ids/puterIds sorted', async () => {
|
||||
@@ -224,32 +235,35 @@ describe('ImageGenerationDriver model catalog', () => {
|
||||
costValue: number;
|
||||
source: string;
|
||||
}>;
|
||||
// dall-e-2 has a 256x256 cost line — must surface in reportedCosts.
|
||||
const dalle256 = reported.find(
|
||||
(r) => r.usageType === 'openai-image-generation:dall-e-2:256x256',
|
||||
// gpt-image-1-mini has a low:1024x1024 cost line — must surface in reportedCosts.
|
||||
const gptLine = reported.find(
|
||||
(r) =>
|
||||
r.usageType ===
|
||||
'openai-image-generation:gpt-image-1-mini:low:1024x1024',
|
||||
);
|
||||
expect(dalle256).toBeDefined();
|
||||
expect(dalle256?.costValue).toBe(
|
||||
OPEN_AI_IMAGE_GENERATION_MODELS.find((m) => m.id === 'dall-e-2')!
|
||||
.costs['256x256'],
|
||||
expect(gptLine).toBeDefined();
|
||||
expect(gptLine?.costValue).toBe(
|
||||
OPEN_AI_IMAGE_GENERATION_MODELS.find(
|
||||
(m) => m.id === 'gpt-image-1-mini',
|
||||
)!.costs['low:1024x1024'],
|
||||
);
|
||||
expect(dalle256?.source).toBe('driver:aiImage/openai-image-generation');
|
||||
expect(gptLine?.source).toBe('driver:aiImage/openai-image-generation');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Provider routing ────────────────────────────────────────────────
|
||||
|
||||
describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
it('routes a known dall-e-2 model id to the OpenAI image provider', async () => {
|
||||
it('routes a known gpt-image-1-mini model id to the OpenAI image provider', async () => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/img.png' }],
|
||||
});
|
||||
|
||||
const result = await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
);
|
||||
|
||||
@@ -260,34 +274,34 @@ describe('ImageGenerationDriver.generate provider routing', () => {
|
||||
expect(replicateRunMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes a known grok-2-image id to the xAI image provider (also OpenAI-SDK shaped)', async () => {
|
||||
it('routes a known grok-imagine-image id to the xAI image provider (also OpenAI-SDK shaped)', async () => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://xai/img.png' }],
|
||||
});
|
||||
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'grok-2-image',
|
||||
model: 'grok-imagine-image',
|
||||
prompt: 'hi',
|
||||
} as never),
|
||||
);
|
||||
|
||||
// xAI's provider also uses the OpenAI mock — assert via the call args.
|
||||
const sent = openaiImagesGenerateMock.mock.calls[0]![0];
|
||||
expect(sent.model).toBe('grok-2-image');
|
||||
expect(sent.model).toBe('grok-imagine-image');
|
||||
expect(sent.prompt).toBe('hi');
|
||||
});
|
||||
|
||||
it('lowercases model lookups so case variants resolve (DALL-E-2 → dall-e-2)', async () => {
|
||||
it('lowercases model lookups so case variants resolve (GPT-Image-1-Mini → gpt-image-1-mini)', async () => {
|
||||
openaiImagesGenerateMock.mockResolvedValueOnce({
|
||||
data: [{ url: 'https://oai/img.png' }],
|
||||
});
|
||||
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'DALL-E-2',
|
||||
model: 'GPT-Image-1-Mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
);
|
||||
|
||||
@@ -322,16 +336,16 @@ describe('ImageGenerationDriver.generate ratio normalization', () => {
|
||||
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
width: 256,
|
||||
height: 256,
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
} as never),
|
||||
);
|
||||
|
||||
// Provider received a size derived from the ratio normalization.
|
||||
const sent = openaiImagesGenerateMock.mock.calls[0]![0];
|
||||
expect(sent.size).toBe('256x256');
|
||||
expect(sent.size).toBe('1024x1536');
|
||||
});
|
||||
|
||||
it('parses aspect_ratio "w:h" into ratio when width/height are absent', async () => {
|
||||
@@ -369,9 +383,9 @@ describe('ImageGenerationDriver.generate audit log', () => {
|
||||
|
||||
await withActor(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
);
|
||||
|
||||
@@ -381,7 +395,7 @@ describe('ImageGenerationDriver.generate audit log', () => {
|
||||
expect(aiLogCall).toBeDefined();
|
||||
const [, payload] = aiLogCall!;
|
||||
const p = payload as Record<string, unknown>;
|
||||
expect(p.model_used).toBe('dall-e-2');
|
||||
expect(p.model_used).toBe('gpt-image-1-mini');
|
||||
expect(p.service_used).toBe('openai-image-generation');
|
||||
// completionId is a fresh uuid-style string per call.
|
||||
expect(typeof p.completionId).toBe('string');
|
||||
@@ -395,9 +409,9 @@ describe('ImageGenerationDriver.generate audit log', () => {
|
||||
await expect(
|
||||
withActor(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
ratio: { w: 256, h: 256 },
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
} as never),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
@@ -424,7 +438,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/',
|
||||
} as never),
|
||||
@@ -438,7 +452,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/image.png',
|
||||
} as never),
|
||||
@@ -455,7 +469,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/somedir/image.png',
|
||||
} as never),
|
||||
@@ -480,7 +494,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
await expect(
|
||||
withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/dir/img.png',
|
||||
} as never),
|
||||
@@ -509,7 +523,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
|
||||
await withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '~/images/out.png',
|
||||
} as never),
|
||||
@@ -541,7 +555,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
|
||||
const result = await withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/photos/out.png',
|
||||
} as never),
|
||||
@@ -584,7 +598,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
|
||||
await withTestUser(() =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/testuser/dir/img.png',
|
||||
} as never),
|
||||
@@ -602,7 +616,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
|
||||
Promise.resolve(
|
||||
runWithContext({ actor: noIdActor }, () =>
|
||||
driver.generate({
|
||||
model: 'dall-e-2',
|
||||
model: 'gpt-image-1-mini',
|
||||
prompt: 'hi',
|
||||
puter_output_path: '/noone/dir/img.png',
|
||||
} as never),
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shared helpers for `input_images` (image-to-image) handling across image
|
||||
* providers. `input_images` is the canonical, cross-provider field; an entry
|
||||
* may be a public URL, a data-URI, or raw base64. Providers whose upstream
|
||||
* API needs base64 use these helpers to normalize URLs server-side (via the
|
||||
* SSRF-guarded `secureFetch`); providers that accept URLs natively (Replicate,
|
||||
* xAI) pass them through untouched.
|
||||
*/
|
||||
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { secureFetch } from '../../util/secureHttp.js';
|
||||
import type { IGenerateParams } from './types.js';
|
||||
|
||||
export function isHttpUrl(s: string): boolean {
|
||||
return s.startsWith('http://') || s.startsWith('https://');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the single input image for providers that only support one.
|
||||
* Throws 400 if `input_images` carries more than one entry. Returns the
|
||||
* chosen image string (URL / data-URI / raw base64) or undefined.
|
||||
*/
|
||||
export function resolveSingleInputImage(
|
||||
params: Pick<IGenerateParams, 'input_image' | 'input_images'>,
|
||||
providerLabel: string,
|
||||
): string | undefined {
|
||||
const imgs = params.input_images;
|
||||
if (imgs && imgs.length > 1) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`${providerLabel} supports only a single input image; pass one image via input_image or a single-element input_images.`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return params.input_image ?? imgs?.[0];
|
||||
}
|
||||
|
||||
const DATA_URI_PATTERN = /^data:([^;,]+)?(?:;base64)?,(.*)$/s;
|
||||
|
||||
/** Parse a `data:<mime>;base64,<payload>` URI into raw base64 + mime. */
|
||||
export function parseDataUri(
|
||||
s: string,
|
||||
): { base64: string; mime: string } | null {
|
||||
const m = DATA_URI_PATTERN.exec(s);
|
||||
if (!m) return null;
|
||||
return { base64: m[2] ?? '', mime: m[1] ?? 'image/png' };
|
||||
}
|
||||
|
||||
/** Fetch an http(s) image and return raw base64 + mime (SSRF-guarded). */
|
||||
export async function fetchImageAsBase64(
|
||||
url: string,
|
||||
): Promise<{ base64: string; mime: string }> {
|
||||
const res = await secureFetch(url);
|
||||
if (!res.ok) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Failed to fetch input image (status ${res.status})`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const mime =
|
||||
res.headers.get('content-type')?.split(';')[0]?.trim() || 'image/png';
|
||||
return { base64: buffer.toString('base64'), mime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize any input-image string to a base64 data-URI:
|
||||
* • http(s) URL → fetched via secureFetch
|
||||
* • data-URI → returned as-is
|
||||
* • raw base64 → wrapped with `mimeHint` (default image/png)
|
||||
*/
|
||||
export async function toBase64DataUri(
|
||||
img: string,
|
||||
mimeHint?: string,
|
||||
): Promise<string> {
|
||||
if (img.startsWith('data:')) return img;
|
||||
if (isHttpUrl(img)) {
|
||||
const { base64, mime } = await fetchImageAsBase64(img);
|
||||
return `data:${mime};base64,${base64}`;
|
||||
}
|
||||
return `data:${mimeHint ?? 'image/png'};base64,${img}`;
|
||||
}
|
||||
@@ -47,6 +47,16 @@ import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './models.js';
|
||||
import { CloudflareImageProvider } from './CloudflareImageProvider.js';
|
||||
|
||||
// Stub the URL→base64 fetch so URL inputs stay offline; keep the rest real.
|
||||
const { fetchImageAsBase64Mock } = vi.hoisted(() => ({
|
||||
fetchImageAsBase64Mock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../inputImage.js', async (orig) => ({
|
||||
...(await orig<typeof import('../../inputImage.js')>()),
|
||||
fetchImageAsBase64: fetchImageAsBase64Mock,
|
||||
}));
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
@@ -81,6 +91,7 @@ const makeProvider = (
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchImageAsBase64Mock.mockReset();
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
@@ -470,3 +481,70 @@ describe('CloudflareImageProvider.generate cost components', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── input_images (canonical image-to-image field) ──────────────────
|
||||
|
||||
describe('CloudflareImageProvider.generate input_images', () => {
|
||||
const klein9bWith = (extra: Record<string, unknown>) => {
|
||||
const provider = makeProvider();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(Buffer.from([1, 2, 3]).buffer, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'image/png' },
|
||||
}),
|
||||
);
|
||||
return withTestActor(() =>
|
||||
provider.generate({
|
||||
model: '@cf/black-forest-labs/flux-2-klein-9b',
|
||||
prompt: 'edit it',
|
||||
ratio: { w: 2000, h: 1000 },
|
||||
...extra,
|
||||
} as never),
|
||||
);
|
||||
};
|
||||
|
||||
const hasInputCostLine = () => {
|
||||
const [, entries] = batchIncrementUsagesSpy.mock.calls[0]!;
|
||||
return (entries as Array<{ usageType: string }>).some((e) =>
|
||||
e.usageType.endsWith(':input_image_mp'),
|
||||
);
|
||||
};
|
||||
|
||||
it('maps a base64/data-URI input_images entry to the input image (cost line appears)', async () => {
|
||||
await klein9bWith({ input_images: ['data:image/png;base64,AAAA'] });
|
||||
expect(hasInputCostLine()).toBe(true);
|
||||
});
|
||||
|
||||
it('maps a singular input_image to the input image', async () => {
|
||||
await klein9bWith({ input_image: 'data:image/png;base64,AAAA' });
|
||||
expect(hasInputCostLine()).toBe(true);
|
||||
});
|
||||
|
||||
it('fetches an http(s) URL input via secureFetch and uses it as the input image', async () => {
|
||||
fetchImageAsBase64Mock.mockResolvedValueOnce({
|
||||
base64: 'AAAA',
|
||||
mime: 'image/png',
|
||||
});
|
||||
await klein9bWith({ input_images: ['https://example.com/in.png'] });
|
||||
expect(fetchImageAsBase64Mock).toHaveBeenCalledWith(
|
||||
'https://example.com/in.png',
|
||||
);
|
||||
expect(hasInputCostLine()).toBe(true);
|
||||
});
|
||||
|
||||
it('throws 400 when more than one input image is supplied (before any fetch)', async () => {
|
||||
const provider = makeProvider();
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.generate({
|
||||
model: '@cf/black-forest-labs/flux-2-klein-9b',
|
||||
prompt: 'edit it',
|
||||
ratio: { w: 1024, h: 1024 },
|
||||
input_images: ['data:image/png;base64,AAAA', 'data:image/png;base64,BBBB'],
|
||||
} as never),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(fetchImageAsBase64Mock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,11 @@ import {
|
||||
CLOUDFLARE_IMAGE_GENERATION_MODELS,
|
||||
CloudflareImageModel,
|
||||
} from './models.js';
|
||||
import {
|
||||
fetchImageAsBase64,
|
||||
isHttpUrl,
|
||||
resolveSingleInputImage,
|
||||
} from '../../inputImage.js';
|
||||
|
||||
type CloudflareGenerateParams = IGenerateParams & {
|
||||
steps?: number;
|
||||
@@ -101,6 +106,16 @@ export class CloudflareImageProvider implements IImageProvider {
|
||||
});
|
||||
}
|
||||
|
||||
// Canonical `input_images`/`input_image` → Cloudflare's `image` field.
|
||||
// Cloudflare accepts a single input image; a URL is fetched to base64
|
||||
// server-side (SSRF-guarded) since the API has no URL field.
|
||||
const singleInput = resolveSingleInputImage(options, 'Cloudflare');
|
||||
if (singleInput) {
|
||||
options.image ??= isHttpUrl(singleInput)
|
||||
? (await fetchImageAsBase64(singleInput)).base64
|
||||
: singleInput;
|
||||
}
|
||||
|
||||
const steps = this.#resolveSteps(selectedModel, options);
|
||||
const costComponents = this.#estimateCost(selectedModel, ratio, steps, {
|
||||
hasInputImage:
|
||||
|
||||
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user