mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4bb57bb79 | ||
|
|
861cf5efb8 | ||
|
|
cf34f9fea9 | ||
|
|
92a6d30e26 | ||
|
|
23e8705b03 | ||
|
|
3240a4670a | ||
|
|
3bf83d40e9 | ||
|
|
c3368f553e | ||
|
|
27def94d8b | ||
|
|
2c139929ac | ||
|
|
b957f797da | ||
|
|
393500d565 | ||
|
|
a108837e82 | ||
|
|
859e88753f | ||
|
|
5160b44c4b | ||
|
|
17a9485fb2 | ||
|
|
d3c44f3d6c | ||
|
|
d49ba5281d | ||
|
|
c107c80bd8 | ||
|
|
cf90e775bf | ||
|
|
0a46096c41 | ||
|
|
289a44ae79 | ||
|
|
827e9bc343 | ||
|
|
f2ffaeb823 | ||
|
|
6f594f4b60 | ||
|
|
326be042f1 | ||
|
|
5b824fb745 | ||
|
|
cf6cb126b5 | ||
|
|
302df1edd2 | ||
|
|
e212509ed3 | ||
|
|
d3e3755d6d | ||
|
|
948ce6e529 | ||
|
|
d117dc231e | ||
|
|
eefa53e5bd | ||
|
|
db89fbfc52 | ||
|
|
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
@@ -53,7 +53,7 @@ The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts))
|
||||
|
||||
- **Modules:** We transpile and build as needed — write ES modules, not CommonJS.
|
||||
- **TypeScript preferred for new files.** Existing JS is fine; convert opportunistically when you're already touching a file.
|
||||
- **Reuse types before inventing them.** Search for an existing type first; extend it if close. Only define a new type when nothing fits.
|
||||
- **Reuse types and code before adding duplicates.** Before defining a type, search for an existing one; extend it if close. Before writing a helper or repeating logic, search for an existing utility, helper, or implementation and reuse or extend it. Only add a new type or helper when nothing suitable exists.
|
||||
- **Make new types findable.** Co-locate them with the layer/module that owns them, export from the obvious entry point, and use a descriptive `PascalCase` name. Don't hide types in random files where future readers won't grep them.
|
||||
- **Naming:** `camelCase` for variables/functions, `PascalCase` for classes and for files containing a class (`AuthService.ts`, `KVStoreDriver.ts`).
|
||||
|
||||
@@ -64,9 +64,11 @@ 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.
|
||||
If comments need to be more than a line, its probably too long, and when it does need multiline comments, use multiline `/** ... */` JSDoc style for consistency. Use `//` for single-line comments.
|
||||
|
||||
**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.
|
||||
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.
|
||||
|
||||
**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",
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
// `dev` opens a browser on boot, skips blocked-email checks, and runs the
|
||||
// dev-time webpack watcher; `prod` serves pre-built bundles.
|
||||
"env": "dev",
|
||||
// Console output format. `json` replaces the global console so every call
|
||||
// emits one structured JSON line (level, timestamp, msg, and the active
|
||||
// request's trace id) — one event per call, so a line-oriented log
|
||||
// collector can't split stack traces across events and level filtering
|
||||
// works. Unset (the default) leaves console output human-readable.
|
||||
"log_format": "text",
|
||||
"version": "0.0.0",
|
||||
// Stable identity for this server node — used by pager alerts and
|
||||
// graceful-shutdown coordination.
|
||||
@@ -75,6 +81,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();
|
||||
@@ -154,6 +158,49 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.directories).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redacts tmp_password from metadata for user actors', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
hasDevAccountAccess: true,
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
const metadata = body.metadata as Record<string, unknown>;
|
||||
expect(metadata.tmp_password).toBeUndefined();
|
||||
// Other metadata keys still reach the user's own client.
|
||||
expect(metadata.hasDevAccountAccess).toBe(true);
|
||||
expect(body.hasDevAccountAccess).toBe(true);
|
||||
});
|
||||
|
||||
it('never sends user metadata to app actors', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{
|
||||
actor: {
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
},
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.metadata).toBeUndefined();
|
||||
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
|
||||
});
|
||||
|
||||
it('marks the user as oidc_only when password is null', async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const oidcUser = await server.stores.user.create({
|
||||
|
||||
+10
-1
@@ -65,6 +65,9 @@ export const handleWhoami = async (
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = user.metadata ? { ...user.metadata } : user.metadata;
|
||||
if (metadata) delete metadata.tmp_password;
|
||||
|
||||
const details: Record<string, unknown> = {
|
||||
username: user.username,
|
||||
uuid: user.uuid,
|
||||
@@ -72,10 +75,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(
|
||||
@@ -94,7 +101,7 @@ export const handleWhoami = async (
|
||||
human_readable_age: user.timestamp
|
||||
? timeago.format(new Date(user.timestamp as string))
|
||||
: null,
|
||||
metadata: user.metadata,
|
||||
metadata,
|
||||
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
|
||||
};
|
||||
|
||||
@@ -157,6 +164,8 @@ export const handleWhoami = async (
|
||||
delete details.desktop_bg_color;
|
||||
delete details.desktop_bg_fit;
|
||||
delete details.human_readable_age;
|
||||
delete details.is_user_token;
|
||||
delete details.metadata;
|
||||
}
|
||||
|
||||
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
+5187
-4782
File diff suppressed because it is too large.
Load diff
+18
-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,21 @@
|
||||
"ai": "^6.0.73",
|
||||
"dedent": "^1.5.3",
|
||||
"javascript-time-ago": "^2.5.11",
|
||||
"libphonenumber-js": "1.13.6",
|
||||
"miniflare": "^4.20260617.1",
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { createPool, type Pool } from 'mysql2';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
import { SQLBatcher } from './SQLBatcher.js';
|
||||
import { splitMysqlStatements } from './splitMysqlStatements.js';
|
||||
@@ -135,6 +136,10 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
// Query interface
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// The db.* spans measure the logical query, including time queued in
|
||||
// the SQLBatcher — the mysql2 auto-instrumentation only sees the
|
||||
// coalesced multi-statement flush, so per-query latency lives here.
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -144,6 +149,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
|
||||
override async pread(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -153,6 +159,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
return (result[0] as Record<string, unknown>[]) ?? [];
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -170,6 +177,9 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(
|
||||
entries: { statement: string; values: unknown[] }[],
|
||||
): Promise<void> {
|
||||
@@ -195,6 +205,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve as resolvePath } from 'path';
|
||||
import { Pool, type PoolConfig, type QueryResult } from 'pg';
|
||||
import { Span } from '../../util/span.js';
|
||||
import {
|
||||
AbstractDatabaseClient,
|
||||
type BatchEntry,
|
||||
@@ -214,6 +215,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -222,6 +224,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return normalizePostgresRows(result);
|
||||
}
|
||||
|
||||
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
|
||||
override async pread(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -230,6 +233,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return normalizePostgresRows(result);
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -238,6 +242,9 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
return mapPostgresWriteResult(result);
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(entries: BatchEntry[]): Promise<void> {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
@@ -258,6 +265,7 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
|
||||
}
|
||||
}
|
||||
|
||||
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
|
||||
override async tryHardRead(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
|
||||
@@ -21,6 +21,7 @@ import { existsSync, mkdirSync, readFileSync } from 'fs';
|
||||
import { basename, dirname, extname, join, resolve } from 'path';
|
||||
import { createContext, runInContext } from 'vm';
|
||||
import type { IConfig } from '../../types';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
|
||||
const MIGRATIONS_DIR = resolve(__dirname, './migrations/sqlite');
|
||||
@@ -86,6 +87,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 {
|
||||
@@ -129,6 +137,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
// Query interface
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@Span('db.read', (query: string) => ({ 'db.statement': query }))
|
||||
override async read(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -149,6 +158,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
return this.read(query, params);
|
||||
}
|
||||
|
||||
@Span('db.write', (query: string) => ({ 'db.statement': query }))
|
||||
override async write(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
@@ -165,6 +175,9 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('db.batchWrite', (entries: unknown[]) => ({
|
||||
'db.batch_size': entries.length,
|
||||
}))
|
||||
override async batchWrite(
|
||||
entries: { statement: string; values: unknown[] }[],
|
||||
): Promise<void> {
|
||||
|
||||
@@ -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`);
|
||||
@@ -43,6 +43,7 @@ import { once } from 'node:events';
|
||||
import { Agent as httpsAgent } from 'node:https';
|
||||
import { HttpError } from '../../core/http';
|
||||
import type { IConfig, IDynamoConfig } from '../../types';
|
||||
import { Span } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
|
||||
const LOCAL_DYNAMO_MEMORY_PREFIX = ':memory:';
|
||||
@@ -151,6 +152,7 @@ export class DDBClient extends PuterClient {
|
||||
await this.#localInitPromise;
|
||||
}
|
||||
|
||||
@Span('ddb.get', (table: string) => ({ 'db.table': table }))
|
||||
async get<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
key: T,
|
||||
@@ -167,6 +169,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
|
||||
async put<T extends Record<string, unknown>>(table: string, item: T) {
|
||||
const command = new PutCommand({
|
||||
TableName: table,
|
||||
@@ -178,6 +181,9 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.batchGet', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
async batchGet(
|
||||
params: { table: string; items: Record<string, unknown> }[],
|
||||
consistentRead = false,
|
||||
@@ -212,6 +218,9 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.batchPut', (params: unknown[]) => ({
|
||||
'db.batch_size': params.length,
|
||||
}))
|
||||
async batchPut(params: { table: string; item: Record<string, unknown> }[]) {
|
||||
const consumedCapacityByTable = new Map<string, number>();
|
||||
if (params.length === 0) {
|
||||
@@ -318,6 +327,7 @@ export class DDBClient extends PuterClient {
|
||||
};
|
||||
}
|
||||
|
||||
@Span('ddb.del', (table: string) => ({ 'db.table': table }))
|
||||
async del<T extends Record<string, unknown>>(table: string, key: T) {
|
||||
const command = new DeleteCommand({
|
||||
TableName: table,
|
||||
@@ -329,6 +339,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.query', (table: string) => ({ 'db.table': table }))
|
||||
async query<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
keys: T,
|
||||
@@ -383,6 +394,7 @@ export class DDBClient extends PuterClient {
|
||||
return client.send(command);
|
||||
}
|
||||
|
||||
@Span('ddb.update', (table: string) => ({ 'db.table': table }))
|
||||
async update<T extends Record<string, unknown>>(
|
||||
table: string,
|
||||
key: T,
|
||||
|
||||
@@ -24,12 +24,20 @@ import type { IConfig } from '../../types';
|
||||
import { PuterClient } from '../types';
|
||||
import { EMAIL_TEMPLATES, type EmailTemplateName } from './templates';
|
||||
|
||||
// -- Types ------------------------------------------------------------
|
||||
/** Attachment shape passed through to the underlying transport. */
|
||||
export interface EmailAttachment {
|
||||
filename: string;
|
||||
content: Buffer | string;
|
||||
contentType?: string;
|
||||
encoding?: string;
|
||||
}
|
||||
|
||||
// nodemailer doesn't ship TS types, so declare the subset we use.
|
||||
interface NodemailerTransport {
|
||||
sendMail: (options: SendMailOptions) => Promise<unknown>;
|
||||
close?: () => void;
|
||||
/** Subset of the transport's send result callers may care about. */
|
||||
export interface SentMessageInfo {
|
||||
messageId?: string;
|
||||
accepted?: string[];
|
||||
rejected?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SendMailOptions {
|
||||
@@ -41,6 +49,7 @@ export interface SendMailOptions {
|
||||
html?: string;
|
||||
text?: string;
|
||||
replyTo?: string;
|
||||
attachments?: EmailAttachment[];
|
||||
}
|
||||
|
||||
export type EmailValidator = (email: string) => Promise<boolean> | boolean;
|
||||
@@ -100,7 +109,8 @@ const DOMAIN_ALIASES: Record<string, string> = {
|
||||
* - Policy + extensible validation (via `validate`)
|
||||
*/
|
||||
export class EmailClient extends PuterClient {
|
||||
private transport: NodemailerTransport | null = null;
|
||||
private transport: ReturnType<typeof nodemailer.createTransport> | null =
|
||||
null;
|
||||
private compiledTemplates: Partial<
|
||||
Record<EmailTemplateName, CompiledTemplate>
|
||||
> = {};
|
||||
@@ -123,8 +133,7 @@ export class EmailClient extends PuterClient {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.transport = nodemailer.createTransport(emailConf as any);
|
||||
this.transport = nodemailer.createTransport(emailConf);
|
||||
console.log('[email] transport configured');
|
||||
}
|
||||
|
||||
@@ -159,21 +168,30 @@ export class EmailClient extends PuterClient {
|
||||
/**
|
||||
* Raw send — bypasses the template system. Useful for one-off
|
||||
* admin emails that don't warrant a named template.
|
||||
*
|
||||
* Returns the transport's send result, or `null` when no transport
|
||||
* is configured (the send is a no-op in that case — callers that
|
||||
* must not silently drop mail should check `isConfigured` first).
|
||||
*/
|
||||
async sendRaw(options: SendMailOptions): Promise<void> {
|
||||
async sendRaw(options: SendMailOptions) {
|
||||
if (!this.transport) {
|
||||
console.warn(
|
||||
'[email] attempted to send email without transport. If you need to send email, configure an SMTP transport in your config file (see docs for details). Email content:',
|
||||
options,
|
||||
);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
await this.transport.sendMail({
|
||||
from: options.from ?? this.defaultFrom(),
|
||||
return await this.transport.sendMail({
|
||||
...options,
|
||||
from: options.from ?? this.defaultFrom(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether an SMTP transport is configured (sends are no-ops otherwise). */
|
||||
get isConfigured(): boolean {
|
||||
return this.transport !== null;
|
||||
}
|
||||
|
||||
// -- Public API: clean / validate ---------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { extensionStore } from '../../extensions';
|
||||
import { withSpan } from '../../util/span.js';
|
||||
import { PuterClient } from '../types';
|
||||
import {
|
||||
EventListener,
|
||||
@@ -99,26 +100,34 @@ export class EventClient extends PuterClient {
|
||||
data: EventMap[T],
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey = (
|
||||
i === parts.length - 1
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`
|
||||
) as ListenKey;
|
||||
const extensionListeners = extensionStore.events[matchKey];
|
||||
const listeners = (this.#eventListeners[matchKey] || []).concat(
|
||||
extensionListeners || [],
|
||||
);
|
||||
if (!listeners) continue;
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
console.error('Error in event listener for event', key, e);
|
||||
// Spanned because callers block on listeners — this is where time
|
||||
// spent in extension hooks (e.g. `ip.validate`) gets attributed.
|
||||
return withSpan('event.emitAndWait', { 'event.key': key }, async () => {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey = (
|
||||
i === parts.length - 1
|
||||
? key
|
||||
: `${parts.slice(0, i + 1).join('.')}.*`
|
||||
) as ListenKey;
|
||||
const extensionListeners = extensionStore.events[matchKey];
|
||||
const listeners = (this.#eventListeners[matchKey] || []).concat(
|
||||
extensionListeners || [],
|
||||
);
|
||||
if (!listeners) continue;
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'Error in event listener for event',
|
||||
key,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +155,19 @@ export class EventClient extends PuterClient {
|
||||
this.#eventListeners[key] ?? (this.#eventListeners[key] = []);
|
||||
listeners.push(callback as EventListener);
|
||||
}
|
||||
off<P extends ListenKey>(
|
||||
key: P,
|
||||
callback: (
|
||||
key: MatchingEvents<P>,
|
||||
data: EventMap[MatchingEvents<P>],
|
||||
meta: EventMetadata,
|
||||
) => Promise<void> | void,
|
||||
) {
|
||||
const listeners = this.#eventListeners[key];
|
||||
if (!listeners) return;
|
||||
const idx = listeners.indexOf(callback as EventListener);
|
||||
if (idx !== -1) listeners.splice(idx, 1);
|
||||
}
|
||||
async #emitEvent<T extends keyof EventMap>(
|
||||
listener: EventListener,
|
||||
key: T,
|
||||
|
||||
@@ -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,80 @@ 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;
|
||||
customer_id: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
'user.card-verified': {
|
||||
user_id: number;
|
||||
user_uid: string;
|
||||
fingerprint: string | null;
|
||||
funding: string | null;
|
||||
country: string | null;
|
||||
customer_id: string | null;
|
||||
};
|
||||
'user.username-changed': {
|
||||
user_id: number;
|
||||
old_username: string;
|
||||
@@ -288,6 +392,8 @@ export type EventMap = {
|
||||
// normalized path: `route.<method>.<path>.before|after|error|reject`. Same
|
||||
// wildcard + veto semantics as the driver lifecycle above.
|
||||
[K in `route.${string}`]: RouteLifecycleEvent;
|
||||
} & {
|
||||
[K in `pubsub.login.${string}`]: { authtoken: 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
@@ -19,6 +19,12 @@
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { trace } from '@opentelemetry/api';
|
||||
import {
|
||||
BasicTracerProvider,
|
||||
InMemorySpanExporter,
|
||||
SimpleSpanProcessor,
|
||||
} from '@opentelemetry/sdk-trace-base';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { actorUid, type Actor } from '../../core/actor.js';
|
||||
@@ -615,4 +621,65 @@ describe('DriverController.registerRoutes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DriverController.#handleCall tracing', () => {
|
||||
let routes: Captured;
|
||||
const exporter = new InMemorySpanExporter();
|
||||
const provider = new BasicTracerProvider({
|
||||
spanProcessors: [new SimpleSpanProcessor(exporter)],
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
routes = captureRoutes(controller);
|
||||
trace.setGlobalTracerProvider(provider);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await provider.shutdown();
|
||||
trace.disable();
|
||||
});
|
||||
|
||||
it('emits driver + downstream spans around a successful call', async () => {
|
||||
const actor = await makeUserActor();
|
||||
await server.stores.permission.setFlatUserPerm(
|
||||
actor.user!.id!,
|
||||
'service:puter-kvstore:ii:puter-kvstore',
|
||||
{
|
||||
permission: 'service:puter-kvstore:ii:puter-kvstore',
|
||||
deleted: false,
|
||||
issuer_user_id: actor.user!.id!,
|
||||
} as never,
|
||||
);
|
||||
exporter.reset();
|
||||
|
||||
const res = makeRes();
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-kvstore',
|
||||
method: 'set',
|
||||
args: { key: `k-${uuidv4()}`, value: 'v' },
|
||||
},
|
||||
actor,
|
||||
);
|
||||
await runWithContext({ actor }, () =>
|
||||
routes['POST /call'](req, res as unknown as Response, () => {}),
|
||||
);
|
||||
expect((res.body as { success: boolean }).success).toBe(true);
|
||||
|
||||
const names = exporter.getFinishedSpans().map((s) => s.name);
|
||||
const driverSpan = exporter
|
||||
.getFinishedSpans()
|
||||
.find((s) => s.name === 'driver.puter-kvstore.set');
|
||||
expect(driverSpan).toBeDefined();
|
||||
expect(driverSpan!.attributes).toMatchObject({
|
||||
driver: 'puter-kvstore',
|
||||
'driver.method': 'set',
|
||||
'driver.name': 'puter-kvstore',
|
||||
});
|
||||
// The call path exercises the permission gate and the KV store's
|
||||
// dynamo writes — both should have produced spans of their own.
|
||||
expect(names).toContain('permission.scan');
|
||||
expect(names.some((n) => n.startsWith('ddb.'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
void vi; // unused but reserved for future expansion
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import type { PermissionService } from '../../services/permission/PermissionService.js';
|
||||
import { PermissionUtil } from '../../services/permission/permissionUtil.js';
|
||||
import type { WithLifecycle } from '../../types';
|
||||
import { withSpan } from '../../util/span.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
type DriverInstance = WithLifecycle & Record<string, unknown>;
|
||||
@@ -382,11 +383,22 @@ export class DriverController extends PuterController {
|
||||
}
|
||||
|
||||
// Drivers read actor/context via the Context API — no drilled args.
|
||||
// The span ends when the method returns; for streamed results that
|
||||
// is stream start, not stream drain (same window the lifecycle
|
||||
// events below report as durationMs).
|
||||
const startedAt = Date.now();
|
||||
let result;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
result = await (fn as (...x: unknown[]) => any).call(driver, args);
|
||||
result = await withSpan(
|
||||
`driver.${ifaceName}.${method}`,
|
||||
{
|
||||
driver: ifaceName,
|
||||
'driver.method': method,
|
||||
'driver.name': resolved,
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (fn as (...x: unknown[]) => any).call(driver, args),
|
||||
);
|
||||
} catch (e) {
|
||||
this.clients.event?.emit(
|
||||
`driver.${ifaceName}.${method}.error`,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -52,6 +52,12 @@ const TEST_ORIGIN = 'http://test.local';
|
||||
let server: PuterServer;
|
||||
let router: PuterRouter;
|
||||
|
||||
// Stand-in for the abuse extension's signup veto. EventClient has no off(),
|
||||
// so one shared listener is installed in beforeAll and tests swap the
|
||||
// override in and out (same pattern as AuthController.test.ts).
|
||||
type SignupValidateOverride = (data: Record<string, unknown>) => void;
|
||||
let signupValidateOverride: SignupValidateOverride | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer({
|
||||
origin: TEST_ORIGIN,
|
||||
@@ -75,6 +81,14 @@ beforeAll(async () => {
|
||||
} as never);
|
||||
router = new PuterRouter();
|
||||
server.controllers.oidc.registerRoutes(router);
|
||||
server.clients.event.on(
|
||||
'puter.signup.validate',
|
||||
(_k: unknown, data: unknown) => {
|
||||
if (signupValidateOverride) {
|
||||
signupValidateOverride(data as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -83,6 +97,7 @@ afterAll(async () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
signupValidateOverride = null;
|
||||
});
|
||||
|
||||
interface CapturedResponse {
|
||||
@@ -92,6 +107,7 @@ interface CapturedResponse {
|
||||
redirectUrl?: string;
|
||||
headers: Record<string, string>;
|
||||
cookies: Array<{ name: string; value: string; opts?: unknown }>;
|
||||
clearedCookies: Array<{ name: string; opts?: unknown }>;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
@@ -100,6 +116,7 @@ const makeReq = (init: {
|
||||
query?: Record<string, unknown>;
|
||||
params?: Record<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
cookies?: Record<string, string>;
|
||||
method?: string;
|
||||
}): Request => {
|
||||
return {
|
||||
@@ -107,6 +124,7 @@ const makeReq = (init: {
|
||||
query: init.query ?? {},
|
||||
params: init.params ?? {},
|
||||
headers: init.headers ?? {},
|
||||
cookies: init.cookies ?? {},
|
||||
method: init.method ?? 'GET',
|
||||
} as unknown as Request;
|
||||
};
|
||||
@@ -117,6 +135,7 @@ const makeRes = () => {
|
||||
body: undefined,
|
||||
headers: {},
|
||||
cookies: [],
|
||||
clearedCookies: [],
|
||||
};
|
||||
const res = {
|
||||
json: vi.fn((value: unknown) => {
|
||||
@@ -155,6 +174,10 @@ const makeRes = () => {
|
||||
captured.cookies.push({ name, value, opts });
|
||||
return res;
|
||||
}),
|
||||
clearCookie: vi.fn((name: string, opts?: unknown) => {
|
||||
captured.clearedCookies.push({ name, opts });
|
||||
return res;
|
||||
}),
|
||||
type: vi.fn(() => res),
|
||||
};
|
||||
return { res: res as unknown as Response, captured };
|
||||
@@ -593,6 +616,7 @@ describe('OIDCController login callback', () => {
|
||||
);
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.redirectUrl).toContain('message=account_suspended');
|
||||
// No session cookie issued for suspended accounts.
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
});
|
||||
@@ -674,9 +698,7 @@ describe('OIDCController login callback', () => {
|
||||
expect(captured.redirectUrl).toContain('embedded_in_popup=true');
|
||||
expect(captured.redirectUrl).toContain('msg_id=msg-99');
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
// 'This account is suspended.' is not in ALLOWED_ERRORS, so the
|
||||
// builder falls back to 'unauthorized'.
|
||||
expect(captured.redirectUrl).toContain('message=unauthorized');
|
||||
expect(captured.redirectUrl).toContain('message=account_suspended');
|
||||
expect(captured.redirectUrl).toContain(
|
||||
`opener_origin=${encodeURIComponent('http://opener.test')}`,
|
||||
);
|
||||
@@ -767,6 +789,291 @@ describe('OIDCController login callback', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// -- Browser binding / login-CSRF --------------------------------------
|
||||
|
||||
describe('OIDCController signup veto (abuse harness)', () => {
|
||||
const vetoWithTrail = (email: string, trailId: string) => {
|
||||
signupValidateOverride = (data) => {
|
||||
if (data.email !== email) return;
|
||||
data.allow = false;
|
||||
data.trail_id = trailId;
|
||||
};
|
||||
};
|
||||
|
||||
const stubIdp = (sub: string, email: string) => {
|
||||
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
|
||||
access_token: 'access',
|
||||
id_token: 'id',
|
||||
} as never);
|
||||
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
} as never);
|
||||
};
|
||||
|
||||
it('createUserFromOIDC returns signup_blocked and the trail id as requestCode', async () => {
|
||||
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
vetoWithTrail(email, 'trail-svc-1');
|
||||
|
||||
const created = await runWithContext(
|
||||
{ req: makeReq({}) },
|
||||
() =>
|
||||
oidc().createUserFromOIDC('custom', {
|
||||
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
|
||||
email,
|
||||
email_verified: true,
|
||||
}),
|
||||
);
|
||||
expect(created.success).toBe(false);
|
||||
expect(created.code).toBe('signup_blocked');
|
||||
expect(created.requestCode).toBe('trail-svc-1');
|
||||
});
|
||||
|
||||
it('login callback redirects with signup_blocked + request_code when first sign-in is vetoed', async () => {
|
||||
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
vetoWithTrail(email, 'trail-login-1');
|
||||
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
|
||||
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.redirectUrl).toContain('action=login');
|
||||
expect(captured.redirectUrl).toContain('message=signup_blocked');
|
||||
expect(captured.redirectUrl).toContain('request_code=trail-login-1');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('signup callback redirects with signup_blocked + request_code when vetoed', async () => {
|
||||
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
vetoWithTrail(email, 'trail-signup-1');
|
||||
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
|
||||
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/signup',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.redirectUrl).toContain('action=signup');
|
||||
expect(captured.redirectUrl).toContain('message=signup_blocked');
|
||||
expect(captured.redirectUrl).toContain('request_code=trail-signup-1');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('a veto with no trail id still redirects with signup_blocked and no request_code', async () => {
|
||||
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
signupValidateOverride = (data) => {
|
||||
if (data.email !== email) return;
|
||||
data.allow = false;
|
||||
};
|
||||
stubIdp(`sub-${Math.random().toString(36).slice(2, 8)}`, email);
|
||||
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/signup',
|
||||
makeReq({ query: { code: 'c', state } }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectUrl).toContain('message=signup_blocked');
|
||||
expect(captured.redirectUrl).not.toContain('request_code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDCController browser binding', () => {
|
||||
const NONCE_COOKIE = 'puter_oidc_nonce';
|
||||
|
||||
const stubIdP = (sub: string, email: string) => {
|
||||
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
|
||||
access_token: 'access',
|
||||
id_token: 'id',
|
||||
} as never);
|
||||
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
|
||||
sub,
|
||||
email,
|
||||
email_verified: true,
|
||||
} as never);
|
||||
};
|
||||
|
||||
it('/start sets an HttpOnly nonce cookie matching the nonce embedded in state', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/:provider/start',
|
||||
makeReq({ params: { provider: 'custom' } }),
|
||||
res,
|
||||
);
|
||||
|
||||
const nonceCookie = captured.cookies.find(
|
||||
(c) => c.name === NONCE_COOKIE,
|
||||
);
|
||||
expect(nonceCookie).toBeTruthy();
|
||||
expect(nonceCookie?.value).toBeTruthy();
|
||||
expect((nonceCookie?.opts as { httpOnly?: boolean })?.httpOnly).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// The cookie value must equal the nonce baked into the signed state.
|
||||
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
|
||||
'state',
|
||||
);
|
||||
const decoded = oidc().verifyState(state!);
|
||||
expect(decoded?.nonce).toBe(nonceCookie?.value);
|
||||
});
|
||||
|
||||
it('completes login when the nonce cookie matches the state nonce', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `bind-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
const nonce = 'browser-nonce-match';
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
nonce,
|
||||
});
|
||||
stubIdP(sub, email);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({
|
||||
query: { code: 'c', state },
|
||||
cookies: { [NONCE_COOKIE]: nonce },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
// Session cookie issued; single-use nonce cookie cleared.
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
|
||||
expect(
|
||||
captured.clearedCookies.some((c) => c.name === NONCE_COOKIE),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects login (no session cookie) when the nonce cookie is absent — the login-CSRF case', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
nonce: 'attacker-flow-nonce',
|
||||
});
|
||||
// If enforcement were missing, this would resolve a user and set a
|
||||
// session cookie for the victim's browser. It must not get that far.
|
||||
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
// Victim's browser has no nonce cookie for the attacker's flow.
|
||||
makeReq({ query: { code: 'c', state }, cookies: {} }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectStatus).toBe(302);
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
// We bail before ever exchanging the code.
|
||||
expect(exchangeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects login when the nonce cookie does not match the state nonce', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
nonce: 'expected-nonce',
|
||||
});
|
||||
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({
|
||||
query: { code: 'c', state },
|
||||
cookies: { [NONCE_COOKIE]: 'a-different-nonce' },
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.redirectUrl).toContain('auth_error=1');
|
||||
expect(captured.cookies).toHaveLength(0);
|
||||
expect(exchangeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects the revalidate callback (400) when the nonce cookie is missing', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
flow: 'revalidate',
|
||||
user_uuid: uuidv4(),
|
||||
nonce: 'reval-nonce',
|
||||
});
|
||||
const exchangeSpy = vi.spyOn(oidc(), 'exchangeCodeForTokens');
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/revalidate',
|
||||
makeReq({ query: { code: 'c', state }, cookies: {} }),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(captured.statusCode).toBe(400);
|
||||
expect(exchangeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets legacy nonce-less state through (deploy grace) without touching the nonce cookie', async () => {
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const email = `legacy-${Math.random().toString(36).slice(2, 8)}@test.local`;
|
||||
// No `nonce` field — mimics a state signed before this shipped.
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
});
|
||||
stubIdP(sub, email);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/auth/oidc/callback/login',
|
||||
makeReq({ query: { code: 'c', state }, cookies: {} }),
|
||||
res,
|
||||
);
|
||||
|
||||
// Proceeds as before; no nonce cookie is cleared for legacy states.
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
|
||||
expect(
|
||||
captured.clearedCookies.some((c) => c.name === NONCE_COOKIE),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /auth/oidc/callback/signup ──────────────────────────────────────
|
||||
|
||||
describe('OIDCController signup callback', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
@@ -26,45 +27,63 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
|
||||
const REVALIDATION_EXPIRY_SEC = 300;
|
||||
|
||||
// Companion cookie that binds an OIDC flow to the browser that started it.
|
||||
// Expiry mirrors STATE_EXPIRY_SEC in OIDCService — the state and its
|
||||
// browser-binding cookie must expire together.
|
||||
const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce';
|
||||
const OIDC_NONCE_EXPIRY_SEC = 600;
|
||||
|
||||
const OIDC_ERROR_REDIRECT_MAP: Record<string, Record<string, string>> = {
|
||||
login: { account_not_found: 'signup', other: 'login' },
|
||||
signup: { account_already_exists: 'login', other: 'signup' },
|
||||
};
|
||||
|
||||
const ALLOWED_ERRORS = ['account_suspended', 'unauthorized'] as const;
|
||||
// The `message` query param is clamped to these codes — the GUI maps them to
|
||||
// display text. Free-text errors (which may describe internals or, for vetoed
|
||||
// signups, the block reason) never reach the redirect URL.
|
||||
const ALLOWED_ERRORS = [
|
||||
'account_suspended',
|
||||
'unauthorized',
|
||||
'signup_blocked',
|
||||
] as const;
|
||||
|
||||
function buildErrorRedirectUrl(
|
||||
origin: string,
|
||||
sourceFlow: string,
|
||||
errorCondition: string,
|
||||
message: (typeof ALLOWED_ERRORS)[number],
|
||||
message: string,
|
||||
stateDecoded?: Record<string, unknown>,
|
||||
requestCode?: string,
|
||||
): string {
|
||||
const targetFlow =
|
||||
OIDC_ERROR_REDIRECT_MAP[sourceFlow]?.[errorCondition] ?? sourceFlow;
|
||||
const base = origin.replace(/\/$/, '') || '/';
|
||||
const clamped = (ALLOWED_ERRORS as readonly string[]).includes(message)
|
||||
? message
|
||||
: 'unauthorized';
|
||||
|
||||
let params: URLSearchParams;
|
||||
if (stateDecoded?.embedded_in_popup && stateDecoded?.msg_id != null) {
|
||||
const params = new URLSearchParams({
|
||||
params = new URLSearchParams({
|
||||
embedded_in_popup: 'true',
|
||||
msg_id: String(stateDecoded.msg_id),
|
||||
auth_error: '1',
|
||||
message: ALLOWED_ERRORS.includes(message)
|
||||
? message
|
||||
: 'unauthorized',
|
||||
message: clamped,
|
||||
action: targetFlow,
|
||||
});
|
||||
if (stateDecoded?.opener_origin) {
|
||||
params.set('opener_origin', String(stateDecoded.opener_origin));
|
||||
}
|
||||
return `${base}/?${params.toString()}`;
|
||||
} else {
|
||||
params = new URLSearchParams({
|
||||
action: targetFlow,
|
||||
auth_error: '1',
|
||||
message: clamped,
|
||||
});
|
||||
}
|
||||
if (requestCode) {
|
||||
params.set('request_code', requestCode);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
action: targetFlow,
|
||||
auth_error: '1',
|
||||
message: ALLOWED_ERRORS.includes(message) ? message : 'unauthorized',
|
||||
});
|
||||
return `${base}/?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -73,6 +92,14 @@ function appendQueryParam(url: string, key: string, value: string): string {
|
||||
return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
}
|
||||
|
||||
/** Length-safe constant-time string compare (never throws on mismatch). */
|
||||
function constantTimeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ba.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff `target` parses as a URL whose origin equals `origin`. Used to
|
||||
* clamp OIDC redirect targets — `startsWith` would accept
|
||||
@@ -139,6 +166,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]
|
||||
@@ -198,6 +238,17 @@ export class OIDCController extends PuterController {
|
||||
statePayload.flow = 'revalidate';
|
||||
}
|
||||
|
||||
// Bind this flow to the initiating browser: a single-use
|
||||
// nonce lives both in the signed `state` and in an HttpOnly
|
||||
// companion cookie. The callback requires them to match, so a
|
||||
// `state` captured from an attacker's own flow can't be
|
||||
// replayed in a victim's browser (login-CSRF / session
|
||||
// fixation).
|
||||
const browserNonce = crypto
|
||||
.randomBytes(32)
|
||||
.toString('base64url');
|
||||
statePayload.nonce = browserNonce;
|
||||
|
||||
const state = this.services.oidc.signState(statePayload);
|
||||
const url = await this.services.oidc.getAuthorizationUrl(
|
||||
provider,
|
||||
@@ -211,6 +262,15 @@ export class OIDCController extends PuterController {
|
||||
{ legacyCode: 'internal_error' },
|
||||
);
|
||||
|
||||
res.cookie(OIDC_NONCE_COOKIE_NAME, browserNonce, {
|
||||
// Same flags as the session cookie: SameSite=None;Secure
|
||||
// on HTTPS so the cookie survives Apple's cross-site
|
||||
// form_post callback; Lax on plain-HTTP self-host.
|
||||
...sessionCookieFlags(this.config),
|
||||
httpOnly: true,
|
||||
maxAge: OIDC_NONCE_EXPIRY_SEC * 1000,
|
||||
path: '/',
|
||||
});
|
||||
res.redirect(302, url);
|
||||
},
|
||||
);
|
||||
@@ -224,7 +284,7 @@ export class OIDCController extends PuterController {
|
||||
|
||||
const loginCb = async (req: Request, res: Response) => {
|
||||
const origin = this.config.origin ?? '';
|
||||
const result = await this.#processCallback(req, 'login');
|
||||
const result = await this.#processCallback(req, res, 'login');
|
||||
if ('error' in result) {
|
||||
console.warn(`OIDC login callback error: ${result.error}`);
|
||||
return res.redirect(
|
||||
@@ -255,8 +315,9 @@ export class OIDCController extends PuterController {
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
resolved.error,
|
||||
resolved.code ?? 'unauthorized',
|
||||
stateDecoded,
|
||||
resolved.requestCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -272,7 +333,7 @@ export class OIDCController extends PuterController {
|
||||
origin,
|
||||
'login',
|
||||
'other',
|
||||
'This account is suspended.',
|
||||
'account_suspended',
|
||||
stateDecoded,
|
||||
),
|
||||
);
|
||||
@@ -287,7 +348,7 @@ export class OIDCController extends PuterController {
|
||||
|
||||
const signupCb = async (req: Request, res: Response) => {
|
||||
const origin = this.config.origin ?? '';
|
||||
const result = await this.#processCallback(req, 'signup');
|
||||
const result = await this.#processCallback(req, res, 'signup');
|
||||
if ('error' in result) {
|
||||
return res.redirect(
|
||||
302,
|
||||
@@ -308,14 +369,18 @@ export class OIDCController extends PuterController {
|
||||
(stateDecoded.referrer as string) ?? null,
|
||||
);
|
||||
if ('error' in resolved) {
|
||||
console.warn(
|
||||
`OIDC signup user resolution error: ${resolved.error}`,
|
||||
);
|
||||
return res.redirect(
|
||||
302,
|
||||
buildErrorRedirectUrl(
|
||||
origin,
|
||||
'signup',
|
||||
'other',
|
||||
'unauthorized',
|
||||
resolved.code ?? 'unauthorized',
|
||||
stateDecoded,
|
||||
resolved.requestCode,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -352,7 +417,7 @@ export class OIDCController extends PuterController {
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const result = await this.#processCallback(req, 'revalidate');
|
||||
const result = await this.#processCallback(req, res, 'revalidate');
|
||||
if ('error' in result) {
|
||||
res.status(400).send(result.error);
|
||||
return;
|
||||
@@ -451,7 +516,7 @@ if (window.opener) {
|
||||
userinfo: { sub: string; email?: unknown; [k: string]: unknown },
|
||||
referrer?: string | null,
|
||||
): Promise<
|
||||
| { error: string }
|
||||
| { error: string; code?: string; requestCode?: string }
|
||||
| {
|
||||
user: import('../../stores/user/UserStore.js').UserRow;
|
||||
origin: 'linked-sub' | 'linked-email' | 'created';
|
||||
@@ -497,13 +562,18 @@ if (window.opener) {
|
||||
referrer,
|
||||
);
|
||||
if (!outcome.success || !outcome.user) {
|
||||
return { error: outcome.error ?? 'Account creation failed.' };
|
||||
return {
|
||||
error: outcome.error ?? 'Account creation failed.',
|
||||
code: outcome.code,
|
||||
requestCode: outcome.requestCode,
|
||||
};
|
||||
}
|
||||
return { user: outcome.user, origin: 'created' };
|
||||
}
|
||||
|
||||
async #processCallback(
|
||||
req: Request,
|
||||
res: Response,
|
||||
flow: string,
|
||||
): Promise<
|
||||
| { error: string }
|
||||
@@ -527,6 +597,28 @@ if (window.opener) {
|
||||
if (!stateDecoded || !stateDecoded.provider)
|
||||
return { error: 'Invalid or expired state.' };
|
||||
|
||||
// Enforce the browser binding set at /start. Every state minted by
|
||||
// the current /start carries a nonce, so this covers all live flows.
|
||||
// States signed before this shipped have no nonce and pass through
|
||||
// until they expire (STATE_EXPIRY, 10 min) so in-flight logins don't
|
||||
// break on deploy — a caller can't forge a nonce-less state because
|
||||
// /start always adds one and the state is server-signed.
|
||||
const expectedNonce =
|
||||
typeof stateDecoded.nonce === 'string' ? stateDecoded.nonce : '';
|
||||
if (expectedNonce) {
|
||||
const cookieNonce = req.cookies?.[OIDC_NONCE_COOKIE_NAME];
|
||||
// Single-use: drop the cookie regardless of the outcome.
|
||||
res.clearCookie(OIDC_NONCE_COOKIE_NAME, { path: '/' });
|
||||
if (
|
||||
typeof cookieNonce !== 'string' ||
|
||||
!constantTimeEqual(cookieNonce, expectedNonce)
|
||||
) {
|
||||
return {
|
||||
error: 'This sign-in could not be verified for your browser. Please start again.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const provider = String(stateDecoded.provider);
|
||||
const callbackUrl = this.services.oidc.getCallbackUrl(flow);
|
||||
if (!callbackUrl) return { error: 'Invalid flow.' };
|
||||
|
||||
@@ -44,7 +44,9 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import type { RouteOptions } from '../../core/http/index.js';
|
||||
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { PuterAIController } from './PuterAIController.js';
|
||||
@@ -192,7 +194,10 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
// call puter-chat-completion directly), but admit the user's own
|
||||
// full-access personal access token: `requireUserActor` keeps apps out
|
||||
// and `allowFullAccessToken` opens the gate to a full-access PAT. Each
|
||||
// proxy route carries both flags.
|
||||
// proxy route carries both flags, plus the shared per-tier AI
|
||||
// rate-limit / concurrency policy — these routes bypass the
|
||||
// `/drivers/call` dispatch (where the driver-declared limits are
|
||||
// enforced), so without the route gates they'd be unthrottled.
|
||||
const userOnlyPaths = [
|
||||
'/puterai/openai/v1/chat/completions',
|
||||
'/puterai/openai/v1/completions',
|
||||
@@ -205,6 +210,16 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: {
|
||||
...AI_RATE_LIMIT.default,
|
||||
scope: 'driver:puter-chat-completion:complete',
|
||||
key: expect.any(Function),
|
||||
},
|
||||
concurrent: {
|
||||
...AI_CONCURRENT.default,
|
||||
scope: 'driver:puter-chat-completion:complete',
|
||||
key: expect.any(Function),
|
||||
},
|
||||
});
|
||||
}
|
||||
const modelsRoute = calls.find(
|
||||
@@ -215,6 +230,46 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
requireAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keys the proxy-route AI limits by user uuid so they share the /drivers/call buckets', () => {
|
||||
const calls: Array<{ path: string; opts: RouteOptions }> = [];
|
||||
const router = {
|
||||
post: vi.fn((path: string, opts: RouteOptions) => {
|
||||
calls.push({ path, opts });
|
||||
return router;
|
||||
}),
|
||||
get: vi.fn(() => router),
|
||||
};
|
||||
|
||||
controller.registerRoutes(router as never);
|
||||
|
||||
const route = calls.find(
|
||||
(c) => c.path === '/puterai/openai/v1/chat/completions',
|
||||
);
|
||||
const rateLimit = route?.opts.rateLimit as {
|
||||
key: (req: Request) => string;
|
||||
scope: string;
|
||||
};
|
||||
const concurrent = route?.opts.concurrent as {
|
||||
key: (req: Request) => string;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
// The dispatch buckets requests as `driver:<iface>:<method>:<uid>`
|
||||
// where uid is the actor's user uuid; scope + key must compose to the
|
||||
// identical string or wire traffic mints a second per-user budget.
|
||||
const req = makeReq({ actor: makeUserActor() });
|
||||
expect(rateLimit.key(req)).toBe('u-7');
|
||||
expect(concurrent.key(req)).toBe('u-7');
|
||||
expect(rateLimit.scope).toBe('driver:puter-chat-completion:complete');
|
||||
expect(concurrent.scope).toBe('driver:puter-chat-completion:complete');
|
||||
|
||||
// No-actor fallback still yields a usable (fingerprint) key rather
|
||||
// than throwing or bucketing everyone together under undefined.
|
||||
const anonKey = rateLimit.key(makeReq({}));
|
||||
expect(typeof anonKey).toBe('string');
|
||||
expect(anonKey.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /openai/v1/chat/completions ─────────────────────────────────────
|
||||
@@ -1506,6 +1561,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', () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import crypto from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
import { computeNetworkFingerprint } from '../../core/http/middleware/rateLimit.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
import type {
|
||||
@@ -29,6 +30,7 @@ import type {
|
||||
ICompleteArguments,
|
||||
} from '../../drivers/ai-chat/types.js';
|
||||
import { isDriverStreamResult } from '../../drivers/meta.js';
|
||||
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../../drivers/util/aiLimits.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
const GEMINI_DOWNLOAD_BASE =
|
||||
@@ -49,6 +51,18 @@ const GEMINI_DOWNLOAD_BASE =
|
||||
*/
|
||||
export class PuterAIController extends PuterController {
|
||||
registerRoutes(router: PuterRouter): void {
|
||||
/**
|
||||
* The wire routes call the chat driver directly instead of going
|
||||
* through the `/drivers/call` dispatch, so the shared per-tier AI
|
||||
* rate-limit / concurrency policy must be declared as route gates
|
||||
* here. `scope` + `key` reproduce the dispatch's bucket key
|
||||
* (`driver:<iface>:<method>:<uid>`) exactly, so wire traffic and
|
||||
* `/drivers/call` traffic draw from one per-user budget rather than
|
||||
* each surface minting its own.
|
||||
*/
|
||||
const aiPolicyScope = 'driver:puter-chat-completion:complete';
|
||||
const aiPolicyKey = (req: Request): string =>
|
||||
req.actor?.user?.uuid || computeNetworkFingerprint(req);
|
||||
const apiAuthOpts = {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
@@ -58,6 +72,16 @@ export class PuterAIController extends PuterController {
|
||||
// personal access tokens (CLI/MCP/scripts). Apps and scoped tokens
|
||||
// stay blocked.
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: {
|
||||
...AI_RATE_LIMIT.default!,
|
||||
scope: aiPolicyScope,
|
||||
key: aiPolicyKey,
|
||||
},
|
||||
concurrent: {
|
||||
...AI_CONCURRENT.default!,
|
||||
scope: aiPolicyScope,
|
||||
key: aiPolicyKey,
|
||||
},
|
||||
} as RouteOptions;
|
||||
const publicOpts = { subdomain: 'api', requireAuth: false } as const;
|
||||
|
||||
@@ -587,6 +611,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 +783,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 +926,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 +1034,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 +1100,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 +1168,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 +1430,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 +1532,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;
|
||||
};
|
||||
|
||||
|
||||
@@ -37,17 +37,33 @@ export class SystemController extends PuterController {
|
||||
) {
|
||||
// -- Healthcheck ---------------------------------------------
|
||||
// Delegates to ServerHealthService for the real check-based
|
||||
// status. Returns `{ ok: true }` when all registered checks pass,
|
||||
// or `{ ok: false, failed: [...] }` + 503 when any fail or the
|
||||
// status. Returns `{ ok: true }` + 200 when all registered checks
|
||||
// pass, or `{ ok: false, failed: [...] }` + 503 when any fail or the
|
||||
// server is draining.
|
||||
router.get('/healthcheck', { subdomain: '*' }, async (_req, res) => {
|
||||
//
|
||||
// `?ignore=a,b` disregards the named checks for this request only.
|
||||
// `?marked-degraded=a,b` demotes the named checks to a non-fatal
|
||||
// `degraded` list: `ok` stays true but the response is 207 so the
|
||||
// caller can tell the node is running in a degraded state.
|
||||
const parseNames = (value) =>
|
||||
typeof value === 'string'
|
||||
? value
|
||||
.split(',')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
router.get('/healthcheck', { subdomain: '*' }, async (req, res) => {
|
||||
const health = this.services.health;
|
||||
if (!health || typeof health.getStatus !== 'function') {
|
||||
// Fallback for boot ordering / missing service.
|
||||
return res.send('ok');
|
||||
}
|
||||
const status = await health.getStatus();
|
||||
const status = await health.getStatus({
|
||||
ignore: parseNames(req.query.ignore),
|
||||
degrade: parseNames(req.query['marked-degraded']),
|
||||
});
|
||||
if (!status.ok) return res.status(503).json(status);
|
||||
if (status.degraded?.length) return res.status(207).json(status);
|
||||
return res.json(status);
|
||||
});
|
||||
|
||||
@@ -137,6 +153,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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { kv } from '../../util/kvSingleton.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
@@ -79,10 +80,11 @@ interface CapturedResponse {
|
||||
const makeReq = (init: {
|
||||
body?: unknown;
|
||||
actor?: Actor;
|
||||
query?: Record<string, unknown>;
|
||||
}): Request => {
|
||||
return {
|
||||
body: init.body ?? {},
|
||||
query: {},
|
||||
query: init.query ?? {},
|
||||
headers: {},
|
||||
actor: init.actor,
|
||||
} as unknown as Request;
|
||||
@@ -139,6 +141,191 @@ describe('SystemController GET /healthcheck', () => {
|
||||
expect(captured.body).toMatchObject({ ok: true });
|
||||
expect(captured.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('parses ?ignore and ?marked-degraded into trimmed name lists', async () => {
|
||||
const spy = vi
|
||||
.spyOn(server.services.health, 'getStatus')
|
||||
.mockResolvedValue({ ok: true });
|
||||
try {
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/healthcheck',
|
||||
makeReq({
|
||||
query: {
|
||||
ignore: 'database-liveness, thumbnailer',
|
||||
'marked-degraded': ' socket-initialized ',
|
||||
},
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
ignore: ['database-liveness', 'thumbnailer'],
|
||||
degrade: ['socket-initialized'],
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns ok:true + 200 when the only failures are ignored', async () => {
|
||||
const spy = vi
|
||||
.spyOn(server.services.health, 'getStatus')
|
||||
.mockImplementation(async ({ ignore = [] } = {}) => {
|
||||
const failed = ['database-liveness'].filter(
|
||||
(name) => !ignore.includes(name),
|
||||
);
|
||||
return failed.length === 0
|
||||
? { ok: true }
|
||||
: { ok: false, failed };
|
||||
});
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/healthcheck',
|
||||
makeReq({ query: { ignore: 'database-liveness' } }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({ ok: true });
|
||||
expect(captured.statusCode).toBe(200);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns ok:true + 207 when the only failures are marked degraded', async () => {
|
||||
const spy = vi
|
||||
.spyOn(server.services.health, 'getStatus')
|
||||
.mockResolvedValue({ ok: true, degraded: ['database-liveness'] });
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/healthcheck',
|
||||
makeReq({ query: { 'marked-degraded': 'database-liveness' } }),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({
|
||||
ok: true,
|
||||
degraded: ['database-liveness'],
|
||||
});
|
||||
expect(captured.statusCode).toBe(207);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('still 503s when a non-ignored failure remains', async () => {
|
||||
const spy = vi
|
||||
.spyOn(server.services.health, 'getStatus')
|
||||
.mockImplementation(async ({ ignore = [] } = {}) => {
|
||||
const failed = ['database-liveness', 'socket-initialized'].filter(
|
||||
(name) => !ignore.includes(name),
|
||||
);
|
||||
return failed.length === 0
|
||||
? { ok: true }
|
||||
: { ok: false, failed };
|
||||
});
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'get',
|
||||
'/healthcheck',
|
||||
makeReq({ query: { ignore: 'database-liveness' } }),
|
||||
res,
|
||||
);
|
||||
expect(captured.statusCode).toBe(503);
|
||||
expect(captured.body).toEqual({
|
||||
ok: false,
|
||||
failed: ['socket-initialized'],
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── ServerHealthService.getStatus ignore / degrade filtering ────────
|
||||
//
|
||||
// Exercises the real service by seeding the in-process status cache
|
||||
// (the kv.js singleton) it reads from, so the actual per-request
|
||||
// classification runs — not a stubbed getStatus.
|
||||
|
||||
describe('ServerHealthService.getStatus ignore/degrade filtering', () => {
|
||||
const STATUS_CACHE_KEY = 'server-health:status';
|
||||
|
||||
const seedStatus = (status: unknown) => {
|
||||
kv.set(STATUS_CACHE_KEY, status, { EX: 5 });
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
kv.del(STATUS_CACHE_KEY);
|
||||
});
|
||||
|
||||
it('collapses to ok:true when every failure is ignored', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness', 'thumbnailer'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['database-liveness', 'thumbnailer'],
|
||||
});
|
||||
expect(status).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('keeps the non-ignored failures', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness', 'thumbnailer'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({ ok: false, failed: ['thumbnailer'] });
|
||||
});
|
||||
|
||||
it('is a no-op for a healthy status', async () => {
|
||||
seedStatus({ ok: true });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('ignores unknown names without affecting real failures', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['not-a-check'],
|
||||
});
|
||||
expect(status).toEqual({ ok: false, failed: ['database-liveness'] });
|
||||
});
|
||||
|
||||
it('demotes marked failures to degraded and stays ok:true', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
degrade: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({ ok: true, degraded: ['database-liveness'] });
|
||||
});
|
||||
|
||||
it('reports degraded alongside remaining hard failures (ok:false)', async () => {
|
||||
seedStatus({
|
||||
ok: false,
|
||||
failed: ['database-liveness', 'socket-initialized'],
|
||||
});
|
||||
const status = await server.services.health.getStatus({
|
||||
degrade: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({
|
||||
ok: false,
|
||||
failed: ['socket-initialized'],
|
||||
degraded: ['database-liveness'],
|
||||
});
|
||||
});
|
||||
|
||||
it('lets ignore take precedence over degrade for the same name', async () => {
|
||||
seedStatus({ ok: false, failed: ['database-liveness'] });
|
||||
const status = await server.services.health.getStatus({
|
||||
ignore: ['database-liveness'],
|
||||
degrade: ['database-liveness'],
|
||||
});
|
||||
expect(status).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── /version ────────────────────────────────────────────────────────
|
||||
@@ -233,6 +420,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,169 @@ 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('suspension gate', () => {
|
||||
// WebDAV must also enforce the suspension gate every other authenticated
|
||||
// route gets from requireAuthGate. Same single-use() dispatch means that
|
||||
// middleware is never wired in, so #dispatch calls assertNotSuspended
|
||||
// itself. Without it, a suspended account could read/write/delete its
|
||||
// whole filesystem over the `dav` subdomain.
|
||||
it('rejects a suspended session actor with 403', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
actor: {
|
||||
user: {
|
||||
id: 1,
|
||||
uuid: 'suspended-uuid',
|
||||
username: 'suspended',
|
||||
suspended: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a suspended user on the Basic-auth path with 403', async () => {
|
||||
const username = `webdav-suspended-${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, {
|
||||
suspended: 1,
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
authorization: basicAuth(username, 'correct-horse'),
|
||||
},
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported methods', () => {
|
||||
it('returns 405 for unknown HTTP methods', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -23,6 +23,10 @@ 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 {
|
||||
assertNotSuspended,
|
||||
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 +93,17 @@ export class WebDAVController extends PuterController {
|
||||
const actor = await this.#resolveActor(req, res);
|
||||
if (!actor) return; // 401 already sent
|
||||
|
||||
// Apply the same suspension + pending-verification gates every other
|
||||
// authenticated route gets from `requireAuthGate` / `requireVerifiedAccount`.
|
||||
// WebDAV dispatches every method off a single `router.use` with no route
|
||||
// options, so that middleware is never inserted into its chain — without
|
||||
// these calls a suspended account (or one still pending email / phone /
|
||||
// card verification) could read, write, and delete its entire filesystem
|
||||
// over the `dav` subdomain, bypassing the gates. Both throw a 403
|
||||
// HttpError, surfaced by the catch in registerRoutes.
|
||||
assertNotSuspended(actor.user);
|
||||
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 +248,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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,9 +54,11 @@ export interface Actor {
|
||||
* Session reference when authenticated via a session token (user actors)
|
||||
* or an app-under-user token that carries a session. Absent for system,
|
||||
* raw-app, and pure access-token actors. Used for session introspection
|
||||
* and targeted logout.
|
||||
* and targeted logout. `kind` mirrors the session row's kind (e.g.
|
||||
* 'web', 'app', 'worker') so callers can gate on how the credential
|
||||
* was minted without an extra session lookup.
|
||||
*/
|
||||
session?: { uid: string } | null;
|
||||
session?: { uid: string; kind?: string | null } | null;
|
||||
}
|
||||
|
||||
/** UUID of the baked-in system user (see 0025 seed migration). */
|
||||
|
||||
@@ -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,78 @@
|
||||
/**
|
||||
* 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
|
||||
* anchor of the rate limiter's default key). 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. The rate
|
||||
* limiter's 'fingerprint' strategy appends it to the network hash so each
|
||||
* device behind a shared network gets its own bucket.
|
||||
*
|
||||
* 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,
|
||||
@@ -407,6 +407,113 @@ describe('adminOnlyGate', () => {
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
// -- Root-token requirement --
|
||||
//
|
||||
// Admin endpoints require a root token (an actor with no app anywhere
|
||||
// in its token chain), so a third-party app an admin authorized can't
|
||||
// reach them on the admin's behalf.
|
||||
|
||||
it('admits an admin acting via a session (root token)', () => {
|
||||
const got = runGate(adminOnlyGate(), {
|
||||
actor: { user: { uuid: 'u-1', username: 'admin' } },
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it("admits an admin's full-access PAT (still a root token — no app)", () => {
|
||||
const got = runGate(adminOnlyGate(), {
|
||||
actor: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
accessToken: {
|
||||
uid: 'tok-1',
|
||||
issuer: { user: { uuid: 'u-1', username: 'admin' } },
|
||||
fullAccess: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects an admin acting through an app with 403 (not a root token)', () => {
|
||||
const got = runGate(adminOnlyGate(), {
|
||||
actor: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
app: { uid: 'app-1' },
|
||||
},
|
||||
});
|
||||
expectHttpError(got, 403, 'forbidden');
|
||||
});
|
||||
|
||||
it('rejects an admin access token issued through an app (app in the token chain)', () => {
|
||||
// Access-token actors carry their app on `accessToken.issuer.app`,
|
||||
// not top-level `actor.app` — the root-token check must walk the
|
||||
// chain, not just the top level.
|
||||
const got = runGate(adminOnlyGate(), {
|
||||
actor: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
accessToken: {
|
||||
uid: 'tok-1',
|
||||
issuer: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
app: { uid: 'app-1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expectHttpError(got, 403, 'forbidden');
|
||||
});
|
||||
|
||||
it('rejects an app-issued access token even when appGated', () => {
|
||||
// The appGated deferral only applies to direct app-under-user
|
||||
// actors: `allowedAppIdsGate` reads top-level `actor.app` and would
|
||||
// pass a chain-only app straight through, so it must not be
|
||||
// deferred to.
|
||||
const got = runGate(adminOnlyGate([], { appGated: true }), {
|
||||
actor: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
accessToken: {
|
||||
uid: 'tok-1',
|
||||
issuer: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
app: { uid: 'app-1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expectHttpError(got, 403, 'forbidden');
|
||||
});
|
||||
|
||||
it('admits an admin acting through an app when appGated (allowedAppIdsGate then decides)', () => {
|
||||
// On an appId-gated route the root-token check is deferred to
|
||||
// `allowedAppIdsGate`; this gate must let the app actor through.
|
||||
const got = runGate(adminOnlyGate([], { appGated: true }), {
|
||||
actor: {
|
||||
user: { uuid: 'u-1', username: 'admin' },
|
||||
app: { uid: 'app-1' },
|
||||
},
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still admits a root token when appGated', () => {
|
||||
const got = runGate(adminOnlyGate([], { appGated: true }), {
|
||||
actor: { user: { uuid: 'u-1', username: 'admin' } },
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies the username check before the root-token check', () => {
|
||||
// A non-admin acting through an app is rejected for being non-admin,
|
||||
// regardless of the app scope.
|
||||
const got = runGate(adminOnlyGate(), {
|
||||
actor: {
|
||||
user: { uuid: 'u-1', username: 'random-user' },
|
||||
app: { uid: 'app-1' },
|
||||
},
|
||||
});
|
||||
expectHttpError(got, 403, 'forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireVerifiedGate ─────────────────────────────────────────────
|
||||
@@ -440,11 +547,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 +564,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 +577,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 +589,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { effectiveActorApp } from '../../actor';
|
||||
import { HttpError } from '../HttpError';
|
||||
import { assertVerifiedEmail } from '../verifiedEmail';
|
||||
|
||||
@@ -79,16 +80,24 @@ 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;
|
||||
}
|
||||
if (req.actor.user.suspended) {
|
||||
next(
|
||||
new HttpError(403, 'Account suspended', {
|
||||
legacyCode: 'forbidden',
|
||||
}),
|
||||
);
|
||||
try {
|
||||
assertNotSuspended(req.actor.user);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
@@ -174,13 +183,24 @@ export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const;
|
||||
* the supplied extras. Extras are *additional* allowed users on top of the
|
||||
* built-in pair, not a replacement for it.
|
||||
*
|
||||
* Implies `requireAuth`. Does *not* imply `requireUserActor` — admin
|
||||
* endpoints are callable via an admin's access token or app-under-user
|
||||
* actor; combine with `requireUserActor` explicitly if a route must be
|
||||
* restricted to browser sessions.
|
||||
* Also requires a *root token* — an actor with no app anywhere in its token
|
||||
* chain (see `effectiveActorApp`) — so a third-party app an admin has
|
||||
* authorized can't reach admin endpoints on the admin's behalf. The one
|
||||
* exception is `appGated`: on a route that is also appId-gated
|
||||
* (`allowedAppIds`), a direct app-under-user actor is deferred to
|
||||
* `allowedAppIdsGate`, so the net effect there is "a root token OR a token
|
||||
* scoped to an allowed app". Access tokens issued through an app are
|
||||
* rejected even then — `allowedAppIdsGate` only sees top-level `actor.app`
|
||||
* and would otherwise wave them through.
|
||||
*
|
||||
* Implies `requireAuth`. Does *not* imply `requireUserActor` — a root token
|
||||
* still includes an admin's full-access personal access token, not only
|
||||
* browser sessions; combine with `requireUserActor` explicitly if a route
|
||||
* must be restricted to browser sessions.
|
||||
*/
|
||||
export const adminOnlyGate = (
|
||||
extras: readonly string[] = [],
|
||||
opts: { appGated?: boolean } = {},
|
||||
): RequestHandler => {
|
||||
// Match the case-insensitivity guarantee of the username column
|
||||
// (MySQL: ascii_general_ci; SQLite: idx_user_username_nocase). Comparing
|
||||
@@ -199,6 +219,20 @@ export const adminOnlyGate = (
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Root-token requirement: reject actors carrying an app anywhere in
|
||||
// their token chain — app-under-user, or an access token issued
|
||||
// through an app. A direct app-under-user actor is deferred to
|
||||
// `allowedAppIdsGate` when the route is appId-gated; chain-only apps
|
||||
// are rejected even then, since that gate can't see them.
|
||||
const chainApp = req.actor ? effectiveActorApp(req.actor) : null;
|
||||
if (chainApp && !(opts.appGated && req.actor?.app?.uid)) {
|
||||
next(
|
||||
new HttpError(403, 'Only admins may request this resource', {
|
||||
legacyCode: 'forbidden',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
@@ -224,29 +258,94 @@ 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const assertNotSuspended = (
|
||||
user: { suspended?: unknown } | undefined,
|
||||
): void => {
|
||||
if (user?.suspended) {
|
||||
throw new HttpError(403, 'Account suspended', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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 { Readable } from 'node:stream';
|
||||
import type { puterClients } from '../../../clients';
|
||||
import type { puterServices } from '../../../services';
|
||||
import type { puterStores } from '../../../stores';
|
||||
import type { IConfig, LayerInstances } from '../../../types';
|
||||
|
||||
interface Layers {
|
||||
clients: LayerInstances<typeof puterClients>;
|
||||
stores: LayerInstances<typeof puterStores>;
|
||||
services: LayerInstances<typeof puterServices>;
|
||||
}
|
||||
|
||||
// Local analogue of the production `<name>.puter.work` worker domain. Requests
|
||||
// to `<name>.workers.puter.localhost` are dispatched into a Miniflare instance
|
||||
// by `LocalWorkerService`, which mirrors the real Cloudflare dispatch path.
|
||||
const WORKER_HOST_SUFFIX = 'workers.puter.localhost';
|
||||
|
||||
// Minimal WHATWG-Response shape we consume from Miniflare's `dispatchFetch`.
|
||||
// It isn't the Node global `Response`, so we type it structurally rather than
|
||||
// importing Miniflare's classes into the HTTP layer.
|
||||
interface FetchResponse {
|
||||
status: number;
|
||||
headers: { forEach(cb: (value: string, key: string) => void): void };
|
||||
body: ReadableStream<Uint8Array> | null;
|
||||
}
|
||||
|
||||
function normalizeHost(value: string | undefined | null): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim().toLowerCase().replace(/^\./, '');
|
||||
if (!trimmed) return null;
|
||||
return trimmed.split(':')[0] || null;
|
||||
}
|
||||
|
||||
// `<name>.workers.puter.localhost` → `name`. Returns null for the bare zone or
|
||||
// any host outside it. Flip this if your dev DNS puts the name elsewhere.
|
||||
function workerNameFromHost(host: string): string | null {
|
||||
if (host === WORKER_HOST_SUFFIX) return null;
|
||||
if (!host.endsWith(`.${WORKER_HOST_SUFFIX}`)) return null;
|
||||
const prefix = host.slice(0, host.length - WORKER_HOST_SUFFIX.length - 1);
|
||||
return prefix.split('.')[0] || null;
|
||||
}
|
||||
|
||||
// Express (Node) request → WHATWG Request the Worker's `fetch(request)` sees.
|
||||
// Must run BEFORE any body-parsing middleware so `req` is still an unconsumed
|
||||
// stream; otherwise the Worker gets an empty body on POST/PUT.
|
||||
function toFetchRequest(req: Parameters<RequestHandler>[0]): Request {
|
||||
const url = `http://${req.headers.host ?? WORKER_HOST_SUFFIX}${req.originalUrl}`;
|
||||
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) headers.append(key, v);
|
||||
} else if (value != null) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const method = (req.method ?? 'GET').toUpperCase();
|
||||
const hasBody = method !== 'GET' && method !== 'HEAD';
|
||||
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers,
|
||||
// `duplex: 'half'` is required by undici whenever a stream body is set.
|
||||
body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined,
|
||||
...(hasBody ? { duplex: 'half' } : {}),
|
||||
} as RequestInit);
|
||||
}
|
||||
|
||||
// WHATWG Response from the Worker → Express response.
|
||||
function sendFetchResponse(
|
||||
res: Parameters<RequestHandler>[1],
|
||||
response: FetchResponse,
|
||||
): void {
|
||||
res.status(response.status);
|
||||
response.headers.forEach((value, key) => {
|
||||
// Node manages framing headers itself; forwarding them corrupts the
|
||||
// response (double content-length, stale transfer-encoding).
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === 'content-length' || lower === 'transfer-encoding') return;
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
|
||||
if (!response.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeStream = Readable.fromWeb(response.body as never);
|
||||
nodeStream.on('error', () => res.destroy());
|
||||
nodeStream.pipe(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves local Workers on `*.workers.puter.localhost` by dispatching into
|
||||
* Miniflare via `LocalWorkerService`. No-op unless `config.workers.localServer`
|
||||
* is set — production keeps hitting real Cloudflare through `WorkerDriver`.
|
||||
*
|
||||
* Mount this BEFORE the body-parsing middleware in `server.ts` so the Worker
|
||||
* receives the raw request stream.
|
||||
*/
|
||||
export const createLocalWorkerProxyMiddleware = (
|
||||
config: IConfig,
|
||||
layers: Layers,
|
||||
): RequestHandler => {
|
||||
if (!config.workers?.localServer) {
|
||||
return (_req, _res, next) => next();
|
||||
}
|
||||
|
||||
const localWorkerService = layers.services.localworkerservice;
|
||||
|
||||
return async (req, res, next) => {
|
||||
const host = normalizeHost(req.hostname);
|
||||
if (!host) return next();
|
||||
|
||||
const workerName = workerNameFromHost(host);
|
||||
if (!workerName) return next();
|
||||
|
||||
try {
|
||||
const fetchRequest = toFetchRequest(req);
|
||||
const response = (await localWorkerService.cfCallLocal(
|
||||
workerName,
|
||||
fetchRequest,
|
||||
)) as unknown as FetchResponse;
|
||||
sendFetchResponse(res, response);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { withSpan } from '../../../util/span.js';
|
||||
import { HttpError } from '../HttpError.js';
|
||||
|
||||
/**
|
||||
@@ -256,8 +257,32 @@ async function acquireKvConcurrent(kv, key, limit) {
|
||||
// limiting — so route / driver code never has to reason about which
|
||||
// backend is wired for which mode.
|
||||
|
||||
/**
|
||||
* Wrap a backend pair so every rate / acquire call runs inside a span
|
||||
* tagged with the backend name. Applied at registration, so all gates
|
||||
* (route middleware, driver helpers, imperative checks) are covered.
|
||||
*/
|
||||
function instrumentBackendPair(name, pair) {
|
||||
const attrs = { 'rate_limit.backend': name };
|
||||
return {
|
||||
rate: (key, limit, windowMs) =>
|
||||
withSpan('rate_limit.check', attrs, () =>
|
||||
pair.rate(key, limit, windowMs),
|
||||
),
|
||||
acquire: (key, limit) =>
|
||||
withSpan('rate_limit.acquire', attrs, () =>
|
||||
pair.acquire(key, limit),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const memoryBackendPair = instrumentBackendPair('memory', {
|
||||
rate: checkMemory,
|
||||
acquire: acquireMemoryConcurrent,
|
||||
});
|
||||
|
||||
const backends = {
|
||||
memory: { rate: checkMemory, acquire: acquireMemoryConcurrent },
|
||||
memory: memoryBackendPair,
|
||||
};
|
||||
let defaultBackendName = 'memory';
|
||||
|
||||
@@ -295,19 +320,19 @@ export function configureRateLimit({
|
||||
} = {}) {
|
||||
// Reset (test reconfigure clears stale wiring).
|
||||
for (const name of Object.keys(backends)) delete backends[name];
|
||||
backends.memory = { rate: checkMemory, acquire: acquireMemoryConcurrent };
|
||||
backends.memory = memoryBackendPair;
|
||||
if (redis) {
|
||||
backends.redis = {
|
||||
backends.redis = instrumentBackendPair('redis', {
|
||||
rate: (key, limit, windowMs) =>
|
||||
checkRedis(redis, key, limit, windowMs),
|
||||
acquire: (key, limit) => acquireRedisConcurrent(redis, key, limit),
|
||||
};
|
||||
});
|
||||
}
|
||||
if (kv) {
|
||||
backends.kv = {
|
||||
backends.kv = instrumentBackendPair('kv', {
|
||||
rate: (key, limit, windowMs) => checkKv(kv, key, limit, windowMs),
|
||||
acquire: (key, limit) => acquireKvConcurrent(kv, key, limit),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
meteringService = metering ?? null;
|
||||
@@ -351,9 +376,10 @@ function resolveBackend(name) {
|
||||
* Build a rate-limit key from the request.
|
||||
*
|
||||
* Strategies:
|
||||
* 'fingerprint' — IP + User-Agent hash (default). Good for
|
||||
* unauthenticated endpoints where the same IP may
|
||||
* serve many users (offices, VPNs).
|
||||
* 'fingerprint' — network hash (IP + headers), refined by the client's
|
||||
* device fingerprint when one was supplied (default).
|
||||
* Good for unauthenticated endpoints where the same
|
||||
* IP may serve many users (offices, VPNs).
|
||||
* 'ip' — bare IP. Simpler but coarser.
|
||||
* 'user' — actor UUID. Use for authenticated endpoints where
|
||||
* you want per-account limits regardless of IP.
|
||||
@@ -392,7 +418,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. Anchors 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 +439,21 @@ function fingerprint(req) {
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* The device fingerprint (validated and stamped by the fingerprint middleware)
|
||||
* refines the bucket so devices behind one NAT don't crowd each other's limit.
|
||||
* It stays anchored to the network hash because the value is client-supplied:
|
||||
* alone it could be spoofed to drain another device's bucket, and rotating it
|
||||
* to mint fresh buckets is caught by the same stacked 'ip' backstop that
|
||||
* catches User-Agent rotation.
|
||||
*/
|
||||
function fingerprint(req) {
|
||||
const network = req.networkFingerprint ?? computeNetworkFingerprint(req);
|
||||
return req.deviceFingerprint
|
||||
? `${network}:${req.deviceFingerprint}`
|
||||
: network;
|
||||
}
|
||||
|
||||
// -- Route middleware ------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,6 +67,7 @@ describe('rateLimitGate — memory backend (default)', () => {
|
||||
actor: init.actor,
|
||||
route: init.route,
|
||||
socket: init.socket ?? { remoteAddress: '1.2.3.4' },
|
||||
deviceFingerprint: init.deviceFingerprint,
|
||||
});
|
||||
|
||||
it('admits up to `limit` hits and rejects the next one with 429', async () => {
|
||||
@@ -140,6 +141,62 @@ describe('rateLimitGate — memory backend (default)', () => {
|
||||
expect(isHttpError(re)).toBe(true);
|
||||
});
|
||||
|
||||
it("'fingerprint' (default) gives each device behind one network its own bucket", async () => {
|
||||
// Same IP and identical headers (a NAT'd office of look-alike
|
||||
// machines) but distinct device fingerprints (stamped by the
|
||||
// fingerprint middleware) → independent buckets, so one device
|
||||
// hitting its limit doesn't block the whole network.
|
||||
const opts = { limit: 1, window: 60_000, scope: 'mem-fp-device' };
|
||||
const shared = {
|
||||
ip: '5.6.7.8',
|
||||
headers: { 'user-agent': 'shared-browser' },
|
||||
};
|
||||
expect(
|
||||
await runGate(
|
||||
opts,
|
||||
makeReq({ ...shared, deviceFingerprint: 'device-alice-01' }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await runGate(
|
||||
opts,
|
||||
makeReq({ ...shared, deviceFingerprint: 'device-bob-02' }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
// Same device again → its own bucket is full.
|
||||
const re = await runGate(
|
||||
opts,
|
||||
makeReq({ ...shared, deviceFingerprint: 'device-alice-01' }),
|
||||
);
|
||||
expect(isHttpError(re)).toBe(true);
|
||||
});
|
||||
|
||||
it('device fingerprint stays anchored to the network — spoofing a value from another IP cannot drain its bucket', async () => {
|
||||
const opts = { limit: 1, window: 60_000, scope: 'mem-fp-anchor' };
|
||||
const fp = 'device-victim-01';
|
||||
// Victim exhausts their bucket from their own network.
|
||||
expect(
|
||||
await runGate(
|
||||
opts,
|
||||
makeReq({ ip: '10.0.0.1', deviceFingerprint: fp }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
const re = await runGate(
|
||||
opts,
|
||||
makeReq({ ip: '10.0.0.1', deviceFingerprint: fp }),
|
||||
);
|
||||
expect(isHttpError(re)).toBe(true);
|
||||
// Attacker replays the same device fingerprint from elsewhere:
|
||||
// different network hash → different bucket, and the victim's
|
||||
// bucket is untouched.
|
||||
expect(
|
||||
await runGate(
|
||||
opts,
|
||||
makeReq({ ip: '99.99.99.99', deviceFingerprint: fp }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
// Stacked gates, mirroring the materializer's handling of a
|
||||
// `rateLimit: [...]` array (each entry is its own gate; a rejection
|
||||
// short-circuits the chain). This is the credential-endpoint pattern:
|
||||
|
||||
@@ -121,9 +121,15 @@ export interface RouteOptions {
|
||||
* extras in this array. `true` means just `admin`/`system`; an array adds
|
||||
* to that pair (does not replace it). Implies `requireAuth`.
|
||||
*
|
||||
* Does NOT imply `requireUserActor` — admin endpoints accept an admin's
|
||||
* access-token or app-under-user actor. Combine with `requireUserActor`
|
||||
* to restrict to browser sessions.
|
||||
* Also requires a *root token* (an actor with no app anywhere in its
|
||||
* token chain), so an admin acting through a third-party app can't reach
|
||||
* the route. Pair with `allowedAppIds` to make an admin route reachable
|
||||
* by specific apps: the combination admits a root token OR a token
|
||||
* scoped to an allowed app.
|
||||
*
|
||||
* Does NOT imply `requireUserActor` — a root token still includes an
|
||||
* admin's full-access personal access token, not only browser sessions.
|
||||
* Combine with `requireUserActor` to restrict to browser sessions.
|
||||
*/
|
||||
adminOnly?: boolean | string[];
|
||||
|
||||
@@ -210,8 +216,10 @@ export interface RouteOptions {
|
||||
* request identity.
|
||||
*
|
||||
* `key` controls how requests are bucketed:
|
||||
* - `'fingerprint'` (default) — IP + User-Agent hash. Safe for
|
||||
* shared IPs (offices, VPNs).
|
||||
* - `'fingerprint'` (default) — network hash (IP + headers),
|
||||
* refined by the client's device fingerprint when one was
|
||||
* supplied. Safe for shared IPs (offices, VPNs): each device
|
||||
* gets its own bucket instead of the whole network sharing one.
|
||||
* - `'ip'` — bare IP address.
|
||||
* - `'user'` — actor's user ID. Use for authenticated routes
|
||||
* where you want per-account limits.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -23,6 +23,74 @@ import type { IChatModel } from '../../types.js';
|
||||
|
||||
// Hardcoded from https://models.dev/api.json
|
||||
export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.6-sol',
|
||||
id: 'gpt-5.6-sol',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2026-02-16',
|
||||
aliases: [
|
||||
'gpt-5.6',
|
||||
'gpt-5.6-sol',
|
||||
'openai/gpt-5.6',
|
||||
'openai/gpt-5.6-sol',
|
||||
],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 500,
|
||||
cached_tokens: 50,
|
||||
completion_tokens: 3000,
|
||||
},
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.6-terra',
|
||||
id: 'gpt-5.6-terra',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2026-02-16',
|
||||
aliases: ['gpt-5.6-terra', 'openai/gpt-5.6-terra'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
cached_tokens: 25,
|
||||
completion_tokens: 1500,
|
||||
},
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.6-luna',
|
||||
id: 'gpt-5.6-luna',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2026-02-16',
|
||||
aliases: ['gpt-5.6-luna', 'openai/gpt-5.6-luna'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 100,
|
||||
cached_tokens: 10,
|
||||
completion_tokens: 600,
|
||||
},
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.5',
|
||||
id: 'gpt-5.5-2026-04-23',
|
||||
|
||||
@@ -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);
|
||||
|
||||
Loaded 100 of 315 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user