mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dda6b82f0f | ||
|
|
6c757c3fc8 | ||
|
|
eba194936e | ||
|
|
a7bdac16da | ||
|
|
716a8d06e2 | ||
|
|
e95cf44fec | ||
|
|
8f8efcb349 | ||
|
|
b188942436 | ||
|
|
9b7e9a98c8 | ||
|
|
d6f7fb6155 | ||
|
|
bd91f5e192 | ||
|
|
971a1b5071 | ||
|
|
ac5eecb7f3 | ||
|
|
3ae076b73e | ||
|
|
5394ccc45c | ||
|
|
3649109f40 | ||
|
|
0ff0ea7743 | ||
|
|
8be115096f | ||
|
|
e66fd2373f | ||
|
|
0e2cce152d | ||
|
|
101e0bc930 | ||
|
|
ea078250bf | ||
|
|
db79f8e484 | ||
|
|
6422f2d513 | ||
|
|
c60626f772 | ||
|
|
cafdaf7b50 | ||
|
|
bb0f56d758 | ||
|
|
7438db7a90 | ||
|
|
948f771838 | ||
|
|
82e34f014d | ||
|
|
9e4c3f8ba3 | ||
|
|
e6acd7386a | ||
|
|
b04e8b046a | ||
|
|
3262b984b7 | ||
|
|
71af88c73c | ||
|
|
2275f65169 | ||
|
|
71b7d6367f | ||
|
|
08369d840b | ||
|
|
94236133b5 | ||
|
|
84b48541bd | ||
|
|
a33d02f522 | ||
|
|
f94d4aa7d9 | ||
|
|
89cfb0c634 | ||
|
|
c9096e9eed | ||
|
|
859ca207c5 | ||
|
|
58fc122f0d | ||
|
|
0e990da961 | ||
|
|
db8236cc97 | ||
|
|
d731ccf873 | ||
|
|
6493d66901 | ||
|
|
9e7f38f473 | ||
|
|
a568ca0f99 | ||
|
|
41e7228a9f | ||
|
|
4568639718 | ||
|
|
73ecff0bd7 | ||
|
|
f2f023880f | ||
|
|
f2093e9204 | ||
|
|
23497dd371 | ||
|
|
37619fb2a3 | ||
|
|
f976686eba | ||
|
|
7af3721543 | ||
|
|
93edec0d01 | ||
|
|
54b61f2e24 | ||
|
|
184ed77493 | ||
|
|
8234b72fc3 | ||
|
|
cc9921945e | ||
|
|
ac6f23a41f | ||
|
|
973d046d33 | ||
|
|
dcb50cde8f | ||
|
|
08beb55853 | ||
|
|
bb0220e163 | ||
|
|
f3e4c8c6d0 | ||
|
|
44254671bb | ||
|
|
bcc6cc8f00 | ||
|
|
84549a1c31 | ||
|
|
0276420213 | ||
|
|
6353a08fd5 | ||
|
|
c265fa308a | ||
|
|
d3f72bc33a | ||
|
|
700fc4d27e | ||
|
|
9c7f30a150 | ||
|
|
85e45b30f5 | ||
|
|
5483e32eab | ||
|
|
f63c7d8142 | ||
|
|
5a2071f852 | ||
|
|
238ed12e28 | ||
|
|
d1afe6c3cf | ||
|
|
e58dd48e80 | ||
|
|
69f87d4dcd | ||
|
|
7b0dd11f7f | ||
|
|
620934fbf4 | ||
|
|
182fc723f1 | ||
|
|
5d3c5b6873 | ||
|
|
f20445bab7 | ||
|
|
8ad1a58f98 | ||
|
|
17e475236a | ||
|
|
b948eaaaa6 | ||
|
|
9b0c034247 | ||
|
|
b0dc9c7f1b | ||
|
|
e79abd43e3 | ||
|
|
36c9c478ec | ||
|
|
90d01b87ea | ||
|
|
b0823449bc | ||
|
|
760d5d346d | ||
|
|
4e11d1dd0f | ||
|
|
5e8c66142d | ||
|
|
189dae25ef | ||
|
|
b2f4a86224 | ||
|
|
122811eb7a | ||
|
|
bc98dacf65 | ||
|
|
b593eb5630 |
No files matched your search
@@ -66,6 +66,10 @@ Keep comments light. Prefer self-documenting code — clear names, small functio
|
||||
|
||||
Don't restate what the code already says. Don't write comments that reference the current task or PR — 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 `-`.
|
||||
|
||||
### Tests
|
||||
|
||||
When adding new behavior (function, endpoint, driver method, branch of logic), add a test for it.
|
||||
+1
-1
@@ -6,7 +6,7 @@ We at **Puter** are committed to maintaining a secure experience for our users a
|
||||
|
||||
The following are in scope for this program:
|
||||
|
||||
* **The Puter open-source project** (available at [github.com/HeyPuter](https://github.com/HeyPuter/puter))
|
||||
* **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
|
||||
* **`puter.com`**
|
||||
* **`api.puter.com`**
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ Connect with the maintainers and community through these channels:
|
||||
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
|
||||
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
|
||||
- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter)
|
||||
- Security issues? [[email protected]](mailto:[email protected])
|
||||
- Security issues or abuse reports? [[email protected]](mailto:[email protected])
|
||||
- Email maintainers at [[email protected]](mailto:[email protected])
|
||||
|
||||
We are always happy to help you with any questions you may have. Don't hesitate to ask!
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"domain": "puter.localhost",
|
||||
"cookie_name": "puter_auth_token",
|
||||
"jwt_secret": "dev-jwt-secret-change-me",
|
||||
"jwt_secret_v2": "dev-jwt-secret-v2-change-me",
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "dev-url-signature-secret-change-me",
|
||||
"allow_all_host_values": true,
|
||||
"allow_no_host_header": true,
|
||||
|
||||
@@ -67,7 +67,11 @@
|
||||
|
||||
// ── Auth / session ──────────────────────────────────────────────────
|
||||
// ALWAYS replace these for any public install — `openssl rand -hex 64`.
|
||||
// `jwt_secret` is legacy verify-only; new tokens sign with `jwt_secret_v2`.
|
||||
"jwt_secret": "change-me",
|
||||
"jwt_secret_v2": "change-me",
|
||||
// Set false to retire v1 tokens entirely (ROLLOUT-1).
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "change-me",
|
||||
"cookie_name": "puter_auth_token",
|
||||
"min_pass_length": 6,
|
||||
|
||||
@@ -113,7 +113,7 @@ Terhubung dengan maintainer dan komunitas melalui saluran-saluran berikut:
|
||||
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
|
||||
- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter)
|
||||
- Isu keamanan? [[email protected]](mailto:[email protected])
|
||||
- Email maintainers di [[email protected]](mailto:[email protected])
|
||||
- Email pengelola di [[email protected]](mailto:[email protected])
|
||||
|
||||
Kami selalu senang membantu Anda dengan pertanyaan apa pun yang Anda miliki. Jangan ragu untuk bertanya!
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
|
||||
MARIADB_PASSWORD=$(openssl rand -hex 32)
|
||||
S3_SECRET_KEY=$(openssl rand -hex 32)
|
||||
JWT_SECRET=$(openssl rand -hex 64)
|
||||
JWT_SECRET_V2=$(openssl rand -hex 64)
|
||||
URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
|
||||
|
||||
cat > .env <<EOF
|
||||
@@ -78,6 +79,8 @@ cat > puter/config/config.json <<EOF
|
||||
"private_app_hosting_domain_alt": "dev.puter.localhost",
|
||||
|
||||
"jwt_secret": "$JWT_SECRET",
|
||||
"jwt_secret_v2": "$JWT_SECRET_V2",
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "$URL_SIGNATURE_SECRET",
|
||||
|
||||
"database": {
|
||||
@@ -131,6 +134,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.
|
||||
- `database.migrationPaths` — Puter applies the bundled MySQL schema on boot. `mysql_mig_1.sql` (tables) and `mysql_mig_2.sql` (default apps: editor, viewer, pdf, camera, player, recorder, git, dev-center, puter-linux). Idempotent — safe to re-run.
|
||||
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
|
||||
|
||||
+12
-9
@@ -58,7 +58,10 @@ services:
|
||||
fi
|
||||
wait $$SERVER_PID
|
||||
volumes:
|
||||
- ./puter/data/valkey:/data
|
||||
# `:z` is an SELinux relabel hint for Fedora/RHEL hosts (no-op
|
||||
# everywhere else) — without it those distros deny container
|
||||
# access to the bind mount and the service loops on EACCES.
|
||||
- ./puter/data/valkey:/data:z
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD-SHELL", "valkey-cli -p 6379 cluster info | grep -q cluster_state:ok"]
|
||||
@@ -77,7 +80,7 @@ services:
|
||||
MARIADB_USER: ${MARIADB_USER:-puter}
|
||||
MARIADB_PASSWORD: ${MARIADB_PASSWORD:-puter-change-me}
|
||||
volumes:
|
||||
- ./puter/data/mariadb:/var/lib/mysql
|
||||
- ./puter/data/mariadb:/var/lib/mysql:z
|
||||
healthcheck:
|
||||
# `healthcheck.sh` ships with the mariadb image; --connect verifies
|
||||
# the server is accepting auth, not just listening on the socket.
|
||||
@@ -102,7 +105,7 @@ services:
|
||||
- "-dbPath"
|
||||
- "/home/dynamodblocal/data"
|
||||
volumes:
|
||||
- ./puter/data/dynamo:/home/dynamodblocal/data
|
||||
- ./puter/data/dynamo:/home/dynamodblocal/data:z
|
||||
|
||||
s3:
|
||||
# RustFS — S3-compatible object storage. Drop-in alternative:
|
||||
@@ -114,7 +117,7 @@ services:
|
||||
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY:-puter}
|
||||
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:-puter-secret-change-me}
|
||||
volumes:
|
||||
- ./puter/data/s3:/data
|
||||
- ./puter/data/s3:/data:z
|
||||
# Internal-only — browsers reach RustFS via nginx (`s3.<domain>`),
|
||||
# which preserves the Host header for S3 signature validation and
|
||||
# rides the same TLS termination as Puter. Uncomment to also expose
|
||||
@@ -180,7 +183,7 @@ services:
|
||||
container_name: puter-ollama
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./puter/data/ollama:/root/.ollama
|
||||
- ./puter/data/ollama:/root/.ollama:z
|
||||
# Uncomment to expose Ollama directly on the host (`localhost:11434`)
|
||||
# for `ollama` CLI / OpenAI-API compatible tools. Internal-only by default.
|
||||
# ports:
|
||||
@@ -257,9 +260,9 @@ services:
|
||||
PGID: 1000
|
||||
volumes:
|
||||
# Drop your config.json here — see selfhosted/full-stack.md.
|
||||
- ./puter/config:/etc/puter
|
||||
- ./puter/config:/etc/puter:z
|
||||
# Persistent runtime data (anything your config points at /var/puter).
|
||||
- ./puter/data/puter:/var/puter
|
||||
- ./puter/data/puter:/var/puter:z
|
||||
healthcheck:
|
||||
test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
|
||||
interval: 30s
|
||||
@@ -279,9 +282,9 @@ services:
|
||||
# Uncomment when you enable TLS in nginx/nginx.conf:
|
||||
# - "${HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro,z
|
||||
# TLS certs (fullchain.pem + privkey.pem). Read-only inside.
|
||||
- ./puter/tls:/etc/nginx/tls:ro
|
||||
- ./puter/tls:/etc/nginx/tls:ro,z
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://localhost/ || exit 1"]
|
||||
interval: 10s
|
||||
|
||||
@@ -95,7 +95,7 @@ async function decodeAndValidateThumbnail(
|
||||
return { mimeType, data };
|
||||
}
|
||||
|
||||
// ── thumbnail.created ───────────────────────────────────────────────
|
||||
// -- thumbnail.created -----------------------------------------------
|
||||
// Intercept data-URL thumbnails before they hit the DB: upload to S3
|
||||
// and replace the URL with an s3:// pointer.
|
||||
|
||||
@@ -253,7 +253,7 @@ extension.on(
|
||||
},
|
||||
);
|
||||
|
||||
// ── thumbnail.upload.prepare ────────────────────────────────────────
|
||||
// -- thumbnail.upload.prepare ----------------------------------------
|
||||
// Generate pre-signed upload URLs so the client can PUT directly to S3.
|
||||
|
||||
extension.on(
|
||||
@@ -266,7 +266,7 @@ extension.on(
|
||||
},
|
||||
);
|
||||
|
||||
// ── thumbnail.read ──────────────────────────────────────────────────
|
||||
// -- thumbnail.read --------------------------------------------------
|
||||
// Convert s3:// or legacy https:// thumbnails to signed URLs.
|
||||
|
||||
extension.on('thumbnail.read', async (_key, entry: Record<string, unknown>) => {
|
||||
@@ -279,7 +279,7 @@ extension.on('thumbnail.read', async (_key, entry: Record<string, unknown>) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── fs.remove.node ──────────────────────────────────────────────────
|
||||
// -- fs.remove.node --------------------------------------------------
|
||||
// Delete S3 thumbnail when the file is removed.
|
||||
|
||||
extension.on(
|
||||
|
||||
+12
-1
@@ -1,7 +1,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Context } from '@heyputer/backend/src/core';
|
||||
import { extension } from '@heyputer/backend/src/extensions';
|
||||
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
|
||||
import type { Request, Response } from 'express';
|
||||
import TimeAgo from 'javascript-time-ago';
|
||||
import localeEn from 'javascript-time-ago/locale/en';
|
||||
|
||||
@@ -94,6 +94,8 @@ export const handleWhoami = async (
|
||||
human_readable_age: user.timestamp
|
||||
? timeago.format(new Date(user.timestamp as string))
|
||||
: null,
|
||||
metadata: user.metadata,
|
||||
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
|
||||
};
|
||||
|
||||
// OIDC revalidate URL for password-less accounts
|
||||
@@ -171,6 +173,15 @@ export const handleWhoami = async (
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
delete subscription.offering.price_id;
|
||||
}
|
||||
|
||||
res.json(details);
|
||||
};
|
||||
|
||||
|
||||
@@ -113,7 +113,10 @@ if ($writeConfig) {
|
||||
$mariadbRootPw = New-HexSecret 32
|
||||
$mariadbPw = New-HexSecret 32
|
||||
$s3SecretKey = New-HexSecret 32
|
||||
# Two JWT secrets: $jwtSecret is verify-only for legacy v1 tokens
|
||||
# already in circulation; $jwtSecretV2 signs every new token.
|
||||
$jwtSecret = New-HexSecret 64
|
||||
$jwtSecretV2 = New-HexSecret 64
|
||||
$urlSigSecret = New-HexSecret 64
|
||||
|
||||
$envContent = @"
|
||||
@@ -142,6 +145,8 @@ S3_BUCKET=puter-local
|
||||
private_app_hosting_domain = "app.$PuterDomain"
|
||||
private_app_hosting_domain_alt = "dev.$PuterDomain"
|
||||
jwt_secret = $jwtSecret
|
||||
jwt_secret_v2 = $jwtSecretV2
|
||||
allow_v1_tokens = $true
|
||||
url_signature_secret = $urlSigSecret
|
||||
database = [ordered]@{
|
||||
engine = 'mysql'
|
||||
|
||||
+18
@@ -50,6 +50,18 @@ docker compose version >/dev/null 2>&1 \
|
||||
mkdir -p "$PUTER_DIR"
|
||||
cd "$PUTER_DIR"
|
||||
mkdir -p puter/config puter/data puter/tls
|
||||
# Pre-create per-service data dirs and make them writable by any UID.
|
||||
# Several upstream images run as non-root inside the container (rustfs
|
||||
# uses UID 10001; dynamo is pinned to 1000 in compose), and rustfs's
|
||||
# entrypoint runs as that same non-root user so it can't chown an
|
||||
# already-existing bind-mounted dir. On hosts where the user that ran
|
||||
# this script has a UID that doesn't match — or where docker is running
|
||||
# rootless — those containers loop on EACCES at startup. 0777 on the
|
||||
# bind-mount roots sidesteps the mismatch without guessing each image's
|
||||
# internal UID. (Docker Desktop on macOS/Windows papers over this with
|
||||
# its VM layer; native Linux docker on Debian/Alpine doesn't.)
|
||||
mkdir -p puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
|
||||
chmod 0777 puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
|
||||
log "install dir: $(pwd)"
|
||||
|
||||
# ── Step 3: docker-compose.yml + nginx config ──────────────────────
|
||||
@@ -80,7 +92,11 @@ if [ "$write_config" = "1" ]; then
|
||||
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
|
||||
MARIADB_PASSWORD=$(openssl rand -hex 32)
|
||||
S3_SECRET_KEY=$(openssl rand -hex 32)
|
||||
# Two JWT secrets: `jwt_secret` is verify-only for legacy v1 tokens
|
||||
# already in circulation; `jwt_secret_v2` signs every new token.
|
||||
# Both are required at boot (`jwt_secret` only when verifying v1).
|
||||
JWT_SECRET=$(openssl rand -hex 64)
|
||||
JWT_SECRET_V2=$(openssl rand -hex 64)
|
||||
URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
|
||||
|
||||
cat > .env <<EOF
|
||||
@@ -111,6 +127,8 @@ EOF
|
||||
"private_app_hosting_domain_alt": "dev.$PUTER_DOMAIN",
|
||||
|
||||
"jwt_secret": "$JWT_SECRET",
|
||||
"jwt_secret_v2": "$JWT_SECRET_V2",
|
||||
"allow_v1_tokens": true,
|
||||
"url_signature_secret": "$URL_SIGNATURE_SECRET",
|
||||
|
||||
"database": {
|
||||
|
||||
Generated
+223
-45
@@ -5984,19 +5984,18 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/eventemitter": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
|
||||
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
|
||||
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/fetch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
|
||||
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
|
||||
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.1",
|
||||
"@protobufjs/inquire": "^1.1.0"
|
||||
"@protobufjs/aspromise": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@protobufjs/float": {
|
||||
@@ -6006,9 +6005,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/inquire": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
|
||||
"integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
|
||||
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@protobufjs/path": {
|
||||
@@ -7436,6 +7435,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsonwebtoken": {
|
||||
"version": "9.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/ms": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/memcached": {
|
||||
"version": "2.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/memcached/-/memcached-2.2.10.tgz",
|
||||
@@ -7459,6 +7469,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mysql": {
|
||||
"version": "2.15.26",
|
||||
"resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz",
|
||||
@@ -7570,6 +7587,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/validator": {
|
||||
"version": "13.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -8934,9 +8958,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10156,9 +10180,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.7",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz",
|
||||
"integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==",
|
||||
"version": "6.6.8",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz",
|
||||
"integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
@@ -10170,7 +10194,7 @@
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3"
|
||||
"ws": "~8.20.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
@@ -10225,9 +10249,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -15400,24 +15424,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.5.6",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
|
||||
"integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==",
|
||||
"version": "7.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz",
|
||||
"integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@protobufjs/aspromise": "^1.1.2",
|
||||
"@protobufjs/base64": "^1.1.2",
|
||||
"@protobufjs/codegen": "^2.0.5",
|
||||
"@protobufjs/eventemitter": "^1.1.0",
|
||||
"@protobufjs/fetch": "^1.1.0",
|
||||
"@protobufjs/eventemitter": "^1.1.1",
|
||||
"@protobufjs/fetch": "^1.1.1",
|
||||
"@protobufjs/float": "^1.0.2",
|
||||
"@protobufjs/inquire": "^1.1.1",
|
||||
"@protobufjs/inquire": "^1.1.2",
|
||||
"@protobufjs/path": "^1.1.2",
|
||||
"@protobufjs/pool": "^1.1.0",
|
||||
"@protobufjs/utf8": "^1.1.1",
|
||||
"@types/node": ">=13.7.0",
|
||||
"long": "^5.0.0"
|
||||
"long": "^5.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -15481,9 +15505,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
@@ -15941,6 +15965,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/run-applescript": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||
@@ -16604,13 +16634,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
|
||||
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz",
|
||||
"integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.18.3"
|
||||
"ws": "~8.20.1"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter/node_modules/debug": {
|
||||
@@ -16631,9 +16661,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter/node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -17214,9 +17244,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz",
|
||||
"integrity": "sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
@@ -18176,9 +18206,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -18395,7 +18425,9 @@
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/validator": "^13.15.10",
|
||||
"chai": "^4.3.7",
|
||||
"nodemon": "^3.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
@@ -18475,6 +18507,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"cssstyle": "^4.6.0",
|
||||
"esbuild": "0.25.11",
|
||||
"fs-extra": "^11.2.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
@@ -18496,6 +18529,129 @@
|
||||
"@esbuild/linux-x64": "0.25.11"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/@asamuzakjp/css-color": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^2.1.3",
|
||||
"@csstools/css-color-parser": "^3.0.9",
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3",
|
||||
"lru-cache": "^10.4.3"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/@csstools/css-calc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/@csstools/css-color-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^5.1.0",
|
||||
"@csstools/css-calc": "^2.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.11",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
|
||||
@@ -18917,6 +19073,19 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/cssstyle": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^3.2.0",
|
||||
"rrweb-cssom": "^0.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"license": "MIT",
|
||||
@@ -19033,6 +19202,8 @@
|
||||
},
|
||||
"src/docs/node_modules/jsdom": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
@@ -19078,6 +19249,12 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"src/docs/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"src/docs/node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"license": "MIT",
|
||||
@@ -19199,13 +19376,14 @@
|
||||
},
|
||||
"src/puter-js": {
|
||||
"name": "@heyputer/puter.js",
|
||||
"version": "2.3.2",
|
||||
"version": "2.4.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@heyputer/kv.js": "^0.2.1",
|
||||
"open": "^10.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"http-server": "^14.1.1",
|
||||
"webpack-cli": "^5.1.4"
|
||||
|
||||
@@ -23,7 +23,7 @@ import { createHash } from 'node:crypto';
|
||||
import type { IConfig } from '../../types';
|
||||
import { PuterClient } from '../types';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
// -- Types ------------------------------------------------------------
|
||||
|
||||
export interface AlarmFields {
|
||||
error?: Error;
|
||||
@@ -49,7 +49,7 @@ interface Alarm {
|
||||
noAlert?: boolean;
|
||||
}
|
||||
|
||||
type PagerSeverity = 'critical' | 'error' | 'warning' | 'info';
|
||||
export type PagerSeverity = 'critical' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface AlertPayload {
|
||||
id: string;
|
||||
@@ -73,7 +73,7 @@ interface KnownErrorRule {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
// -- Helpers ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deterministic short identifier derived from an alarm ID.
|
||||
@@ -166,7 +166,7 @@ function cleanFields(fields: AlarmFields): Record<string, string> {
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── AlarmClient ──────────────────────────────────────────────────────
|
||||
// -- AlarmClient ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Manages system alarms and dispatches alerts to external paging
|
||||
@@ -184,7 +184,7 @@ export class AlarmClient extends PuterClient {
|
||||
super(config);
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────
|
||||
// -- Lifecycle ----------------------------------------------------
|
||||
|
||||
override async onServerStart(): Promise<void> {
|
||||
const pagerConf = this.config.pager;
|
||||
@@ -228,13 +228,23 @@ export class AlarmClient extends PuterClient {
|
||||
console.log('[alarm] entering drain mode — suppressing new alarms');
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────
|
||||
// -- Public API ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create or update an alarm. If the alarm ID already exists, the
|
||||
* occurrence count is incremented and a repeat alert is dispatched.
|
||||
*
|
||||
* `severity` is the PagerDuty severity for this alarm; omit for the
|
||||
* default 'critical'. Use 'info' / 'warning' for expected-but-worth-
|
||||
* tracking signals (e.g. a user hitting a rate limit) so they record
|
||||
* and de-dupe like any other alarm but don't page on-call.
|
||||
*/
|
||||
create(id: string, message: string, fields: AlarmFields = {}): void {
|
||||
create(
|
||||
id: string,
|
||||
message: string,
|
||||
fields: AlarmFields = {},
|
||||
severity?: PagerSeverity,
|
||||
): void {
|
||||
if (this.draining) {
|
||||
if (!this.drainLogged) {
|
||||
this.drainLogged = true;
|
||||
@@ -256,6 +266,7 @@ export class AlarmClient extends PuterClient {
|
||||
shortId: shortId(id),
|
||||
message,
|
||||
fields,
|
||||
severity,
|
||||
started: Date.now(),
|
||||
timestamps: [Date.now()],
|
||||
occurrences: [],
|
||||
@@ -298,7 +309,7 @@ export class AlarmClient extends PuterClient {
|
||||
this.knownErrors = rules;
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────
|
||||
// -- Internals ----------------------------------------------------
|
||||
|
||||
private recordOccurrence(
|
||||
alarm: Alarm,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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 { compareMigrationFilenames } from './MySQLDatabaseClient.js';
|
||||
|
||||
describe('compareMigrationFilenames', () => {
|
||||
it('orders numbered migrations numerically, not lexically', () => {
|
||||
const input = [
|
||||
'mysql_mig_10.sql',
|
||||
'mysql_mig_2.sql',
|
||||
'mysql_mig_1.sql',
|
||||
'mysql_mig_9.sql',
|
||||
'mysql_mig_3.sql',
|
||||
];
|
||||
const sorted = [...input].sort(compareMigrationFilenames);
|
||||
expect(sorted).toEqual([
|
||||
'mysql_mig_1.sql',
|
||||
'mysql_mig_2.sql',
|
||||
'mysql_mig_3.sql',
|
||||
'mysql_mig_9.sql',
|
||||
'mysql_mig_10.sql',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps mig_10 after mig_9 when the real prod set is shuffled', () => {
|
||||
// Reflects the current migrations/mysql/ listing — guards against
|
||||
// a future "let's just rename to padded" suggestion accidentally
|
||||
// re-introducing the lex bug if the rename is incomplete.
|
||||
const real = [
|
||||
'mysql_mig_9.sql',
|
||||
'mysql_mig_3.sql',
|
||||
'mysql_mig_10.sql',
|
||||
'mysql_mig_1.sql',
|
||||
'mysql_mig_7.sql',
|
||||
'mysql_mig_5.sql',
|
||||
'mysql_mig_2.sql',
|
||||
'mysql_mig_4.sql',
|
||||
'mysql_mig_8.sql',
|
||||
'mysql_mig_6.sql',
|
||||
];
|
||||
const sorted = [...real].sort(compareMigrationFilenames);
|
||||
for (let i = 1; i <= sorted.length; i += 1) {
|
||||
expect(sorted[i - 1]).toBe(`mysql_mig_${i}.sql`);
|
||||
}
|
||||
});
|
||||
|
||||
it('sorts non-numeric filenames after numbered ones, lexically among themselves', () => {
|
||||
// Numbered files always run first (they're the canonical history);
|
||||
// unmatched names follow in localeCompare order. Mixing the two
|
||||
// sets prevents a vendor dump from accidentally wedging itself
|
||||
// between mig_4 and mig_5 if it happened to lex-sort there.
|
||||
const mixed = [
|
||||
'mysql_mig_2.sql',
|
||||
'mysql_vendor_dump.sql',
|
||||
'mysql_mig_10.sql',
|
||||
'mysql_bootstrap.sql',
|
||||
'mysql_mig_1.sql',
|
||||
];
|
||||
const sorted = [...mixed].sort(compareMigrationFilenames);
|
||||
expect(sorted).toEqual([
|
||||
'mysql_mig_1.sql',
|
||||
'mysql_mig_2.sql',
|
||||
'mysql_mig_10.sql',
|
||||
'mysql_bootstrap.sql',
|
||||
'mysql_vendor_dump.sql',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is stable for already-sorted input', () => {
|
||||
const sorted = [
|
||||
'mysql_mig_1.sql',
|
||||
'mysql_mig_2.sql',
|
||||
'mysql_mig_10.sql',
|
||||
'mysql_mig_11.sql',
|
||||
];
|
||||
expect([...sorted].sort(compareMigrationFilenames)).toEqual(sorted);
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,37 @@ const RETRIABLE_ERROR_MESSAGES = [
|
||||
'ETIMEDOUT',
|
||||
];
|
||||
|
||||
/**
|
||||
* Comparator for MySQL migration filenames.
|
||||
*
|
||||
* Existing files are named `mysql_mig_<N>.sql` with unpadded N, so a
|
||||
* plain lexical sort puts `mysql_mig_10.sql` BEFORE `mysql_mig_2.sql`.
|
||||
* Pull the trailing integer out and sort numerically. Anything that
|
||||
* doesn't match the `_<digits>.sql` shape (one-off, vendor dump) falls
|
||||
* back to lexical comparison so the order stays deterministic, and
|
||||
* unmatched names sort *after* numbered files so future numbered
|
||||
* migrations don't get interleaved into a one-off's namespace.
|
||||
*
|
||||
* Exported only for the unit test — the production caller is the
|
||||
* `runMigrations()` loop inside this file.
|
||||
*/
|
||||
export const compareMigrationFilenames = (a: string, b: string): number => {
|
||||
const numericIndex = (name: string): number => {
|
||||
const m = /_(\d+)\.sql$/.exec(name);
|
||||
return m ? Number.parseInt(m[1], 10) : Number.NaN;
|
||||
};
|
||||
const na = numericIndex(a);
|
||||
const nb = numericIndex(b);
|
||||
if (Number.isFinite(na) && Number.isFinite(nb)) {
|
||||
if (na !== nb) return na - nb;
|
||||
} else if (Number.isFinite(na)) {
|
||||
return -1;
|
||||
} else if (Number.isFinite(nb)) {
|
||||
return 1;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
};
|
||||
|
||||
type PoolConfig = Parameters<typeof createPool>[0];
|
||||
|
||||
enum Configuration {
|
||||
@@ -247,7 +278,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
|
||||
.filter(
|
||||
(f) => f.endsWith('.sql') && f.startsWith('mysql'),
|
||||
)
|
||||
.sort();
|
||||
.sort(compareMigrationFilenames);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`[mysql] migration path is unreadable: ${dir}`,
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { basename, extname, join, resolve } from 'path';
|
||||
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 { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
|
||||
@@ -78,6 +78,13 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
|
||||
[43, ['0047_app-url-updates.sql']],
|
||||
[44, ['0048_old-app-names-unique-tuple.sql']],
|
||||
[45, ['0049_music-player-pdf-player-updates.sql']],
|
||||
[46, ['0050_add_preamble_version.sql']],
|
||||
[47, ['0051_sessions_v2.sql']],
|
||||
[48, ['0052_sessions_v2_lookups.sql']],
|
||||
[49, ['0053_sessions_access_token_uid.sql']],
|
||||
[50, ['0054_sessions_workers.sql']],
|
||||
[50, ['0055_username_nocase_unique.sql']],
|
||||
[51, ['0056_sessions_kind_worker.sql']],
|
||||
];
|
||||
|
||||
export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
@@ -102,6 +109,10 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
|
||||
: (this.config.database?.path ?? ':memory:');
|
||||
const isNew = dbPath === ':memory:' || !existsSync(dbPath);
|
||||
|
||||
if (dbPath !== ':memory:') {
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
}
|
||||
|
||||
this.db = new Database(dbPath);
|
||||
|
||||
await this.runMigrations(isNew);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Copyright (C) 2024-present Puter Technologies Inc.
|
||||
--
|
||||
-- This file is part of Puter.
|
||||
--
|
||||
-- Puter is free software: you can redistribute it and/or modify
|
||||
-- it under the terms of the GNU Affero General Public License as published
|
||||
-- by the Free Software Foundation, either version 3 of the License, or
|
||||
-- (at your option) any later version.
|
||||
--
|
||||
-- This program is distributed in the hope that it will be useful,
|
||||
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
-- GNU Affero General Public License for more details.
|
||||
--
|
||||
-- You should have received a copy of the GNU Affero General Public License
|
||||
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- Mirrors SQLite migration 0053. Adds the
|
||||
-- `access_token_uid` reverse-lookup column on `sessions` so raw-uuid
|
||||
-- revoke can find the matching session row when only the v2 token_uid
|
||||
-- (no JWT) is presented.
|
||||
--
|
||||
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration
|
||||
-- directory can be replayed safely.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_access_token_uid;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_sessions_access_token_uid()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'access_token_uid'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `access_token_uid` VARCHAR(64) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_access_token_uid'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD INDEX `idx_sessions_access_token_uid` (`access_token_uid`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_sessions_access_token_uid();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_access_token_uid;
|
||||
@@ -0,0 +1,70 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Worker session uniqueness. Each Puter worker is a separately-deployed
|
||||
-- code unit (its own subdomain), so one user can have many workers under
|
||||
-- the same app — distinguished by `meta.worker_name`. The natural unique
|
||||
-- key for an active worker session is therefore
|
||||
-- (user_id, app_uid, worker_name), and app_uid is allowed NULL for
|
||||
-- user-scoped workers that aren't bound to any specific app.
|
||||
--
|
||||
-- Implemented the same way as `app_unique_key` from mig_9: a VIRTUAL
|
||||
-- generated column that's non-NULL only for the rows under the rule,
|
||||
-- then a UNIQUE INDEX on the column. NULLs don't conflict in MySQL
|
||||
-- UNIQUE indexes, so soft-revoked / non-worker rows fall out
|
||||
-- automatically. IFNULL normalises NULL `app_uid` so two user-scoped
|
||||
-- workers with the same name still dedupe. JSON_UNQUOTE strips the
|
||||
-- JSON value quoting from JSON_EXTRACT so the concatenated key is a
|
||||
-- plain string that matches the SELECT path's binding.
|
||||
--
|
||||
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration
|
||||
-- directory can be replayed safely.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_worker_unique;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_sessions_worker_unique()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'worker_unique_key'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `worker_unique_key` VARCHAR(550)
|
||||
GENERATED ALWAYS AS (
|
||||
IF(`kind` = 'worker' AND `revoked_at` IS NULL,
|
||||
CONCAT(`user_id`, '|', IFNULL(`app_uid`, ''), '|',
|
||||
IFNULL(JSON_UNQUOTE(JSON_EXTRACT(`meta`, '$.worker_name')), '')),
|
||||
NULL)
|
||||
) VIRTUAL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_user_worker_active'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD UNIQUE INDEX `idx_sessions_user_worker_active` (`worker_unique_key`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_sessions_worker_unique();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_worker_unique;
|
||||
@@ -0,0 +1,50 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Add 'worker' to the `sessions.kind` ENUM. mig_11 already added the
|
||||
-- worker uniqueness index and the application code in SessionStore
|
||||
-- inserts rows with kind='worker', but the ENUM defined in mig_8 never
|
||||
-- listed 'worker' as a permitted value. Under STRICT_TRANS_TABLES the
|
||||
-- INSERT fails outright; under a relaxed sql_mode the value is coerced
|
||||
-- to '' (and the worker SELECT-by-kind path then misses the row).
|
||||
-- Mirrors SQLite migration 0056.
|
||||
--
|
||||
-- Idempotent: guarded against COLUMN_TYPE so re-running the directory is
|
||||
-- a no-op once 'worker' is in the ENUM.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_kind_worker;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_sessions_kind_worker()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND COLUMN_NAME = 'kind'
|
||||
AND FIND_IN_SET('worker', REPLACE(REPLACE(REPLACE(COLUMN_TYPE, 'enum(', ''), ')', ''), '''', '')) > 0
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
MODIFY COLUMN `kind`
|
||||
ENUM('web', 'app', 'access_token', 'asset', 'worker')
|
||||
NOT NULL DEFAULT 'web';
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_sessions_kind_worker();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_kind_worker;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- 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/>.
|
||||
--
|
||||
-- Idempotent: the ADD COLUMN is guarded by an INFORMATION_SCHEMA check,
|
||||
-- so re-running the migration directory is safe (required — the runner
|
||||
-- has no per-file tracking).
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_add_subdomains_preamble_version()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'subdomains' AND COLUMN_NAME = 'preamble_version'
|
||||
) THEN
|
||||
ALTER TABLE `subdomains`
|
||||
ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL;
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_add_subdomains_preamble_version();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version;
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Extend `sessions` so a single row can represent any token kind
|
||||
-- (web/app/access_token/asset), carry display metadata for the
|
||||
-- manage-sessions UI, and be soft-revoked with row-level expiry.
|
||||
-- Mirrors SQLite migration 0050.
|
||||
--
|
||||
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded by an
|
||||
-- INFORMATION_SCHEMA check, so re-running the migration directory
|
||||
-- is safe (required — the runner has no per-file tracking).
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_extend_sessions_v2()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'kind'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `kind` ENUM('web', 'app', 'access_token', 'asset')
|
||||
NOT NULL DEFAULT 'web';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'label'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `label` VARCHAR(255) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'parent_session_id'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `parent_session_id` VARCHAR(64) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_ip'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `last_ip` VARCHAR(64) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_user_agent'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `last_user_agent` VARCHAR(512) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'revoked_at'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `revoked_at` BIGINT DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'expires_at'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `expires_at` BIGINT DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_user_revoked'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD INDEX `idx_sessions_user_revoked` (`user_id`, `revoked_at`);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_parent'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD INDEX `idx_sessions_parent` (`parent_session_id`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_extend_sessions_v2();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2;
|
||||
@@ -0,0 +1,130 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Composite-key lookups + audit columns. Mirrors
|
||||
-- SQLite migration 0052. MySQL has no partial unique indexes, so the
|
||||
-- "at most one active row per (user_id, app_uid)" / "one active row
|
||||
-- per legacy_token_uid" semantics are encoded via VIRTUAL generated
|
||||
-- columns that are NULL when the row isn't subject to the rule —
|
||||
-- MySQL allows multiple NULLs in a UNIQUE index, so non-applicable
|
||||
-- rows don't conflict.
|
||||
--
|
||||
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration
|
||||
-- directory can be replayed safely.
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_v2_lookups;
|
||||
DELIMITER //
|
||||
CREATE PROCEDURE _puter_sessions_v2_lookups()
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'app_uid'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `app_uid` VARCHAR(64) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'legacy_token_uid'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `legacy_token_uid` VARCHAR(64) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'created_via'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `created_via` VARCHAR(32) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'auth_id'
|
||||
) THEN
|
||||
ALTER TABLE `sessions` ADD COLUMN `auth_id` VARCHAR(64) DEFAULT NULL;
|
||||
END IF;
|
||||
|
||||
-- Generated discriminant: non-NULL only for active app-authorization rows,
|
||||
-- so UNIQUE(app_unique_key) enforces "one active app session per
|
||||
-- (user_id, app_uid)" while permitting any number of revoked rows.
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'app_unique_key'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `app_unique_key` VARCHAR(150)
|
||||
GENERATED ALWAYS AS (
|
||||
IF(`kind` = 'app' AND `revoked_at` IS NULL,
|
||||
CONCAT(`user_id`, '|', `app_uid`),
|
||||
NULL)
|
||||
) VIRTUAL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'legacy_token_unique_key'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD COLUMN `legacy_token_unique_key` VARCHAR(64)
|
||||
GENERATED ALWAYS AS (
|
||||
IF(`revoked_at` IS NULL, `legacy_token_uid`, NULL)
|
||||
) VIRTUAL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_user_app_active'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD UNIQUE INDEX `idx_sessions_user_app_active` (`app_unique_key`);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_legacy_token_active'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD UNIQUE INDEX `idx_sessions_legacy_token_active`
|
||||
(`legacy_token_unique_key`);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'sessions'
|
||||
AND INDEX_NAME = 'idx_sessions_kind_user'
|
||||
) THEN
|
||||
ALTER TABLE `sessions`
|
||||
ADD INDEX `idx_sessions_kind_user` (`kind`, `user_id`);
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
CALL _puter_sessions_v2_lookups();
|
||||
|
||||
DROP PROCEDURE IF EXISTS _puter_sessions_v2_lookups;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 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/>.
|
||||
|
||||
ALTER TABLE `subdomains` ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL;
|
||||
@@ -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/>.
|
||||
|
||||
-- Extend `sessions` so a single row can represent any token kind
|
||||
-- (web/app/access_token/asset), carry display metadata for the
|
||||
-- manage-sessions UI, and be soft-revoked.
|
||||
|
||||
ALTER TABLE `sessions` ADD COLUMN `kind` TEXT NOT NULL DEFAULT 'web'
|
||||
CHECK (`kind` IN ('web', 'app', 'access_token', 'asset'));
|
||||
ALTER TABLE `sessions` ADD COLUMN `label` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `parent_session_id` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `last_ip` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `last_user_agent` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `revoked_at` INTEGER;
|
||||
ALTER TABLE `sessions` ADD COLUMN `expires_at` INTEGER;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked`
|
||||
ON `sessions` (`user_id`, `revoked_at`);
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_parent`
|
||||
ON `sessions` (`parent_session_id`);
|
||||
@@ -0,0 +1,45 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Composite-key lookups + audit columns.
|
||||
-- - `app_uid` : binds `kind='app'` rows to their app authorization
|
||||
-- target. (user_id, app_uid) is the idempotency key.
|
||||
-- - `legacy_token_uid` : keys lazy-backfilled rows to the v1 token_uid that
|
||||
-- originally minted them.
|
||||
-- - `created_via` : audit sentinel (e.g. 'legacy_backfill').
|
||||
-- - `auth_id` : stable per-user identity that survives re-login;
|
||||
-- lets manage-sessions group by identity.
|
||||
|
||||
ALTER TABLE `sessions` ADD COLUMN `app_uid` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `legacy_token_uid` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `created_via` TEXT;
|
||||
ALTER TABLE `sessions` ADD COLUMN `auth_id` TEXT;
|
||||
|
||||
-- Partial unique indexes keep "at most one active row per key" without
|
||||
-- breaking the soft-revoke pattern (revoked rows stay for audit).
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_app_active`
|
||||
ON `sessions` (`user_id`, `app_uid`)
|
||||
WHERE `kind` = 'app' AND `revoked_at` IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_legacy_token_active`
|
||||
ON `sessions` (`legacy_token_uid`)
|
||||
WHERE `legacy_token_uid` IS NOT NULL AND `revoked_at` IS NULL;
|
||||
|
||||
-- Supports manage-sessions list queries grouped by kind.
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_kind_user`
|
||||
ON `sessions` (`kind`, `user_id`);
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Let `kind='access_token'` rows be reverse-looked-up
|
||||
-- from the `token_uid` claim that lives only in `access_token_permissions`.
|
||||
-- Required so `POST /auth/revoke-access-token` with a raw token_uid input
|
||||
-- (no JWT) can find and soft-revoke the session row, matching the JWT
|
||||
-- input path's coverage. Without it, raw-uuid revoke would only drop the
|
||||
-- permissions row, leaving the session-row kill switch un-flipped.
|
||||
|
||||
ALTER TABLE `sessions` ADD COLUMN `access_token_uid` TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_access_token_uid`
|
||||
ON `sessions` (`access_token_uid`)
|
||||
WHERE `access_token_uid` IS NOT NULL;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Worker session uniqueness. One active kind='worker' row per
|
||||
-- (user_id, app_uid, worker_name).
|
||||
--
|
||||
-- SQLite UNIQUE indexes treat NULLs as distinct (per the SQL standard),
|
||||
-- so user-scoped workers (where `app_uid` is NULL) would otherwise be
|
||||
-- allowed to duplicate. IFNULL collapses NULL `app_uid` to empty
|
||||
-- string in the index expression so two user-scoped workers with the
|
||||
-- same worker_name correctly conflict. MySQL handles the same case
|
||||
-- via the IFNULL in mig_11's generated column.
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_worker_active`
|
||||
ON `sessions` (`user_id`, IFNULL(`app_uid`, ''), json_extract(`meta`, '$.worker_name'))
|
||||
WHERE `kind` = 'worker' AND `revoked_at` IS NULL;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Enforce case-insensitive uniqueness on user.username.
|
||||
--
|
||||
-- SQLite defaults varchar columns to BINARY collation, so without this
|
||||
-- index `Admin` and `admin` can coexist as separate rows even though the
|
||||
-- reserved-name check and adminOnly gate both treat usernames as
|
||||
-- case-insensitive. Prod MySQL already uses ascii_general_ci on this
|
||||
-- column; this brings self-hosted SQLite to the same invariant.
|
||||
--
|
||||
-- If this CREATE fails because the DB already contains case-collision
|
||||
-- duplicates, resolve them manually before re-running the migration —
|
||||
-- there is no safe automatic merge of two user accounts.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_username_nocase
|
||||
ON user(username COLLATE NOCASE)
|
||||
WHERE username IS NOT NULL;
|
||||
@@ -0,0 +1,90 @@
|
||||
-- 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/>.
|
||||
|
||||
-- Add 'worker' to the `kind` CHECK constraint on `sessions`. Mirrors the
|
||||
-- MySQL ENUM extension in mysql_mig_12.
|
||||
--
|
||||
-- SQLite cannot ALTER a CHECK constraint in place, so we follow the
|
||||
-- standard 12-step rebuild: create `sessions_new` with the corrected
|
||||
-- constraint, copy rows, drop the old table, rename, then recreate every
|
||||
-- index that lived on the original (indexes are auto-dropped with their
|
||||
-- table).
|
||||
|
||||
CREATE TABLE `sessions_new` (
|
||||
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" INTEGER NOT NULL,
|
||||
"uuid" TEXT NOT NULL,
|
||||
"meta" JSON DEFAULT NULL,
|
||||
"created_at" INTEGER DEFAULT 0,
|
||||
"last_activity" INTEGER DEFAULT 0,
|
||||
"kind" TEXT NOT NULL DEFAULT 'web'
|
||||
CHECK (`kind` IN ('web', 'app', 'access_token', 'asset', 'worker')),
|
||||
"label" TEXT,
|
||||
"parent_session_id" TEXT,
|
||||
"last_ip" TEXT,
|
||||
"last_user_agent" TEXT,
|
||||
"revoked_at" INTEGER,
|
||||
"expires_at" INTEGER,
|
||||
"app_uid" TEXT,
|
||||
"legacy_token_uid" TEXT,
|
||||
"created_via" TEXT,
|
||||
"auth_id" TEXT,
|
||||
"access_token_uid" TEXT,
|
||||
FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
INSERT INTO `sessions_new` (
|
||||
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
|
||||
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
|
||||
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
|
||||
`created_via`, `auth_id`, `access_token_uid`
|
||||
)
|
||||
SELECT
|
||||
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
|
||||
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
|
||||
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
|
||||
`created_via`, `auth_id`, `access_token_uid`
|
||||
FROM `sessions`;
|
||||
|
||||
DROP TABLE `sessions`;
|
||||
|
||||
ALTER TABLE `sessions_new` RENAME TO `sessions`;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked`
|
||||
ON `sessions` (`user_id`, `revoked_at`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_parent`
|
||||
ON `sessions` (`parent_session_id`);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_app_active`
|
||||
ON `sessions` (`user_id`, `app_uid`)
|
||||
WHERE `kind` = 'app' AND `revoked_at` IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_legacy_token_active`
|
||||
ON `sessions` (`legacy_token_uid`)
|
||||
WHERE `legacy_token_uid` IS NOT NULL AND `revoked_at` IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_kind_user`
|
||||
ON `sessions` (`kind`, `user_id`);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS `idx_sessions_access_token_uid`
|
||||
ON `sessions` (`access_token_uid`)
|
||||
WHERE `access_token_uid` IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_worker_active`
|
||||
ON `sessions` (`user_id`, IFNULL(`app_uid`, ''), json_extract(`meta`, '$.worker_name'))
|
||||
WHERE `kind` = 'worker' AND `revoked_at` IS NULL;
|
||||
@@ -24,7 +24,7 @@ import type { IConfig } from '../../types';
|
||||
import { PuterClient } from '../types';
|
||||
import { EMAIL_TEMPLATES, type EmailTemplateName } from './templates';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
// -- Types ------------------------------------------------------------
|
||||
|
||||
// nodemailer doesn't ship TS types, so declare the subset we use.
|
||||
interface NodemailerTransport {
|
||||
@@ -50,7 +50,7 @@ interface CompiledTemplate {
|
||||
html: ReturnType<typeof template>;
|
||||
}
|
||||
|
||||
// ── Clean-email rules ────────────────────────────────────────────────
|
||||
// -- Clean-email rules ------------------------------------------------
|
||||
|
||||
type CleanRule = (parts: { local: string; domain: string }) => {
|
||||
local: string;
|
||||
@@ -90,7 +90,7 @@ const DOMAIN_ALIASES: Record<string, string> = {
|
||||
'googlemail.com': 'gmail.com',
|
||||
};
|
||||
|
||||
// ── EmailClient ──────────────────────────────────────────────────────
|
||||
// -- EmailClient ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Unified email client. Handles:
|
||||
@@ -112,7 +112,7 @@ export class EmailClient extends PuterClient {
|
||||
this.compileTemplates();
|
||||
}
|
||||
|
||||
// ── Lifecycle ────────────────────────────────────────────────────
|
||||
// -- Lifecycle ----------------------------------------------------
|
||||
|
||||
override onServerStart(): void {
|
||||
const emailConf = this.config.email;
|
||||
@@ -133,7 +133,7 @@ export class EmailClient extends PuterClient {
|
||||
this.transport = null;
|
||||
}
|
||||
|
||||
// ── Public API: sending ──────────────────────────────────────────
|
||||
// -- Public API: sending ------------------------------------------
|
||||
|
||||
/**
|
||||
* Render a template and send it to `to`.
|
||||
@@ -174,7 +174,7 @@ export class EmailClient extends PuterClient {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Public API: clean / validate ─────────────────────────────────
|
||||
// -- Public API: clean / validate ---------------------------------
|
||||
|
||||
/**
|
||||
* Normalize an email to its canonical form for dedup comparisons.
|
||||
@@ -241,7 +241,7 @@ export class EmailClient extends PuterClient {
|
||||
this.validators.push(fn);
|
||||
}
|
||||
|
||||
// ── Internals ────────────────────────────────────────────────────
|
||||
// -- Internals ----------------------------------------------------
|
||||
|
||||
private defaultFrom(): string {
|
||||
return this.config.email?.from ?? '"Puter" [email protected]';
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
|
||||
import { extensionStore } from '../../extensions';
|
||||
import { PuterClient } from '../types';
|
||||
import { EventListener, EventMap, ListenKey, MatchingEvents } from './types';
|
||||
import {
|
||||
EventListener,
|
||||
EventMap,
|
||||
EventMetadata,
|
||||
ListenKey,
|
||||
MatchingEvents,
|
||||
} from './types';
|
||||
|
||||
export class EventClient extends PuterClient {
|
||||
#eventListeners: Partial<Record<ListenKey, EventListener[]>> = {};
|
||||
@@ -52,7 +58,11 @@ export class EventClient extends PuterClient {
|
||||
* scan of every listener.
|
||||
*
|
||||
*/
|
||||
emit<T extends keyof EventMap>(key: T, data: EventMap[T], meta: unknown) {
|
||||
emit<T extends keyof EventMap>(
|
||||
key: T,
|
||||
data: EventMap[T],
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const matchKey = (
|
||||
@@ -87,7 +97,7 @@ export class EventClient extends PuterClient {
|
||||
async emitAndWait<T extends keyof EventMap>(
|
||||
key: T,
|
||||
data: EventMap[T],
|
||||
meta: unknown,
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
const parts = key.split('.');
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
@@ -129,7 +139,7 @@ export class EventClient extends PuterClient {
|
||||
callback: (
|
||||
key: MatchingEvents<P>,
|
||||
data: EventMap[MatchingEvents<P>],
|
||||
meta: unknown,
|
||||
meta: EventMetadata,
|
||||
) => Promise<void> | void,
|
||||
) {
|
||||
const listeners: EventListener[] =
|
||||
@@ -140,7 +150,7 @@ export class EventClient extends PuterClient {
|
||||
listener: EventListener,
|
||||
key: T,
|
||||
data: EventMap[T],
|
||||
meta: unknown,
|
||||
meta: EventMetadata,
|
||||
) {
|
||||
try {
|
||||
await listener(key, data, meta);
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
* the SocketService fans out to user-scoped channels.
|
||||
*/
|
||||
|
||||
import { Actor } from '../../core';
|
||||
import { FSEntry } from '../../stores/fs/FSEntry';
|
||||
|
||||
// GUI write events spread an entry plus per-event metadata into `response`.
|
||||
// The exact field set varies by emit site (FSController / LegacyFSController /
|
||||
// WebDAVController each project a slightly different shape) so the envelope
|
||||
@@ -96,8 +99,11 @@ export type EventMap = {
|
||||
};
|
||||
'app.from-origin': { origin: string };
|
||||
'app.privateAccess.check': {
|
||||
app: unknown;
|
||||
actor: unknown;
|
||||
appUid: string;
|
||||
userUid: string;
|
||||
requestHost: string;
|
||||
requestPath: string;
|
||||
actor?: Actor;
|
||||
result: {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
@@ -162,8 +168,9 @@ export type EventMap = {
|
||||
sourceObjectKey: string;
|
||||
copyObjectKey: string;
|
||||
};
|
||||
'fs.move.node': { node: unknown; fromPath: string; toPath: string };
|
||||
'fs.remove.node': { node: unknown; entry: unknown; target: unknown };
|
||||
'fs.move.node': { node: FSEntry; fromPath: string; toPath: string };
|
||||
'fs.remove.node': { node: FSEntry; entry: FSEntry; target: FSEntry };
|
||||
'fs.write.file': { node: FSEntry; entry: FSEntry; target: FSEntry };
|
||||
'fs.storage.upload-progress': {
|
||||
upload_tracker: unknown;
|
||||
context: unknown;
|
||||
@@ -199,6 +206,13 @@ export type EventMap = {
|
||||
// ---- Subdomains ----
|
||||
'subdomain.delete': { subdomain: string };
|
||||
'subdomain.update': { subdomain: string };
|
||||
'site.htmlServed': {
|
||||
subdomain: string;
|
||||
entry: unknown;
|
||||
host: string;
|
||||
requestPath: string;
|
||||
mime: string;
|
||||
};
|
||||
|
||||
// ---- Thumbnails ----
|
||||
'thumbnail.read': {
|
||||
@@ -264,8 +278,9 @@ export type MatchingEvents<P extends ListenKey> = P extends `${infer Prefix}.*`
|
||||
? Extract<EventKey, `${Prefix}.${string}`>
|
||||
: P & EventKey;
|
||||
|
||||
export type EventMetadata = { from_outside?: boolean };
|
||||
export type EventListener<K extends EventKey = EventKey> = (
|
||||
key: K,
|
||||
data: EventMap[K],
|
||||
meta: unknown,
|
||||
meta: EventMetadata,
|
||||
) => Promise<void> | void;
|
||||
@@ -196,7 +196,7 @@ export class AppController extends PuterController {
|
||||
},
|
||||
);
|
||||
|
||||
// ── POST /query/app ────────────────────────────────────────
|
||||
// -- POST /query/app ----------------------------------------
|
||||
// Batch marketplace-style lookup by name or UID.
|
||||
//
|
||||
// Access rules: only apps the caller has a legitimate reason to
|
||||
@@ -293,7 +293,7 @@ export class AppController extends PuterController {
|
||||
},
|
||||
);
|
||||
|
||||
// ── GET /app-icon/:app_uid(/:size) ─────────────────────────
|
||||
// -- GET /app-icon/:app_uid(/:size) -------------------------
|
||||
// Serve app icon — data URL decoded inline, HTTP URL redirected.
|
||||
//
|
||||
// ⚠ FLAG: Missing sharp-based resize pipeline; serves the original.
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { EventClient } from '../../clients/event/EventClient.js';
|
||||
@@ -2079,6 +2080,104 @@ describe('AuthController session endpoints', () => {
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('rename-session: 400 when uuid param is missing', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
await expect(
|
||||
controller.handleRenameSession(
|
||||
makeReq({ label: 'x' }, { actor, params: {} }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rename-session: 400 when label is the wrong type', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
await expect(
|
||||
controller.handleRenameSession(
|
||||
makeReq(
|
||||
{ label: 123 as unknown as string },
|
||||
{ actor, params: { uuid: 'whatever' } },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rename-session: 400 when label field is missing entirely', async () => {
|
||||
// Guards against accidental "PATCH with empty body silently clears
|
||||
// the label". Type guard rejects `undefined` before reaching the
|
||||
// service layer.
|
||||
const { actor } = await makeUserAndActor();
|
||||
await expect(
|
||||
controller.handleRenameSession(
|
||||
makeReq({}, { actor, params: { uuid: 'whatever' } }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rename-session: 404 when the uuid belongs to another user', async () => {
|
||||
const { user: u1 } = await makeUserAndActor();
|
||||
const { actor: a2 } = await makeUserAndActor();
|
||||
const sessionRes = await server.services.auth.createSessionToken(
|
||||
u1,
|
||||
{},
|
||||
);
|
||||
const uuid = (sessionRes.session as { uuid: string }).uuid;
|
||||
await expect(
|
||||
controller.handleRenameSession(
|
||||
makeReq(
|
||||
{ label: 'pwned' },
|
||||
{ actor: a2, params: { uuid } },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('rename-session: success updates the row label', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const sessionRes = await server.services.auth.createSessionToken(
|
||||
user,
|
||||
{},
|
||||
);
|
||||
const uuid = (sessionRes.session as { uuid: string }).uuid;
|
||||
const res = makeRes();
|
||||
await controller.handleRenameSession(
|
||||
makeReq({ label: 'My Phone' }, { actor, params: { uuid } }),
|
||||
res,
|
||||
);
|
||||
expect(res.body).toEqual({});
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT `label` FROM `sessions` WHERE `uuid` = ?',
|
||||
[uuid],
|
||||
);
|
||||
expect((rows[0] as { label: string }).label).toBe('My Phone');
|
||||
});
|
||||
|
||||
it('rename-session: accepts null to clear the label', async () => {
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const sessionRes = await server.services.auth.createSessionToken(
|
||||
user,
|
||||
{},
|
||||
);
|
||||
const uuid = (sessionRes.session as { uuid: string }).uuid;
|
||||
// Seed a non-null label so the clear-to-null transition is observable.
|
||||
await server.clients.db.write(
|
||||
'UPDATE `sessions` SET `label` = ? WHERE `uuid` = ?',
|
||||
['something', uuid],
|
||||
);
|
||||
await controller.handleRenameSession(
|
||||
makeReq({ label: null }, { actor, params: { uuid } }),
|
||||
makeRes(),
|
||||
);
|
||||
const rows = await server.clients.db.read(
|
||||
'SELECT `label` FROM `sessions` WHERE `uuid` = ?',
|
||||
[uuid],
|
||||
);
|
||||
expect((rows[0] as { label: string | null }).label).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Dev-app grants/revokes ─────────────────────────────────────────
|
||||
@@ -3405,4 +3504,568 @@ describe('AuthController.handleRevokeSession additional branches', () => {
|
||||
const body = res.body as { sessions: unknown[] };
|
||||
expect(Array.isArray(body.sessions)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to revoke the caller’s OWN current session row (400)', async () => {
|
||||
// Invariant: a self-revoke leaves the client in an ambiguous
|
||||
// identity state because the response can't write fresh auth
|
||||
// state. /logout is the only path that should end the session
|
||||
// you're currently authenticated under.
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const sessionRes = await server.services.auth.createSessionToken(
|
||||
user,
|
||||
{},
|
||||
);
|
||||
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
|
||||
const actorWithSession = {
|
||||
...actor,
|
||||
session: { uid: sessionUid },
|
||||
} as Actor;
|
||||
await expect(
|
||||
controller.handleRevokeSession(
|
||||
makeReq({ uuid: sessionUid }, { actor: actorWithSession }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
});
|
||||
|
||||
it('still allows revoking a DIFFERENT session belonging to the same user', async () => {
|
||||
// Sanity check that the self-revoke guard only blocks the
|
||||
// caller's own uuid — sibling rows must still be revokable
|
||||
// (that's the whole point of manage-sessions).
|
||||
const { user, actor } = await makeUserAndActor();
|
||||
const callerSession = await server.services.auth.createSessionToken(
|
||||
user,
|
||||
{},
|
||||
);
|
||||
const targetSession = await server.services.auth.createSessionToken(
|
||||
user,
|
||||
{},
|
||||
);
|
||||
const actorWithSession = {
|
||||
...actor,
|
||||
session: {
|
||||
uid: (callerSession.session as { uuid: string }).uuid,
|
||||
},
|
||||
} as Actor;
|
||||
const res = makeRes();
|
||||
await controller.handleRevokeSession(
|
||||
makeReq(
|
||||
{ uuid: (targetSession.session as { uuid: string }).uuid },
|
||||
{ actor: actorWithSession },
|
||||
),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { sessions: unknown[] }).sessions).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── handleMigrateToken ──────────────────────────────────────────────
|
||||
|
||||
describe('AuthController.handleMigrateToken', () => {
|
||||
const TEST_ORIGIN = 'https://migrate.test.local';
|
||||
|
||||
// PuterServer keeps config in a private field (#config), so we go
|
||||
// through the controller — IController stores it as `protected
|
||||
// config` which TS marks but JS doesn't enforce, and the controller
|
||||
// is the actual consumer of #isMigrateTokenOriginAllowed anyway.
|
||||
const controllerConfig = () =>
|
||||
(controller as { config: Record<string, unknown> }).config;
|
||||
|
||||
// Mints a v1-shaped JWT signed under the test server's legacy
|
||||
// secret. The body matches what migrateLegacyToken expects per
|
||||
// `decoded.type`.
|
||||
const mintV1Token = (payload: Record<string, unknown>): string => {
|
||||
const legacy = controllerConfig().jwt_secret as string | undefined;
|
||||
if (!legacy) throw new Error('test config missing jwt_secret');
|
||||
return jwt.sign(payload, legacy);
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
// Make the origin allow-check pass for these tests. We mutate
|
||||
// the live config because setupTestServer is shared across the
|
||||
// file; the original value is undefined (default config has no
|
||||
// `origin`) so we don't need to restore.
|
||||
controllerConfig().origin = TEST_ORIGIN;
|
||||
});
|
||||
|
||||
it('rejects when the Origin header is missing', async () => {
|
||||
await expect(
|
||||
controller.handleMigrateToken(makeReq({}), makeRes()),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('rejects when the Origin header is not in config.origin or the allowlist', async () => {
|
||||
const { user } = await makeUserAndActor();
|
||||
const v1 = mintV1Token({
|
||||
type: 'access-token',
|
||||
token_uid: uuidv4(),
|
||||
user_uid: user.uuid,
|
||||
});
|
||||
await expect(
|
||||
controller.handleMigrateToken(
|
||||
makeReq(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
origin: 'https://not-allowed.example',
|
||||
authorization: `Bearer ${v1}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('normalizes trailing slash on the request Origin (B4)', async () => {
|
||||
// The Origin header per spec doesn't carry a trailing slash, but
|
||||
// a misconfigured proxy or a deployment with config.origin
|
||||
// ending in `/` would otherwise force every call to reject.
|
||||
const { user } = await makeUserAndActor();
|
||||
const v1 = mintV1Token({
|
||||
type: 'access-token',
|
||||
token_uid: uuidv4(),
|
||||
user_uid: user.uuid,
|
||||
});
|
||||
const res = makeRes();
|
||||
await controller.handleMigrateToken(
|
||||
makeReq(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
origin: `${TEST_ORIGIN}/`, // trailing slash
|
||||
authorization: `Bearer ${v1}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { kind: string }).kind).toBe('access_token');
|
||||
});
|
||||
|
||||
it('normalizes case on the request Origin (B4)', async () => {
|
||||
const { user } = await makeUserAndActor();
|
||||
const v1 = mintV1Token({
|
||||
type: 'access-token',
|
||||
token_uid: uuidv4(),
|
||||
user_uid: user.uuid,
|
||||
});
|
||||
const res = makeRes();
|
||||
await controller.handleMigrateToken(
|
||||
makeReq(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
origin: TEST_ORIGIN.toUpperCase(),
|
||||
authorization: `Bearer ${v1}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { kind: string }).kind).toBe('access_token');
|
||||
});
|
||||
|
||||
it('returns 409 reauth_required for v1 web/session tokens', async () => {
|
||||
// Web tokens never migrate silently — they always go through
|
||||
// the interactive reauth flow. The body code is what puter.js /
|
||||
// GUI key on; the 409 status is what tells SDK code "this
|
||||
// isn't a generic auth failure, route through reauth".
|
||||
const { user } = await makeUserAndActor();
|
||||
const v1 = mintV1Token({
|
||||
type: 'session',
|
||||
user_uid: user.uuid,
|
||||
uuid: uuidv4(),
|
||||
});
|
||||
await expect(
|
||||
controller.handleMigrateToken(
|
||||
makeReq(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
origin: TEST_ORIGIN,
|
||||
authorization: `Bearer ${v1}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
code: 'reauth_required',
|
||||
});
|
||||
});
|
||||
|
||||
it('does NOT set the puter_token_v2 cookie when migrating an access token (B3)', async () => {
|
||||
// Access tokens are programmatic — they ride in Authorization
|
||||
// headers, not browser cookies. Setting a cookie here would
|
||||
// confuse cookie-only middleware downstream.
|
||||
const { user } = await makeUserAndActor();
|
||||
const v1 = mintV1Token({
|
||||
type: 'access-token',
|
||||
token_uid: uuidv4(),
|
||||
user_uid: user.uuid,
|
||||
});
|
||||
const res = makeRes();
|
||||
await controller.handleMigrateToken(
|
||||
makeReq(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
origin: TEST_ORIGIN,
|
||||
authorization: `Bearer ${v1}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
res,
|
||||
);
|
||||
expect(res.cookies.puter_token_v2).toBeUndefined();
|
||||
expect((res.body as { kind: string }).kind).toBe('access_token');
|
||||
expect((res.body as { token: string }).token).toBeTruthy();
|
||||
});
|
||||
|
||||
it('sets the puter_token_v2 cookie when migrating an app-under-user token (B3)', async () => {
|
||||
// App tokens DO get a cookie companion — the app runs inside an
|
||||
// iframe in the GUI, and the GUI's cookie-only middleware
|
||||
// authenticates subsequent calls from the iframe via the cookie
|
||||
// rather than the client having to plumb Authorization through
|
||||
// every request.
|
||||
const { user } = await makeUserAndActor();
|
||||
const appUid = `app-${uuidv4()}`;
|
||||
const v1 = mintV1Token({
|
||||
type: 'app-under-user',
|
||||
user_uid: user.uuid,
|
||||
app_uid: appUid,
|
||||
});
|
||||
const res = makeRes();
|
||||
await controller.handleMigrateToken(
|
||||
makeReq(
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
origin: TEST_ORIGIN,
|
||||
authorization: `Bearer ${v1}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
res,
|
||||
);
|
||||
expect((res.body as { kind: string }).kind).toBe('app');
|
||||
const cookie = res.cookies.puter_token_v2;
|
||||
expect(cookie).toBeDefined();
|
||||
expect(cookie.value).toBe((res.body as { token: string }).token);
|
||||
expect(cookie.opts?.httpOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// -- auth_id preservation on forced re-login --
|
||||
|
||||
describe('AuthController auth_id preservation on reauth', () => {
|
||||
const password = 'correct-horse-battery';
|
||||
const mintReauth = (uuid: string): string =>
|
||||
server.services.auth.signReauthToken(uuid);
|
||||
|
||||
it('handleLogin with matching reauth_token completes login as the same user', async () => {
|
||||
const u = `aid_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const ip = `127.0.${Math.floor(Math.random() * 200)}.1`;
|
||||
await controller.handleSignup(
|
||||
makeReq(
|
||||
{ username: u, email: `${u}@test.local`, password },
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
);
|
||||
const seeded = await server.stores.user.getByUsername(u);
|
||||
|
||||
const res = makeRes();
|
||||
await controller.handleLogin(
|
||||
makeReq(
|
||||
{
|
||||
username: u,
|
||||
password,
|
||||
reauth_token: mintReauth(seeded!.uuid),
|
||||
},
|
||||
{ ip },
|
||||
),
|
||||
res,
|
||||
);
|
||||
expect(isCompleteLoginResponse(res.body)).toBe(true);
|
||||
expect((res.body as { user: { uuid: string } }).user.uuid).toBe(
|
||||
seeded!.uuid,
|
||||
);
|
||||
});
|
||||
|
||||
it('handleLogin with mismatched reauth_token is rejected 409', async () => {
|
||||
const a = `aida_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const b = `aidb_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const ip = `127.0.${Math.floor(Math.random() * 200)}.2`;
|
||||
await controller.handleSignup(
|
||||
makeReq(
|
||||
{ username: a, email: `${a}@test.local`, password },
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
);
|
||||
await controller.handleSignup(
|
||||
makeReq(
|
||||
{ username: b, email: `${b}@test.local`, password },
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
);
|
||||
const userB = await server.stores.user.getByUsername(b);
|
||||
|
||||
await expect(
|
||||
controller.handleLogin(
|
||||
makeReq(
|
||||
{
|
||||
username: a,
|
||||
password,
|
||||
reauth_token: mintReauth(userB!.uuid),
|
||||
},
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 409,
|
||||
fields: { code: 'auth_id_mismatch' },
|
||||
});
|
||||
});
|
||||
|
||||
it('handleLogin with reauth_token for an unknown user returns 404', async () => {
|
||||
const u = `aidu_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const ip = `127.0.${Math.floor(Math.random() * 200)}.3`;
|
||||
await controller.handleSignup(
|
||||
makeReq(
|
||||
{ username: u, email: `${u}@test.local`, password },
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
);
|
||||
await expect(
|
||||
controller.handleLogin(
|
||||
makeReq(
|
||||
{
|
||||
username: u,
|
||||
password,
|
||||
reauth_token: mintReauth(uuidv4()),
|
||||
},
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('handleLogin with a non-string reauth_token returns 400', async () => {
|
||||
const u = `aidb_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const ip = `127.0.${Math.floor(Math.random() * 200)}.4`;
|
||||
await controller.handleSignup(
|
||||
makeReq(
|
||||
{ username: u, email: `${u}@test.local`, password },
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
);
|
||||
await expect(
|
||||
controller.handleLogin(
|
||||
makeReq({ username: u, password, reauth_token: 42 }, { ip }),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('handleLogin with a forged/garbage reauth_token returns 401', async () => {
|
||||
const u = `aidf_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const ip = `127.0.${Math.floor(Math.random() * 200)}.9`;
|
||||
await controller.handleSignup(
|
||||
makeReq(
|
||||
{ username: u, email: `${u}@test.local`, password },
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
);
|
||||
await expect(
|
||||
controller.handleLogin(
|
||||
makeReq(
|
||||
{ username: u, password, reauth_token: 'not-a-jwt' },
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
it('handleLogin OTP branch echoes auth_id into the OTP JWT', async () => {
|
||||
const u = `aidotp_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const ip = `127.0.${Math.floor(Math.random() * 200)}.5`;
|
||||
await controller.handleSignup(
|
||||
makeReq(
|
||||
{ username: u, email: `${u}@test.local`, password },
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
);
|
||||
const seeded = await server.stores.user.getByUsername(u);
|
||||
await server.stores.user.update(seeded!.id, {
|
||||
otp_enabled: 1,
|
||||
otp_secret: 'TESTSECRETBASE32',
|
||||
});
|
||||
|
||||
const res = makeRes();
|
||||
await controller.handleLogin(
|
||||
makeReq(
|
||||
{
|
||||
username: u,
|
||||
password,
|
||||
reauth_token: mintReauth(seeded!.uuid),
|
||||
},
|
||||
{ ip },
|
||||
),
|
||||
res,
|
||||
);
|
||||
expect(res.statusCode).toBe(202);
|
||||
const body = res.body as { otp_jwt_token: string };
|
||||
const decoded = server.services.token.verify(
|
||||
'otp',
|
||||
body.otp_jwt_token,
|
||||
) as {
|
||||
user_uid: string;
|
||||
auth_id?: string;
|
||||
};
|
||||
expect(decoded.auth_id).toBe(seeded!.uuid);
|
||||
});
|
||||
|
||||
it('handleSignup is_temp + matching reauth_token returns the SAME temp user', async () => {
|
||||
const tempRes1 = makeRes();
|
||||
const ip = `127.0.${Math.floor(Math.random() * 200)}.6`;
|
||||
await controller.handleSignup(
|
||||
makeReq({ is_temp: true }, { ip }),
|
||||
tempRes1,
|
||||
);
|
||||
const body1 = tempRes1.body as { user: { uuid: string } };
|
||||
const tempUuid = body1.user.uuid;
|
||||
const tempUser1 = await server.stores.user.getByUuid(tempUuid);
|
||||
expect(tempUser1).toBeTruthy();
|
||||
const markerId = tempUser1!.id;
|
||||
|
||||
const tempRes2 = makeRes();
|
||||
await controller.handleSignup(
|
||||
makeReq(
|
||||
{ is_temp: true, reauth_token: mintReauth(tempUuid) },
|
||||
{ ip },
|
||||
),
|
||||
tempRes2,
|
||||
);
|
||||
expect(isCompleteLoginResponse(tempRes2.body)).toBe(true);
|
||||
const body2 = tempRes2.body as {
|
||||
user: { uuid: string; is_temp: boolean };
|
||||
};
|
||||
expect(body2.user.uuid).toBe(tempUuid);
|
||||
expect(body2.user.is_temp).toBe(true);
|
||||
|
||||
const tempUser2 = await server.stores.user.getByUuid(tempUuid);
|
||||
expect(tempUser2!.id).toBe(markerId);
|
||||
});
|
||||
|
||||
it('handleSignup is_temp + reauth_token pointing at a permanent user is rejected', async () => {
|
||||
const u = `aidperm_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const ip = `127.0.${Math.floor(Math.random() * 200)}.7`;
|
||||
await controller.handleSignup(
|
||||
makeReq(
|
||||
{ username: u, email: `${u}@test.local`, password },
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
);
|
||||
const seeded = await server.stores.user.getByUsername(u);
|
||||
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
makeReq(
|
||||
{
|
||||
is_temp: true,
|
||||
reauth_token: mintReauth(seeded!.uuid),
|
||||
},
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('handleSignup is_temp + reauth_token for an unknown user returns 404', async () => {
|
||||
const ip = `127.0.${Math.floor(Math.random() * 200)}.8`;
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
makeReq(
|
||||
{
|
||||
is_temp: true,
|
||||
reauth_token: mintReauth(uuidv4()),
|
||||
},
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('handleSignup is_temp rejects a forged reauth_token (401)', async () => {
|
||||
const ip = `127.0.${Math.floor(Math.random() * 200)}.10`;
|
||||
await expect(
|
||||
controller.handleSignup(
|
||||
makeReq(
|
||||
{ is_temp: true, reauth_token: 'not-a-jwt' },
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 401 });
|
||||
});
|
||||
|
||||
it('rate-limits reauth_token login attempts per IP', async () => {
|
||||
const ip = `10.99.${Math.floor(Math.random() * 200)}.${Math.floor(Math.random() * 200)}`;
|
||||
const u = `aidrl_${Math.random().toString(36).slice(2, 10)}`;
|
||||
await controller.handleSignup(
|
||||
makeReq(
|
||||
{ username: u, email: `${u}@test.local`, password },
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
);
|
||||
const seeded = await server.stores.user.getByUsername(u);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const res = makeRes();
|
||||
await controller.handleLogin(
|
||||
makeReq(
|
||||
{
|
||||
username: u,
|
||||
password,
|
||||
reauth_token: mintReauth(seeded!.uuid),
|
||||
},
|
||||
{ ip },
|
||||
),
|
||||
res,
|
||||
);
|
||||
expect(isCompleteLoginResponse(res.body)).toBe(true);
|
||||
}
|
||||
await expect(
|
||||
controller.handleLogin(
|
||||
makeReq(
|
||||
{
|
||||
username: u,
|
||||
password,
|
||||
reauth_token: mintReauth(seeded!.uuid),
|
||||
},
|
||||
{ ip },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 429 });
|
||||
});
|
||||
});
|
||||
@@ -18,15 +18,19 @@
|
||||
*/
|
||||
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import validator from 'validator';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { antiCsrf } from '../../core/http/middleware/antiCsrf.js';
|
||||
import { generateCaptcha } from '../../core/http/middleware/captcha.js';
|
||||
import { createUserProtectedGate } from '../../core/http/middleware/userProtected.js';
|
||||
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
|
||||
import {
|
||||
createUserProtectedGate,
|
||||
createWebSessionActorGate,
|
||||
} from '../../core/http/middleware/userProtected.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import {
|
||||
ROUTES_METADATA_KEY,
|
||||
@@ -41,8 +45,8 @@ import {
|
||||
createSecret as otpCreateSecret,
|
||||
verify as verifyOtp,
|
||||
} from '../../services/auth/OTPUtil.js';
|
||||
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
|
||||
import { generate_identifier } from '../../util/identifier.js';
|
||||
import { getTaskbarItems } from '../../util/taskbarItems.js';
|
||||
import {
|
||||
@@ -91,10 +95,9 @@ const RESERVED_USERNAMES = new Set([
|
||||
*/
|
||||
@Controller('')
|
||||
export class AuthController extends PuterController {
|
||||
// ── Login ───────────────────────────────────────────────────────
|
||||
// -- Login -------------------------------------------------------
|
||||
|
||||
@Post('/login', {
|
||||
subdomain: ['api', ''],
|
||||
captcha: true,
|
||||
rateLimit: { scope: 'login', limit: 10, window: 15 * 60_000 },
|
||||
})
|
||||
@@ -159,23 +162,34 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const passwordMatch = await bcrypt.compare(password, user.password);
|
||||
const passwordMatch = await bcrypt.compare(
|
||||
password,
|
||||
user.password as string,
|
||||
);
|
||||
if (!passwordMatch) {
|
||||
throw new HttpError(401, 'Incorrect password.', {
|
||||
legacyCode: 'password_mismatch',
|
||||
});
|
||||
}
|
||||
|
||||
// OTP branching — if 2FA enabled, return a short-lived OTP JWT
|
||||
const reauthAuthId = this.#extractAuthIdFromReauthToken(
|
||||
req.body.reauth_token,
|
||||
);
|
||||
await this.#enforceAuthIdMatch(req, user, reauthAuthId);
|
||||
|
||||
// OTP branching — if 2FA enabled, return a short-lived OTP JWT.
|
||||
// Re-bind the verified `auth_id` into the JWT so the follow-up
|
||||
// OTP/recovery call can re-enforce the match without re-trusting
|
||||
// a free-form claim from the client.
|
||||
if (user.otp_enabled) {
|
||||
const otp_jwt_token = this.services.token.sign(
|
||||
'otp',
|
||||
{
|
||||
user_uid: user.uuid,
|
||||
purpose: 'otp-login',
|
||||
},
|
||||
{ expiresIn: '5m' },
|
||||
);
|
||||
const otpClaims: Record<string, unknown> = {
|
||||
user_uid: user.uuid,
|
||||
purpose: 'otp-login',
|
||||
};
|
||||
if (reauthAuthId) otpClaims.auth_id = reauthAuthId;
|
||||
const otp_jwt_token = this.services.token.sign('otp', otpClaims, {
|
||||
expiresIn: '5m',
|
||||
});
|
||||
|
||||
res.status(202).json({
|
||||
proceed: true,
|
||||
@@ -188,10 +202,9 @@ export class AuthController extends PuterController {
|
||||
await this.#completeLogin(req, res, user);
|
||||
}
|
||||
|
||||
// ── Login: OTP verification ─────────────────────────────────────
|
||||
// -- Login: OTP verification -------------------------------------
|
||||
|
||||
@Post('/login/otp', {
|
||||
subdomain: ['api', ''],
|
||||
captcha: true,
|
||||
rateLimit: {
|
||||
scope: 'login-otp',
|
||||
@@ -212,7 +225,11 @@ export class AuthController extends PuterController {
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = this.services.token.verify('otp', token);
|
||||
decoded = this.services.token.verify<{
|
||||
user_uid: string;
|
||||
purpose: string;
|
||||
auth_id?: string;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid token.', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -240,13 +257,14 @@ export class AuthController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
|
||||
|
||||
await this.#completeLogin(req, res, user);
|
||||
}
|
||||
|
||||
// ── Login: recovery code ────────────────────────────────────────
|
||||
// -- Login: recovery code ----------------------------------------
|
||||
|
||||
@Post('/login/recovery-code', {
|
||||
subdomain: ['api', ''],
|
||||
captcha: true,
|
||||
rateLimit: {
|
||||
scope: 'login-recovery',
|
||||
@@ -267,7 +285,11 @@ export class AuthController extends PuterController {
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = this.services.token.verify('otp', token);
|
||||
decoded = this.services.token.verify<{
|
||||
user_uid: string;
|
||||
purpose: string;
|
||||
auth_id?: string;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid token.', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -291,7 +313,7 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
const hashed = hashRecoveryCode(code);
|
||||
const codes = (user.otp_recovery_codes || '')
|
||||
const codes = ((user.otp_recovery_codes as string) || '')
|
||||
.split(',')
|
||||
.filter(Boolean);
|
||||
const idx = codes.indexOf(hashed);
|
||||
@@ -308,13 +330,14 @@ export class AuthController extends PuterController {
|
||||
);
|
||||
await this.stores.user.invalidateById(user.id);
|
||||
|
||||
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
|
||||
|
||||
await this.#completeLogin(req, res, user);
|
||||
}
|
||||
|
||||
// ── Signup ──────────────────────────────────────────────────────
|
||||
// -- Signup ------------------------------------------------------
|
||||
|
||||
@Post('/signup', {
|
||||
subdomain: ['api', ''],
|
||||
captcha: true,
|
||||
rateLimit: { scope: 'signup', limit: 10, window: 15 * 60_000 },
|
||||
})
|
||||
@@ -332,6 +355,49 @@ export class AuthController extends PuterController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Temp-user reauth short-circuit: when an existing temp user is
|
||||
// forced through the reauth flow, the GUI re-submits /signup with
|
||||
// is_temp=true plus the server-signed reauth_token from the 401.
|
||||
// Verifying the token (not a raw auth_id) means a leaked uuid alone
|
||||
// can't re-attach a session to someone else's temp account.
|
||||
// Permanent users must go through /login (they have credentials),
|
||||
// so we reject that path here.
|
||||
if (
|
||||
is_temp &&
|
||||
body.reauth_token !== undefined &&
|
||||
body.reauth_token !== null
|
||||
) {
|
||||
const reauthAuthId = this.#extractAuthIdFromReauthToken(
|
||||
body.reauth_token,
|
||||
);
|
||||
if (!reauthAuthId) {
|
||||
throw new HttpError(400, 'Invalid `reauth_token`.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
await this.#checkAuthIdRateLimit(req);
|
||||
const existing = await this.stores.user.getByUuid(reauthAuthId);
|
||||
if (!existing) {
|
||||
throw new HttpError(404, 'auth_id not found.', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
}
|
||||
if (existing.password !== null || existing.email !== null) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'auth_id resolves to a non-temp account; use /login instead.',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
if (existing.suspended) {
|
||||
throw new HttpError(401, 'This account is suspended.', {
|
||||
legacyCode: 'account_suspended',
|
||||
});
|
||||
}
|
||||
await this.#completeLogin(req, res, existing);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill in temp user defaults
|
||||
if (is_temp) {
|
||||
body.username ??= await this.#generateRandomUsername();
|
||||
@@ -455,17 +521,7 @@ export class AuthController extends PuterController {
|
||||
// - skip temp-user creation via `event.no_temp_user = true`
|
||||
// Listeners run sequentially so multi-signal checks (rate limit +
|
||||
// IP reputation + domain reputation) can short-circuit cleanly.
|
||||
const validateEvent: {
|
||||
req: Request;
|
||||
data: Record<string, unknown>;
|
||||
ip: string | null;
|
||||
email: string | undefined;
|
||||
allow: boolean;
|
||||
no_temp_user: boolean;
|
||||
requires_email_confirmation: boolean;
|
||||
message: string | null;
|
||||
code: string | null;
|
||||
} = {
|
||||
const validateEvent = {
|
||||
req,
|
||||
data: body,
|
||||
ip: ((req.headers?.['x-forwarded-for'] as string | undefined) ||
|
||||
@@ -480,6 +536,7 @@ export class AuthController extends PuterController {
|
||||
requires_email_confirmation: false,
|
||||
message: null,
|
||||
code: null,
|
||||
user_agent: req?.headers?.['user-agent'] ?? null,
|
||||
};
|
||||
try {
|
||||
await this.clients.event?.emitAndWait(
|
||||
@@ -532,7 +589,7 @@ export class AuthController extends PuterController {
|
||||
|
||||
let user;
|
||||
if (pseudo_user) {
|
||||
// ── Pseudo-user claim (convert the placeholder row) ──
|
||||
// -- Pseudo-user claim (convert the placeholder row) --
|
||||
await this.stores.user.update(pseudo_user.id, {
|
||||
username: body.username,
|
||||
password: password_hash,
|
||||
@@ -573,7 +630,7 @@ export class AuthController extends PuterController {
|
||||
force: true,
|
||||
});
|
||||
} else {
|
||||
// ── New user ────────────────────────────────────────
|
||||
// -- New user ----------------------------------------
|
||||
const clientIp = req.ip || req.socket?.remoteAddress || null;
|
||||
const proxyIpChain = req.headers['x-forwarded-for'];
|
||||
|
||||
@@ -618,7 +675,7 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provision FS home + default folders ─────────────────
|
||||
// -- Provision FS home + default folders -----------------
|
||||
// Idempotent — skips if `user.trash_uuid` is already set (pseudo
|
||||
// users who went through a prior signup won't double-create).
|
||||
try {
|
||||
@@ -631,7 +688,7 @@ export class AuthController extends PuterController {
|
||||
console.warn('[signup] generateDefaultFsentries failed:', e);
|
||||
}
|
||||
|
||||
// ── Send email confirmation ─────────────────────────────
|
||||
// -- Send email confirmation -----------------------------
|
||||
if (
|
||||
!is_temp &&
|
||||
user!.requires_email_confirmation &&
|
||||
@@ -704,17 +761,20 @@ export class AuthController extends PuterController {
|
||||
await this.#completeLogin(req, res, user!);
|
||||
}
|
||||
|
||||
// ── Logout ──────────────────────────────────────────────────────
|
||||
// -- Logout ------------------------------------------------------
|
||||
|
||||
@Post('/logout', {
|
||||
subdomain: ['api', ''],
|
||||
requireAuth: true,
|
||||
allowUnconfirmed: true,
|
||||
antiCsrf: true,
|
||||
})
|
||||
async handleLogout(req: Request, res: Response): Promise<void> {
|
||||
// Clear the session cookie
|
||||
res.clearCookie(this.config.cookie_name);
|
||||
// Clear the session cookie + the v2 migrate-token companion
|
||||
// cookie (set after `/auth/migrate-token` for app-under-user
|
||||
// iframes). authProbe reads `puter_token_v2` as a fallback, so
|
||||
// a stale value would re-authenticate the next request.
|
||||
res.clearCookie(this.config.cookie_name ?? 'puter_token');
|
||||
res.clearCookie('puter_token_v2');
|
||||
|
||||
// Remove the session (fire-and-forget)
|
||||
if (req.token) {
|
||||
@@ -725,7 +785,9 @@ export class AuthController extends PuterController {
|
||||
// same path as /user-protected/delete-own-user — so we don't
|
||||
// orphan fsentries/sessions/permissions.
|
||||
if (req.actor?.user && !req.actor.user.email) {
|
||||
const user = await this.stores.user.getByUuid(req.actor.user.uuid);
|
||||
const user = await this.stores.user.getByUuid(
|
||||
req.actor.user.uuid as string,
|
||||
);
|
||||
if (user && user.password === null && user.email === null) {
|
||||
this.#cascadeDeleteUser(user.id).catch((e) => {
|
||||
console.warn('[logout] temp-user cleanup failed:', e);
|
||||
@@ -736,7 +798,7 @@ export class AuthController extends PuterController {
|
||||
res.send('logged out');
|
||||
}
|
||||
|
||||
// ── Email confirmation ──────────────────────────────────────────
|
||||
// -- Email confirmation ------------------------------------------
|
||||
|
||||
@Post('/send-confirm-email', {
|
||||
subdomain: ['api', ''],
|
||||
@@ -837,6 +899,16 @@ export class AuthController extends PuterController {
|
||||
email_confirm_token: null,
|
||||
});
|
||||
|
||||
// Revoke confirmation from any other accounts sharing this
|
||||
// email so only the account whose owner just proved inbox
|
||||
// access retains verified status.
|
||||
const canonical = cleanEmail(user.email!);
|
||||
await this.stores.user.unconfirmOthersByEmail(
|
||||
user.id,
|
||||
user.email!,
|
||||
canonical,
|
||||
);
|
||||
|
||||
await promoteToVerifiedGroup(this.stores.group, this.config, user);
|
||||
|
||||
try {
|
||||
@@ -856,7 +928,7 @@ export class AuthController extends PuterController {
|
||||
res.json({ email_confirmed: true, original_client_socket_id });
|
||||
}
|
||||
|
||||
// ── Password recovery ───────────────────────────────────────────
|
||||
// -- Password recovery -------------------------------------------
|
||||
|
||||
@Post('/send-pass-recovery-email', {
|
||||
subdomain: ['api', ''],
|
||||
@@ -948,7 +1020,12 @@ export class AuthController extends PuterController {
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = this.services.token.verify('otp', token);
|
||||
decoded = this.services.token.verify<{
|
||||
user_uid: string;
|
||||
email: string;
|
||||
exp: number;
|
||||
purpose: string;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid or expired token.', {
|
||||
legacyCode: 'token_expired' as never,
|
||||
@@ -960,7 +1037,7 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const user = await this.stores.user.getByUuid(decoded.user_uid);
|
||||
const user = await this.stores.user.getByUuid(decoded?.user_uid);
|
||||
if (!user || user.email !== decoded.email) {
|
||||
throw new HttpError(400, 'Token is no longer valid.', {
|
||||
legacyCode: 'bad_request',
|
||||
@@ -972,7 +1049,7 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
const exp = decoded.exp;
|
||||
const exp = decoded.exp as number;
|
||||
const time_remaining = exp
|
||||
? Math.max(0, exp - Math.floor(Date.now() / 1000))
|
||||
: 0;
|
||||
@@ -1005,7 +1082,12 @@ export class AuthController extends PuterController {
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = this.services.token.verify('otp', token);
|
||||
decoded = this.services.token.verify<{
|
||||
user_uid: string;
|
||||
email: string;
|
||||
token: string;
|
||||
purpose: string;
|
||||
}>('otp', token);
|
||||
} catch {
|
||||
throw new HttpError(400, 'Invalid or expired token.', {
|
||||
legacyCode: 'token_expired' as never,
|
||||
@@ -1050,7 +1132,7 @@ export class AuthController extends PuterController {
|
||||
res.send('Password successfully updated.');
|
||||
}
|
||||
|
||||
// ── User-protected mutations ────────────────────────────────────
|
||||
// -- User-protected mutations ------------------------------------
|
||||
//
|
||||
// The five `/user-protected/*` and `/user-protected/delete-own-user`
|
||||
// routes are wired in the `registerRoutes` override below because
|
||||
@@ -1348,7 +1430,7 @@ export class AuthController extends PuterController {
|
||||
res.send('Email changed successfully. You may close this window.');
|
||||
}
|
||||
|
||||
// ── Save account (convert temp user to permanent) ───────────────
|
||||
// -- Save account (convert temp user to permanent) ---------------
|
||||
|
||||
@Post('/save_account', {
|
||||
subdomain: ['api', ''],
|
||||
@@ -1538,7 +1620,7 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Captcha generation ───────────────────────────────────────────
|
||||
// -- Captcha generation -------------------------------------------
|
||||
|
||||
@Get('/api/captcha/generate', { subdomain: '*' })
|
||||
async handleCaptchaGenerate(_req: Request, res: Response): Promise<void> {
|
||||
@@ -1549,10 +1631,9 @@ export class AuthController extends PuterController {
|
||||
res.json({ token, image });
|
||||
}
|
||||
|
||||
// ── Anti-CSRF token generation ──────────────────────────────────
|
||||
// -- Anti-CSRF token generation ----------------------------------
|
||||
|
||||
@Get('/get-anticsrf-token', {
|
||||
subdomain: '',
|
||||
requireAuth: true,
|
||||
allowUnconfirmed: true,
|
||||
})
|
||||
@@ -1566,7 +1647,7 @@ export class AuthController extends PuterController {
|
||||
res.json({ token });
|
||||
}
|
||||
|
||||
// ── Permission grants ───────────────────────────────────────────
|
||||
// -- Permission grants -------------------------------------------
|
||||
|
||||
@Post('/auth/grant-user-user', {
|
||||
subdomain: 'api',
|
||||
@@ -1638,7 +1719,7 @@ export class AuthController extends PuterController {
|
||||
res.json({});
|
||||
}
|
||||
|
||||
// ── Permission revokes ──────────────────────────────────────────
|
||||
// -- Permission revokes ------------------------------------------
|
||||
|
||||
@Post('/auth/revoke-user-user', {
|
||||
subdomain: 'api',
|
||||
@@ -1710,7 +1791,7 @@ export class AuthController extends PuterController {
|
||||
res.json({});
|
||||
}
|
||||
|
||||
// ── Permission checks ───────────────────────────────────────────
|
||||
// -- Permission checks -------------------------------------------
|
||||
|
||||
@Post('/auth/check-permissions', { subdomain: 'api', requireAuth: true })
|
||||
async handleCheckPermissions(req: Request, res: Response): Promise<void> {
|
||||
@@ -1738,7 +1819,7 @@ export class AuthController extends PuterController {
|
||||
res.json({ permissions: result });
|
||||
}
|
||||
|
||||
// ── Session management ──────────────────────────────────────────
|
||||
// -- Session management ------------------------------------------
|
||||
|
||||
@Get('/auth/list-sessions', { subdomain: 'api', requireUserActor: true })
|
||||
async handleListSessions(req: Request, res: Response): Promise<void> {
|
||||
@@ -1746,12 +1827,10 @@ export class AuthController extends PuterController {
|
||||
res.json(sessions);
|
||||
}
|
||||
|
||||
@Post('/auth/revoke-session', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
antiCsrf: true,
|
||||
})
|
||||
// Wired imperatively in `registerRoutes` so the cookie-only gate
|
||||
// (built from `this.config`) can be composed in. Cookie-only is
|
||||
// mandatory: an access token must not be able to revoke its own
|
||||
// issuing web session.
|
||||
async handleRevokeSession(req: Request, res: Response): Promise<void> {
|
||||
const { uuid } = req.body;
|
||||
if (!uuid || typeof uuid !== 'string') {
|
||||
@@ -1759,7 +1838,28 @@ export class AuthController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
// The caller's own session row must go through /logout, not a
|
||||
// self-revoke — otherwise the response can't write fresh auth
|
||||
// state and the client ends up with an ambiguous post-revoke
|
||||
// identity. /auth/revoke-all-sessions still supports a separate
|
||||
// `include_current` opt-in for the nuclear case.
|
||||
if (uuid === req.actor!.session?.uid) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'Cannot revoke your current session — use /logout instead',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
// `getByUuid` returns null when the row is missing, already
|
||||
// soft-revoked, or past `expires_at` — surface as 404 so a stale
|
||||
// manage-sessions UI doesn't 500 when it clicks revoke on a row
|
||||
// that already went away.
|
||||
const session = await this.stores.session.getByUuid(uuid);
|
||||
if (!session) {
|
||||
throw new HttpError(404, 'Session not found', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
}
|
||||
if (session.user_id !== req.actor!.user.id) {
|
||||
throw new HttpError(403, 'Can only revoke your own sessions', {
|
||||
legacyCode: 'unauthorized',
|
||||
@@ -1770,7 +1870,38 @@ export class AuthController extends PuterController {
|
||||
res.json({ sessions });
|
||||
}
|
||||
|
||||
// ── Dev app permissions ─────────────────────────────────────────
|
||||
async handleRevokeAllSessions(req: Request, res: Response): Promise<void> {
|
||||
const { include_current, include_apps } = req.body ?? {};
|
||||
await this.services.auth.revokeAllSessions(req.actor!, {
|
||||
includeCurrent: !!include_current,
|
||||
includeApps: !!include_apps,
|
||||
});
|
||||
const sessions = await this.services.auth.listSessions(req.actor!);
|
||||
res.json({ sessions });
|
||||
}
|
||||
|
||||
async handleRenameSession(req: Request, res: Response): Promise<void> {
|
||||
const uuid = req.params.uuid;
|
||||
const { label } = (req.body ?? {}) as { label?: unknown };
|
||||
if (!uuid || typeof uuid !== 'string') {
|
||||
throw new HttpError(400, 'Missing or invalid `uuid`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
if (label !== null && typeof label !== 'string') {
|
||||
throw new HttpError(400, '`label` must be a string or null', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
await this.services.auth.setSessionLabel(
|
||||
req.actor!,
|
||||
uuid,
|
||||
label ?? null,
|
||||
);
|
||||
res.json({});
|
||||
}
|
||||
|
||||
// -- Dev app permissions -----------------------------------------
|
||||
|
||||
@Post('/auth/grant-dev-app', { subdomain: 'api', requireUserActor: true })
|
||||
async handleGrantDevApp(req: Request, res: Response): Promise<void> {
|
||||
@@ -1825,7 +1956,7 @@ export class AuthController extends PuterController {
|
||||
res.json({});
|
||||
}
|
||||
|
||||
// ── Permission listing ──────────────────────────────────────────
|
||||
// -- Permission listing ------------------------------------------
|
||||
|
||||
@Get('/auth/list-permissions', {
|
||||
subdomain: 'api',
|
||||
@@ -1887,7 +2018,7 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
// ── App origin resolution ───────────────────────────────────────
|
||||
// -- App origin resolution ---------------------------------------
|
||||
|
||||
@Post('/auth/app-uid-from-origin', { subdomain: 'api', requireAuth: true })
|
||||
async handleAppUidFromOrigin(req: Request, res: Response): Promise<void> {
|
||||
@@ -1900,7 +2031,7 @@ export class AuthController extends PuterController {
|
||||
res.json({ uid });
|
||||
}
|
||||
|
||||
// ── App token + check ───────────────────────────────────────────
|
||||
// -- App token + check -------------------------------------------
|
||||
|
||||
@Post('/auth/get-user-app-token', {
|
||||
subdomain: 'api',
|
||||
@@ -1938,7 +2069,10 @@ export class AuthController extends PuterController {
|
||||
{},
|
||||
);
|
||||
|
||||
const token = this.services.auth.getUserAppToken(req.actor!, app_uid);
|
||||
const token = await this.services.auth.getUserAppToken(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
);
|
||||
|
||||
const missingFSPathPromise = (async () => {
|
||||
// Ensure the app's per-user AppData directory exists.
|
||||
@@ -1993,7 +2127,7 @@ export class AuthController extends PuterController {
|
||||
token?: string;
|
||||
} = { app_uid, authenticated };
|
||||
if (authenticated) {
|
||||
result.token = this.services.auth.getUserAppToken(
|
||||
result.token = await this.services.auth.getUserAppToken(
|
||||
req.actor!,
|
||||
app_uid,
|
||||
);
|
||||
@@ -2001,7 +2135,75 @@ export class AuthController extends PuterController {
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
// ── Access tokens ───────────────────────────────────────────────
|
||||
// -- Access tokens -----------------------------------------------
|
||||
|
||||
async handleMigrateToken(req: Request, res: Response): Promise<void> {
|
||||
// 1. Origin lock. Reject anything that isn't same-origin to
|
||||
// `config.origin` or in the explicit per-deployment allowlist.
|
||||
// No Origin header → reject (this endpoint is browser-only by
|
||||
// design; server-side callers should re-auth properly).
|
||||
const reqOrigin = req.headers.origin;
|
||||
if (!reqOrigin || !this.#isMigrateTokenOriginAllowed(reqOrigin)) {
|
||||
throw new HttpError(403, 'Origin not allowed', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Extract token. Header takes precedence so body-capture logs
|
||||
// (if any) never see the credential.
|
||||
const authHeader = req.headers.authorization;
|
||||
const headerToken =
|
||||
typeof authHeader === 'string' && authHeader.startsWith('Bearer ')
|
||||
? authHeader.slice('Bearer '.length).trim()
|
||||
: null;
|
||||
const bodyToken =
|
||||
typeof req.body?.token === 'string' ? req.body.token.trim() : null;
|
||||
const v1Token = headerToken || bodyToken;
|
||||
if (!v1Token) {
|
||||
throw new HttpError(400, 'Missing token', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.services.auth.migrateLegacyToken(v1Token, {
|
||||
ip: req.ip,
|
||||
userAgent:
|
||||
typeof req.headers['user-agent'] === 'string'
|
||||
? req.headers['user-agent']
|
||||
: undefined,
|
||||
});
|
||||
// `puter_token_v2` is the cookie companion to v2 app tokens —
|
||||
// the app runs in-browser (we already gated on Origin above) so
|
||||
// the GUI's cookie-only middleware can authenticate subsequent
|
||||
// calls from the same iframe without the client having to
|
||||
// forward Authorization headers. Access tokens are programmatic
|
||||
// (no browser cookie surface) so we deliberately skip them here.
|
||||
if (result.kind === 'app') {
|
||||
res.cookie('puter_token_v2', result.token, {
|
||||
...sessionCookieFlags(this.config),
|
||||
httpOnly: true,
|
||||
});
|
||||
}
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
#isMigrateTokenOriginAllowed(origin: string): boolean {
|
||||
// Origin headers and config values both reach us in inconsistent
|
||||
// shapes (trailing slash, mixed case from misconfigured deploys,
|
||||
// stray whitespace from JSON config edits). Normalize both sides
|
||||
// before equality so a `config.origin` with a trailing slash
|
||||
// doesn't reject every same-origin browser call.
|
||||
const normalize = (raw: string | undefined): string =>
|
||||
(raw ?? '').trim().replace(/\/+$/, '').toLowerCase();
|
||||
const incoming = normalize(origin);
|
||||
if (!incoming) return false;
|
||||
if (incoming === normalize(this.config.origin)) return true;
|
||||
const allowlist = (
|
||||
this.config as { allow_migrate_token_origins?: string[] }
|
||||
).allow_migrate_token_origins;
|
||||
if (!Array.isArray(allowlist)) return false;
|
||||
return allowlist.some((entry) => normalize(entry) === incoming);
|
||||
}
|
||||
|
||||
@Post('/auth/create-access-token', {
|
||||
subdomain: 'api',
|
||||
@@ -2034,10 +2236,10 @@ export class AuthController extends PuterController {
|
||||
res.json({ token });
|
||||
}
|
||||
|
||||
@Post('/auth/revoke-access-token', {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
})
|
||||
// Wired imperatively in `registerRoutes` so the cookie-only gate
|
||||
// (built from `this.config`) can be composed in. Cookie-only is
|
||||
// mandatory: a leaked access token must not be able to silently
|
||||
// revoke its own siblings.
|
||||
async handleRevokeAccessToken(req: Request, res: Response): Promise<void> {
|
||||
let { tokenOrUuid } = req.body;
|
||||
if (!tokenOrUuid || typeof tokenOrUuid !== 'string') {
|
||||
@@ -2054,7 +2256,7 @@ export class AuthController extends PuterController {
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
// ── 2FA: configure ──────────────────────────────────────────────
|
||||
// -- 2FA: configure ----------------------------------------------
|
||||
|
||||
@Post('/auth/configure-2fa/:action', {
|
||||
subdomain: 'api',
|
||||
@@ -2157,7 +2359,7 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
// ── 2FA: disable (user-protected, wired in registerRoutes below) ─
|
||||
// -- 2FA: disable (user-protected, wired in registerRoutes below) -
|
||||
|
||||
async handleDisable2fa(req: Request, res: Response): Promise<void> {
|
||||
const user = await this.stores.user.getById(req.actor!.user.id!, {
|
||||
@@ -2187,7 +2389,7 @@ export class AuthController extends PuterController {
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// ── Developer profile ───────────────────────────────────────────
|
||||
// -- Developer profile -------------------------------------------
|
||||
|
||||
@Get('/get-dev-profile', { subdomain: 'api', requireUserActor: true })
|
||||
async handleGetDevProfile(req: Request, res: Response): Promise<void> {
|
||||
@@ -2217,7 +2419,7 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Group management ────────────────────────────────────────────
|
||||
// -- Group management --------------------------------------------
|
||||
|
||||
@Post('/group/create', { subdomain: 'api', requireUserActor: true })
|
||||
async handleGroupCreate(req: Request, res: Response): Promise<void> {
|
||||
@@ -2323,10 +2525,9 @@ export class AuthController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Session helpers ─────────────────────────────────────────────
|
||||
// -- Session helpers ---------------------------------------------
|
||||
|
||||
@Get('/get-gui-token', {
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
})
|
||||
@@ -2348,7 +2549,6 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
|
||||
@Get('/session/sync-cookie', {
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
})
|
||||
@@ -2366,14 +2566,14 @@ export class AuthController extends PuterController {
|
||||
user,
|
||||
req.actor.session.uid,
|
||||
);
|
||||
res.cookie(this.config.cookie_name, sessionToken, {
|
||||
res.cookie(this.config.cookie_name ?? 'puter_token', sessionToken, {
|
||||
...sessionCookieFlags(this.config),
|
||||
httpOnly: true,
|
||||
});
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
// ── Delete own account (user-protected, wired below) ────────────
|
||||
// -- Delete own account (user-protected, wired below) ------------
|
||||
//
|
||||
// Purge S3 objects + fsentries first, then the user row. FK
|
||||
// cascades on most related tables are `ON DELETE SET NULL` (not
|
||||
@@ -2382,13 +2582,14 @@ export class AuthController extends PuterController {
|
||||
|
||||
async handleDeleteOwnUser(req: Request, res: Response): Promise<void> {
|
||||
const userId = req.actor!.user.id!;
|
||||
res.clearCookie(this.config.cookie_name);
|
||||
res.clearCookie(this.config.cookie_name ?? 'puter_token');
|
||||
res.clearCookie('puter_token_v2');
|
||||
res.clearCookie('puter_revalidation');
|
||||
await this.#cascadeDeleteUser(userId);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
// ── registerRoutes override ─────────────────────────────────────
|
||||
// -- registerRoutes override -------------------------------------
|
||||
//
|
||||
// The `@Controller('')` decorator would normally install a default
|
||||
// `registerRoutes` walker that iterates `prototype[__puterRoutes]`.
|
||||
@@ -2431,7 +2632,7 @@ export class AuthController extends PuterController {
|
||||
routerMethod.call(router, r.path, r.options, bound);
|
||||
}
|
||||
|
||||
// ── User-protected routes (per-instance middleware) ──────────
|
||||
// -- User-protected routes (per-instance middleware) ----------
|
||||
const userProtectedDeps = {
|
||||
config: this.config,
|
||||
userStore: this.stores.user,
|
||||
@@ -2442,7 +2643,6 @@ export class AuthController extends PuterController {
|
||||
router.post(
|
||||
'/user-protected/change-password',
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
rateLimit: {
|
||||
scope: 'passwd',
|
||||
@@ -2462,7 +2662,6 @@ export class AuthController extends PuterController {
|
||||
router.post(
|
||||
'/user-protected/change-username',
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
requireVerified: true,
|
||||
rateLimit: {
|
||||
@@ -2483,7 +2682,6 @@ export class AuthController extends PuterController {
|
||||
router.post(
|
||||
'/user-protected/change-email',
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
rateLimit: {
|
||||
scope: 'change-email-start',
|
||||
@@ -2503,7 +2701,6 @@ export class AuthController extends PuterController {
|
||||
router.post(
|
||||
'/user-protected/disable-2fa',
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
rateLimit: {
|
||||
scope: 'disable-2fa',
|
||||
@@ -2523,7 +2720,6 @@ export class AuthController extends PuterController {
|
||||
router.post(
|
||||
'/user-protected/delete-own-user',
|
||||
{
|
||||
subdomain: ['api', ''],
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
middleware: [
|
||||
@@ -2534,9 +2730,78 @@ export class AuthController extends PuterController {
|
||||
},
|
||||
(req, res) => this.handleDeleteOwnUser(req, res),
|
||||
);
|
||||
|
||||
const webSessionGate = createWebSessionActorGate();
|
||||
|
||||
router.post(
|
||||
'/auth/revoke-session',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
antiCsrf: true,
|
||||
middleware: [webSessionGate],
|
||||
},
|
||||
(req, res) => this.handleRevokeSession(req, res),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/auth/revoke-all-sessions',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
antiCsrf: true,
|
||||
rateLimit: {
|
||||
scope: 'revoke-all-sessions',
|
||||
limit: 10,
|
||||
window: 60 * 60_000,
|
||||
key: 'user',
|
||||
},
|
||||
middleware: [webSessionGate],
|
||||
},
|
||||
(req, res) => this.handleRevokeAllSessions(req, res),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/auth/revoke-access-token',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
antiCsrf: true,
|
||||
middleware: [webSessionGate],
|
||||
},
|
||||
(req, res) => this.handleRevokeAccessToken(req, res),
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/auth/sessions/:uuid/label',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowUnconfirmed: true,
|
||||
antiCsrf: true,
|
||||
middleware: [webSessionGate],
|
||||
},
|
||||
(req, res) => this.handleRenameSession(req, res),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/auth/migrate-token',
|
||||
{
|
||||
subdomain: 'api',
|
||||
rateLimit: {
|
||||
scope: 'migrate-token',
|
||||
limit: 20,
|
||||
window: 15 * 60_000,
|
||||
key: 'ip',
|
||||
},
|
||||
},
|
||||
(req, res) => this.handleMigrateToken(req, res),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────
|
||||
// -- Private helpers ----------------------------------------------
|
||||
|
||||
async #cascadeDeleteUser(userId: number): Promise<void> {
|
||||
try {
|
||||
@@ -2621,6 +2886,89 @@ export class AuthController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-IP enumeration clamp on `auth_id`-bearing auth requests. Separate
|
||||
* from the route-level rate limit so a tighter ceiling applies only to
|
||||
* the path that takes a uuid hint from the body — the normal login
|
||||
* path stays at its more generous limit.
|
||||
*/
|
||||
async #checkAuthIdRateLimit(req: Request): Promise<void> {
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
const ok = await checkRateLimit(
|
||||
`login-with-auth-id:${ip}`,
|
||||
5,
|
||||
15 * 60_000,
|
||||
);
|
||||
if (!ok) {
|
||||
throw new HttpError(429, 'Too many auth_id login attempts.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
fields: { 'retry-after': 900 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the `auth_id` claim from a client-supplied reauth_token.
|
||||
* Returns null when no token was supplied. Throws on invalid/expired
|
||||
* tokens. The reauth_token is a server-signed JWT minted by the
|
||||
* authProbe at 401 time — accepting only the signed envelope (vs. a
|
||||
* raw UUID) means a leaked auth_id alone can't attach a session to
|
||||
* an existing account.
|
||||
*/
|
||||
#extractAuthIdFromReauthToken(suppliedToken: unknown): string | null {
|
||||
if (suppliedToken === undefined || suppliedToken === null) return null;
|
||||
if (typeof suppliedToken !== 'string' || !suppliedToken) {
|
||||
throw new HttpError(400, 'Invalid `reauth_token`.', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
const { authId } = this.services.auth.verifyReauthToken(suppliedToken);
|
||||
return authId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce a verified `auth_id` against the user the credential flow
|
||||
* has resolved. Caller has already extracted `auth_id` from the
|
||||
* server-signed reauth_token (or from an OTP-flow JWT). When the GUI
|
||||
* is forced through reauth, the 401 response embeds the reauth_token;
|
||||
* the client echoes it back so we can confirm the second login lands
|
||||
* on the same user row — critical for temp users, where a fresh
|
||||
* signup would otherwise mint a new account and strand their files.
|
||||
*
|
||||
* No `authId` supplied → no-op (normal login). Unknown `authId` →
|
||||
* 404 (mirrors username-not-found, avoids being an enumeration
|
||||
* oracle). Mismatch against the resolved user → 409
|
||||
* (`auth_id_mismatch`).
|
||||
*/
|
||||
async #enforceAuthIdMatch(
|
||||
req: Request,
|
||||
resolvedUser: { id: number; uuid: string },
|
||||
authId: string | null,
|
||||
): Promise<void> {
|
||||
if (!authId) return;
|
||||
|
||||
await this.#checkAuthIdRateLimit(req);
|
||||
|
||||
// Common path: auth_id maps to the same uuid the credential flow
|
||||
// resolved. Skip the user-table read entirely. The DB lookup is
|
||||
// only needed to disambiguate 404 (unknown auth_id) from 409
|
||||
// (known but mismatched) on the error path.
|
||||
if (authId === resolvedUser.uuid) return;
|
||||
|
||||
const authIdUser = await this.stores.user.getByUuid(authId);
|
||||
if (!authIdUser) {
|
||||
throw new HttpError(404, 'auth_id not found.', {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
}
|
||||
if (authIdUser.id !== resolvedUser.id) {
|
||||
throw new HttpError(409, 'auth_id does not match credentials.', {
|
||||
legacyCode: 'bad_request',
|
||||
fields: { code: 'auth_id_mismatch' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async #completeLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -2645,7 +2993,7 @@ export class AuthController extends PuterController {
|
||||
await this.services.auth.createSessionToken(user as never, meta);
|
||||
|
||||
// HTTP-only cookie gets the session token
|
||||
res.cookie(this.config.cookie_name, sessionToken, {
|
||||
res.cookie(this.config.cookie_name ?? 'puter_token', sessionToken, {
|
||||
...sessionCookieFlags(this.config),
|
||||
httpOnly: true,
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ export class DesktopController extends PuterController {
|
||||
}
|
||||
|
||||
registerRoutes(router) {
|
||||
// ── Desktop background ──────────────────────────────────────
|
||||
// -- Desktop background --------------------------------------
|
||||
|
||||
router.post(
|
||||
'/set-desktop-bg',
|
||||
@@ -98,7 +98,7 @@ export class DesktopController extends PuterController {
|
||||
},
|
||||
);
|
||||
|
||||
// ── Taskbar items ───────────────────────────────────────────
|
||||
// -- Taskbar items -------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/update-taskbar-items',
|
||||
@@ -123,7 +123,7 @@ export class DesktopController extends PuterController {
|
||||
},
|
||||
);
|
||||
|
||||
// ── Folder layout ───────────────────────────────────────────
|
||||
// -- Folder layout -------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/set_layout',
|
||||
@@ -149,7 +149,7 @@ export class DesktopController extends PuterController {
|
||||
},
|
||||
);
|
||||
|
||||
// ── Folder sort ─────────────────────────────────────────────
|
||||
// -- Folder sort ---------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/set_sort_by',
|
||||
@@ -188,7 +188,7 @@ export class DesktopController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
// -- Helpers ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update columns on an actor-owned fsentry. Accepts either `item_uid`
|
||||
|
||||
@@ -124,11 +124,13 @@ const makeSyntheticDriver = () => ({
|
||||
|
||||
const buildController = (driver: ReturnType<typeof makeSyntheticDriver>) => {
|
||||
// The controller reads `this.services?.permission` only when an actor
|
||||
// is on the request; otherwise the services bag is unused. Empty
|
||||
// objects are sufficient for this path.
|
||||
// is on the request; otherwise the services bag is unused. The
|
||||
// alarm client is invoked on the rate-limit / concurrency rejection
|
||||
// paths, so stub a no-op `create`.
|
||||
const clients = { alarm: { create: () => {} } };
|
||||
return new DriverController(
|
||||
{} as any,
|
||||
{} as any,
|
||||
clients as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{ syntheticDriver: driver } as any,
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { Controller } from '../../core/http/decorators.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
|
||||
import {
|
||||
acquireDriverConcurrent,
|
||||
checkDriverRateLimit,
|
||||
@@ -39,7 +39,7 @@ import { PuterController } from '../types.js';
|
||||
|
||||
type DriverInstance = WithLifecycle & Record<string, unknown>;
|
||||
|
||||
// ── /drivers/xd payload ─────────────────────────────────────────────
|
||||
// -- /drivers/xd payload ---------------------------------------------
|
||||
//
|
||||
// Self-contained HTML/JS shipped to the iframe consumer. Listens for
|
||||
// postMessage events shaped as `{ id, interface, method, params }`,
|
||||
@@ -110,7 +110,7 @@ const XD_HTML = `<!DOCTYPE html>
|
||||
<body></body>
|
||||
</html>`;
|
||||
|
||||
// ── Controller ──────────────────────────────────────────────────────
|
||||
// -- Controller ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Routes driver RPC calls through a unified HTTP surface.
|
||||
@@ -129,6 +129,96 @@ const XD_HTML = `<!DOCTYPE html>
|
||||
* additional drivers end up in that bag before this controller is
|
||||
* instantiated, so they show up here automatically.
|
||||
*/
|
||||
/**
|
||||
* Catch-all upstream-error translator for the driver boundary.
|
||||
*
|
||||
* Drivers that wrap a third-party SDK (OpenAI, Anthropic, etc.) often
|
||||
* let the SDK's own error class bubble — those carry an HTTP `.status`
|
||||
* but are plain `Error` subclasses, not `HttpError`s, so they would
|
||||
* otherwise hit the global error handler as unexpected 500s and page
|
||||
* PagerDuty. Repackage them with `upstream_*` legacy codes so the
|
||||
* alarm gate (server.ts) treats them as upstream failures and only
|
||||
* pages on the two we actually care about (rate-limit / auth).
|
||||
*
|
||||
* Status extraction covers the shapes we've seen in the wild:
|
||||
* - `.status` / `.statusCode` (OpenAI / Anthropic / Together / Google GenAI)
|
||||
* - `.response.status` (Replicate's `ApiError`)
|
||||
* - `.$metadata.httpStatusCode` (AWS SDK v3, e.g. Polly)
|
||||
* - status sniffed from the message string (last resort, for providers
|
||||
* that re-wrap their SDK error in `new Error(msg)` and lose the field)
|
||||
*
|
||||
* `HttpError`s thrown by drivers pass through untouched.
|
||||
*/
|
||||
const extractUpstreamStatus = (e: {
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
response?: { status?: number };
|
||||
$metadata?: { httpStatusCode?: number };
|
||||
message?: string;
|
||||
}): number | undefined => {
|
||||
const direct = e.status ?? e.statusCode;
|
||||
if (typeof direct === 'number') return direct;
|
||||
const fromResponse = e.response?.status;
|
||||
if (typeof fromResponse === 'number') return fromResponse;
|
||||
const fromAws = e.$metadata?.httpStatusCode;
|
||||
if (typeof fromAws === 'number') return fromAws;
|
||||
// Message sniff (e.g. "... failed with status 422 ...").
|
||||
// Only trust if it's adjacent to a status-indicating word to
|
||||
// avoid matching random 4xx/5xx-looking numbers in payloads.
|
||||
const msg = e.message;
|
||||
if (typeof msg === 'string') {
|
||||
const m = msg.match(/\bstatus(?:\s+code)?\s*[:=]?\s*(4\d\d|5\d\d)\b/i);
|
||||
if (m) return Number(m[1]);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const translateProviderError = (err: unknown): unknown => {
|
||||
if (isHttpError(err)) return err;
|
||||
if (!err || typeof err !== 'object') return err;
|
||||
const e = err as {
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
response?: { status?: number };
|
||||
$metadata?: { httpStatusCode?: number };
|
||||
message?: string;
|
||||
error?: { code?: string; type?: string; message?: string };
|
||||
code?: string;
|
||||
};
|
||||
const status = extractUpstreamStatus(e);
|
||||
if (typeof status !== 'number') return err;
|
||||
|
||||
const msg = e.error?.message ?? e.message ?? 'Upstream provider error';
|
||||
const upstreamCode = e.error?.code ?? e.code;
|
||||
const fields = { upstreamStatus: status, upstreamCode };
|
||||
|
||||
if (status === 429) {
|
||||
return new HttpError(429, msg, {
|
||||
legacyCode: 'upstream_rate_limited',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
return new HttpError(500, msg, {
|
||||
legacyCode: 'upstream_auth_failed',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
if (status >= 500) {
|
||||
return new HttpError(400, 'AI provider unavailable', {
|
||||
legacyCode: 'upstream_provider_unavailable',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
if (status >= 400) {
|
||||
return new HttpError(400, msg, {
|
||||
legacyCode: 'upstream_bad_request',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
return err;
|
||||
};
|
||||
|
||||
@Controller('/drivers')
|
||||
export class DriverController extends PuterController {
|
||||
/** iface → Map<driverName, driverInstance> */
|
||||
@@ -146,7 +236,7 @@ export class DriverController extends PuterController {
|
||||
this.#buildIfaceMap();
|
||||
}
|
||||
|
||||
// ── Lookup API (used by tests / internals) ──────────────────────
|
||||
// -- Lookup API (used by tests / internals) ----------------------
|
||||
|
||||
/** Resolve a driver by interface + optional name (default when omitted). */
|
||||
resolve(interfaceName: string, driverName?: string): DriverInstance | null {
|
||||
@@ -170,7 +260,7 @@ export class DriverController extends PuterController {
|
||||
return this.#defaults.get(interfaceName);
|
||||
}
|
||||
|
||||
// ── Route registration ──────────────────────────────────────────
|
||||
// -- Route registration ------------------------------------------
|
||||
|
||||
registerRoutes(router: PuterRouter): void {
|
||||
router.post(
|
||||
@@ -190,7 +280,7 @@ export class DriverController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────
|
||||
// -- Handlers ----------------------------------------------------
|
||||
|
||||
#handleCall = async (req: Request, res: Response): Promise<void> => {
|
||||
const {
|
||||
@@ -278,6 +368,19 @@ export class DriverController extends PuterController {
|
||||
if (
|
||||
!(await checkDriverRateLimit(req, ifaceName, method, rateLimitSpec))
|
||||
) {
|
||||
// De-dupe on (iface, method) so a hot loop across many users
|
||||
// aggregates as occurrences on a single low-severity alarm
|
||||
// instead of fanning out one per user.
|
||||
this.clients.alarm.create(
|
||||
`driver_rate_limit_hit:${ifaceName}:${method}`,
|
||||
`Driver rate limit hit on ${ifaceName}:${method}`,
|
||||
{
|
||||
iface: ifaceName,
|
||||
method,
|
||||
userUuid: req.actor?.user?.uuid,
|
||||
},
|
||||
'info',
|
||||
);
|
||||
throw new HttpError(429, 'Too many requests.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
@@ -300,6 +403,16 @@ export class DriverController extends PuterController {
|
||||
concurrentSpec,
|
||||
);
|
||||
if (!handle.ok) {
|
||||
this.clients.alarm.create(
|
||||
`driver_concurrent_limit_hit:${ifaceName}:${method}`,
|
||||
`Driver concurrency limit hit on ${ifaceName}:${method}`,
|
||||
{
|
||||
iface: ifaceName,
|
||||
method,
|
||||
userUuid: req.actor?.user?.uuid,
|
||||
},
|
||||
'info',
|
||||
);
|
||||
throw new HttpError(429, 'Too many concurrent requests.', {
|
||||
legacyCode: 'too_many_requests',
|
||||
});
|
||||
@@ -327,11 +440,13 @@ export class DriverController extends PuterController {
|
||||
Context.set('driverName', requestedDriver);
|
||||
|
||||
// Drivers read actor/context via the Context API — no drilled args.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (fn as (...x: unknown[]) => any).call(
|
||||
driver,
|
||||
args,
|
||||
);
|
||||
let result;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
result = await (fn as (...x: unknown[]) => any).call(driver, args);
|
||||
} catch (e) {
|
||||
throw translateProviderError(e);
|
||||
}
|
||||
|
||||
if (isDriverStreamResult(result)) {
|
||||
res.setHeader('Content-Type', result.content_type);
|
||||
@@ -379,7 +494,7 @@ export class DriverController extends PuterController {
|
||||
res.send(XD_HTML);
|
||||
};
|
||||
|
||||
// ── Internals ───────────────────────────────────────────────────
|
||||
// -- Internals ---------------------------------------------------
|
||||
|
||||
#buildIfaceMap(): void {
|
||||
const bag = this.drivers as unknown as Record<string, DriverInstance>;
|
||||
|
||||
@@ -434,6 +434,119 @@ describe('FSController.completeBatchWrites', () => {
|
||||
expect(finalized?.wasOverwrite).toBe(true);
|
||||
});
|
||||
|
||||
it('emits updated events with GUI metadata when overwriting via batch completion', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const target = `/${username}/Documents/overwrite-event.js`;
|
||||
|
||||
const firstStart = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.startBatchWrites(
|
||||
makeReq<SignedWriteRequest[]>({
|
||||
body: [{ fileMetadata: { path: target, size: 1 } }],
|
||||
actor,
|
||||
}),
|
||||
firstStart.res,
|
||||
),
|
||||
);
|
||||
const [firstResponse] = firstStart.captured
|
||||
.body as SignedWriteResponse[];
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: [{ uploadId: firstResponse!.sessionId }],
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const targetEntry = await server.stores.fsEntry.getEntryByPath(target);
|
||||
expect(targetEntry).not.toBeNull();
|
||||
await server.stores.subdomain.create({
|
||||
userId,
|
||||
subdomain: `workers.puter.${username}-worker`,
|
||||
rootDirId: targetEntry!.id,
|
||||
});
|
||||
|
||||
const secondStart = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.startBatchWrites(
|
||||
makeReq<SignedWriteRequest[]>({
|
||||
body: [
|
||||
{
|
||||
fileMetadata: {
|
||||
path: target,
|
||||
size: 2,
|
||||
overwrite: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
actor,
|
||||
}),
|
||||
secondStart.res,
|
||||
),
|
||||
);
|
||||
const [secondResponse] = secondStart.captured
|
||||
.body as SignedWriteResponse[];
|
||||
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
let updatedCall:
|
||||
| (typeof emitSpy.mock.calls)[number]
|
||||
| undefined;
|
||||
try {
|
||||
await withActor(actor, () =>
|
||||
controller.completeBatchWrites(
|
||||
makeReq<CompleteWriteRequest[]>({
|
||||
body: [
|
||||
{
|
||||
uploadId: secondResponse!.sessionId,
|
||||
guiMetadata: {
|
||||
operationId: 'op-123',
|
||||
itemUploadId: 'item-456',
|
||||
socketId: 'socket-789',
|
||||
originalClientSocketId: 'socket-789',
|
||||
},
|
||||
},
|
||||
],
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
updatedCall = emitSpy.mock.calls.find(
|
||||
([eventName]) => eventName === 'outer.gui.item.updated',
|
||||
);
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
expect(updatedCall).toBeTruthy();
|
||||
const payload = updatedCall?.[1] as {
|
||||
user_id_list?: number[];
|
||||
response?: Record<string, unknown>;
|
||||
};
|
||||
expect(payload.user_id_list).toEqual([userId]);
|
||||
expect(payload.response).toMatchObject({
|
||||
uid: expect.any(String),
|
||||
uuid: expect.any(String),
|
||||
id: expect.any(String),
|
||||
path: target,
|
||||
name: 'overwrite-event.js',
|
||||
is_dir: false,
|
||||
type: expect.stringMatching(/^application\/javascript/),
|
||||
workers: [
|
||||
expect.objectContaining({
|
||||
subdomain: `workers.puter.${username}-worker`,
|
||||
address: expect.stringContaining(`${username}-worker`),
|
||||
}),
|
||||
],
|
||||
from_new_service: true,
|
||||
operation_id: 'op-123',
|
||||
item_upload_id: 'item-456',
|
||||
socket_id: 'socket-789',
|
||||
original_client_socket_id: 'socket-789',
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects another user's session ids with a 4xx", async () => {
|
||||
const a = await makeUser();
|
||||
const b = await makeUser();
|
||||
@@ -678,6 +791,87 @@ describe('FSController.searchEntries', () => {
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
expect(results.some((r) => r.name === needle)).toBe(true);
|
||||
});
|
||||
|
||||
it('scopes app-under-user actors to their AppData root', async () => {
|
||||
const { actor: userActor } = await makeUser();
|
||||
const username = userActor.user!.username!;
|
||||
const appUid = `app-search-${uuidv4()}`;
|
||||
const appActor: Actor = { ...userActor, app: { uid: appUid } };
|
||||
const needle = `appneedle-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// User-owned entry outside AppData — must NOT appear for the app.
|
||||
await withActor(userActor, () =>
|
||||
controller.mkdirEntry(
|
||||
makeReq({
|
||||
body: { path: `/${username}/Documents/${needle}` },
|
||||
actor: userActor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
// Entry under the app's own AppData — must appear.
|
||||
await withActor(userActor, () =>
|
||||
controller.mkdirEntry(
|
||||
makeReq({
|
||||
body: {
|
||||
path: `/${username}/AppData/${appUid}/${needle}`,
|
||||
create_missing_parents: true,
|
||||
},
|
||||
actor: userActor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(appActor, () =>
|
||||
controller.searchEntries(
|
||||
makeReq({ body: { query: needle }, actor: appActor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const results = captured.body as Array<{ name: string; path: string }>;
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
for (const r of results) {
|
||||
expect(
|
||||
r.path === `/${username}/AppData/${appUid}` ||
|
||||
r.path.startsWith(`/${username}/AppData/${appUid}/`),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
results.some(
|
||||
(r) => r.path === `/${username}/Documents/${needle}`,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns nothing for an app actor when no AppData entries match', async () => {
|
||||
const { actor: userActor } = await makeUser();
|
||||
const username = userActor.user!.username!;
|
||||
const appUid = `app-search-${uuidv4()}`;
|
||||
const appActor: Actor = { ...userActor, app: { uid: appUid } };
|
||||
const needle = `appneedle-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// Only seed outside AppData — the app must not be able to find it.
|
||||
await withActor(userActor, () =>
|
||||
controller.mkdirEntry(
|
||||
makeReq({
|
||||
body: { path: `/${username}/Documents/${needle}` },
|
||||
actor: userActor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(appActor, () =>
|
||||
controller.searchEntries(
|
||||
makeReq({ body: { query: needle }, actor: appActor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(captured.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── /read (readEntry, validation paths) ─────────────────────────────
|
||||
|
||||
@@ -22,9 +22,11 @@ import type { Request, Response } from 'express';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { effectiveActorApp } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { Controller, Get, Post } from '../../core/http/decorators.js';
|
||||
import { assertNormalized } from '../../services/fs/resolveNode.js';
|
||||
import type {
|
||||
PreparedBatchWrite,
|
||||
UploadedBatchWriteItem,
|
||||
@@ -57,6 +59,7 @@ import type {
|
||||
ThumbnailUploadPrepareItem,
|
||||
ThumbnailUploadPreparePayload,
|
||||
} from './types.js';
|
||||
import { toLegacyEntry } from './legacyFsHelpers.js';
|
||||
class UploadProgressTracker implements UploadProgressTrackerLike {
|
||||
total = 0;
|
||||
progress = 0;
|
||||
@@ -90,7 +93,7 @@ const DEFAULT_BATCH_WRITE_SIDE_EFFECT_CONCURRENCY = 8;
|
||||
|
||||
@Controller('/fs')
|
||||
export class FSController extends PuterController {
|
||||
override getReportedCosts(): Record<string, unknown>[] {
|
||||
override getReportedCosts() {
|
||||
return Object.entries(FS_COSTS).map(([usageType, ucentsPerUnit]) => ({
|
||||
usageType,
|
||||
ucentsPerUnit,
|
||||
@@ -420,7 +423,12 @@ export class FSController extends PuterController {
|
||||
const busboy = Busboy({ headers: req.headers });
|
||||
|
||||
busboy.on('field', (fieldName, value, info) => {
|
||||
if (info.fieldnameTruncated || info.valueTruncated) {
|
||||
if (
|
||||
(info as unknown as { filenameTruncated: string })
|
||||
.filenameTruncated ||
|
||||
info.nameTruncated ||
|
||||
info.valueTruncated
|
||||
) {
|
||||
failParse(
|
||||
new HttpError(
|
||||
400,
|
||||
@@ -809,7 +817,7 @@ export class FSController extends PuterController {
|
||||
res.json(updatedResponses);
|
||||
}
|
||||
|
||||
// ── Read-side routes ────────────────────────────────────────────────
|
||||
// -- Read-side routes ------------------------------------------------
|
||||
|
||||
@Post('/stat', { subdomain: 'api', requireVerified: true })
|
||||
async statEntry(req: Request, res: Response) {
|
||||
@@ -909,7 +917,7 @@ export class FSController extends PuterController {
|
||||
|
||||
@Post('/search', { subdomain: 'api', requireVerified: true })
|
||||
async searchEntries(req: Request, res: Response) {
|
||||
this.#requireActor(req);
|
||||
const actor = this.#requireActor(req);
|
||||
const userId = this.#getActorUserId(req);
|
||||
const body = this.#toObjectRecord(req.body);
|
||||
const query =
|
||||
@@ -928,6 +936,7 @@ export class FSController extends PuterController {
|
||||
userId,
|
||||
query,
|
||||
limit ?? 200,
|
||||
this.#appDataScopeForActor(actor),
|
||||
);
|
||||
res.json(results);
|
||||
}
|
||||
@@ -1006,7 +1015,7 @@ export class FSController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mutation routes ────────────────────────────────────────────────
|
||||
// -- Mutation routes ------------------------------------------------
|
||||
|
||||
@Post('/mkdir', { subdomain: 'api', requireVerified: true })
|
||||
async mkdirEntry(req: Request, res: Response) {
|
||||
@@ -1204,7 +1213,7 @@ export class FSController extends PuterController {
|
||||
res.json(shortcut);
|
||||
}
|
||||
|
||||
// ── Read-side helpers ───────────────────────────────────────────────
|
||||
// -- Read-side helpers -----------------------------------------------
|
||||
|
||||
#requireActor(req: Request): Actor {
|
||||
const actor = req.actor;
|
||||
@@ -1648,24 +1657,6 @@ export class FSController extends PuterController {
|
||||
normalizedFileMetadata.multipartPartSize = multipartPartSize;
|
||||
}
|
||||
|
||||
const bucket = this.#firstDefined(
|
||||
metadataRecord.bucket,
|
||||
fallbackRecord.bucket,
|
||||
);
|
||||
if (typeof bucket === 'string' && bucket.length > 0) {
|
||||
normalizedFileMetadata.bucket = bucket;
|
||||
}
|
||||
|
||||
const bucketRegion = this.#firstDefined(
|
||||
metadataRecord.bucketRegion,
|
||||
metadataRecord.bucket_region,
|
||||
fallbackRecord.bucketRegion,
|
||||
fallbackRecord.bucket_region,
|
||||
);
|
||||
if (typeof bucketRegion === 'string' && bucketRegion.length > 0) {
|
||||
normalizedFileMetadata.bucketRegion = bucketRegion;
|
||||
}
|
||||
|
||||
const associatedAppId = this.#toNumber(
|
||||
this.#firstDefined(
|
||||
metadataRecord.associatedAppId,
|
||||
@@ -1787,7 +1778,8 @@ export class FSController extends PuterController {
|
||||
pathToNormalize = `/${username}${pathToNormalize.slice(1)}`;
|
||||
}
|
||||
|
||||
let normalizedPath = pathPosix.normalize(pathToNormalize);
|
||||
assertNormalized(pathToNormalize);
|
||||
let normalizedPath = pathToNormalize;
|
||||
if (!normalizedPath.startsWith('/')) {
|
||||
normalizedPath = `/${normalizedPath}`;
|
||||
}
|
||||
@@ -2032,56 +2024,7 @@ export class FSController extends PuterController {
|
||||
}
|
||||
|
||||
async #toGuiFsEntry(entry: FSEntry): Promise<Record<string, unknown>> {
|
||||
const dirpath = pathPosix.dirname(entry.path);
|
||||
const extension = pathPosix.extname(entry.name).slice(1).toLowerCase();
|
||||
const response = {
|
||||
id: entry.uuid,
|
||||
uid: entry.uuid,
|
||||
uuid: entry.uuid,
|
||||
parent_id: entry.parentUid,
|
||||
parent_uid: entry.parentUid,
|
||||
path: entry.path,
|
||||
dirname: dirpath,
|
||||
dirpath,
|
||||
name: entry.name,
|
||||
is_dir: entry.isDir,
|
||||
is_shortcut: entry.isShortcut ? 1 : 0,
|
||||
shortcut_to: entry.shortcutTo,
|
||||
type: entry.isDir ? 'folder' : extension,
|
||||
writable: true,
|
||||
is_public: entry.isPublic,
|
||||
thumbnail: entry.thumbnail,
|
||||
immutable: entry.immutable,
|
||||
metadata: entry.metadata,
|
||||
modified: entry.modified,
|
||||
created: entry.created,
|
||||
accessed: entry.accessed,
|
||||
size: entry.size,
|
||||
};
|
||||
|
||||
if (
|
||||
typeof response.thumbnail === 'string' &&
|
||||
response.thumbnail.length > 0
|
||||
) {
|
||||
const thumbnailEntry = {
|
||||
uuid: entry.uuid,
|
||||
thumbnail: response.thumbnail,
|
||||
};
|
||||
// emitAndWait — listener rewrites s3:// / legacy URLs into
|
||||
// time-limited signed URLs on the payload object.
|
||||
await this.clients.event.emitAndWait(
|
||||
'thumbnail.read',
|
||||
thumbnailEntry,
|
||||
{},
|
||||
);
|
||||
response.thumbnail =
|
||||
typeof thumbnailEntry.thumbnail === 'string' &&
|
||||
thumbnailEntry.thumbnail.length > 0
|
||||
? thumbnailEntry.thumbnail
|
||||
: null;
|
||||
}
|
||||
|
||||
return response;
|
||||
return toLegacyEntry(this.clients.event, entry);
|
||||
}
|
||||
|
||||
async #emitGuiWriteEvent(
|
||||
@@ -2091,7 +2034,7 @@ export class FSController extends PuterController {
|
||||
): Promise<void> {
|
||||
const response = {
|
||||
...(await this.#toGuiFsEntry(fsEntry)),
|
||||
...this.#toEventGuiMetadata(guiMetadata, false),
|
||||
...this.#toEventGuiMetadata(guiMetadata),
|
||||
from_new_service: true,
|
||||
};
|
||||
await this.clients.event.emit(
|
||||
@@ -2142,6 +2085,20 @@ export class FSController extends PuterController {
|
||||
return pathParts.length >= 2 && pathParts[1] === 'AppData';
|
||||
}
|
||||
|
||||
// If `actor` is effectively scoped to an app (directly or through an
|
||||
// access-token issuer chain), return that app's AppData root under the
|
||||
// actor's user. Returns undefined for pure user actors. The store anchors
|
||||
// search results to this path so app actors can't see entries outside
|
||||
// their AppData via `/fs/search`.
|
||||
#appDataScopeForActor(actor: Actor): string | undefined {
|
||||
const app = effectiveActorApp(actor);
|
||||
if (!app) return undefined;
|
||||
const username = actor.user?.username;
|
||||
if (typeof username !== 'string' || username.length === 0)
|
||||
return undefined;
|
||||
return `/${username}/AppData/${app.uid}`;
|
||||
}
|
||||
|
||||
#estimateDataUrlSize(dataUrl: string): number {
|
||||
const commaIndex = dataUrl.indexOf(',');
|
||||
const base64 =
|
||||
@@ -2235,7 +2192,7 @@ export class FSController extends PuterController {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { index, contentType, size };
|
||||
return { index, contentType, size } as ThumbnailUploadPrepareItem;
|
||||
}
|
||||
|
||||
async #attachSignedThumbnailUploadTargets(
|
||||
@@ -2255,11 +2212,12 @@ export class FSController extends PuterController {
|
||||
|
||||
const payload: ThumbnailUploadPreparePayload = {
|
||||
items: prepareItems.map(
|
||||
(item): ThumbnailUploadPrepareItem => ({
|
||||
index: item.index,
|
||||
contentType: item.contentType,
|
||||
...(item.size !== undefined ? { size: item.size } : {}),
|
||||
}),
|
||||
(item): ThumbnailUploadPrepareItem =>
|
||||
({
|
||||
index: item.index,
|
||||
contentType: item.contentType,
|
||||
...(item.size !== undefined ? { size: item.size } : {}),
|
||||
}) as ThumbnailUploadPrepareItem,
|
||||
),
|
||||
};
|
||||
// emitAndWait — listeners populate `uploadUrl` / `thumbnailUrl` on
|
||||
|
||||
@@ -813,6 +813,57 @@ describe('LegacyFSController.search', () => {
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
expect(results.some((r) => r.name === needle)).toBe(true);
|
||||
});
|
||||
|
||||
it('scopes app-under-user actors to their AppData root', async () => {
|
||||
const { actor: userActor } = await makeUser();
|
||||
const username = userActor.user!.username!;
|
||||
const appUid = `app-legacy-search-${uuidv4()}`;
|
||||
const appActor: Actor = { ...userActor, app: { uid: appUid } };
|
||||
const needle = `appneedle-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
await withActor(userActor, () =>
|
||||
controller.mkdir(
|
||||
makeReq({
|
||||
body: { path: `/${username}/Documents/${needle}` },
|
||||
actor: userActor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
await withActor(userActor, () =>
|
||||
controller.mkdir(
|
||||
makeReq({
|
||||
body: {
|
||||
path: `/${username}/AppData/${appUid}/${needle}`,
|
||||
create_missing_parents: true,
|
||||
},
|
||||
actor: userActor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(appActor, () =>
|
||||
controller.search(
|
||||
makeReq({ body: { query: needle }, actor: appActor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const results = captured.body as Array<{ path: string }>;
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
for (const r of results) {
|
||||
expect(
|
||||
r.path === `/${username}/AppData/${appUid}` ||
|
||||
r.path.startsWith(`/${username}/AppData/${appUid}/`),
|
||||
).toBe(true);
|
||||
}
|
||||
expect(
|
||||
results.some(
|
||||
(r) => r.path === `/${username}/Documents/${needle}`,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── read (validation paths only) ────────────────────────────────────
|
||||
@@ -2032,3 +2083,197 @@ describe('LegacyFSController.readdir extras', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
// ── suggestApps (ACL-gated) ─────────────────────────────────────────
|
||||
|
||||
describe('LegacyFSController.suggestApps', () => {
|
||||
it('refuses to look up entries an app actor cannot see', async () => {
|
||||
const { actor: userActor } = await makeUser();
|
||||
const username = userActor.user!.username!;
|
||||
const appActor: Actor = {
|
||||
...userActor,
|
||||
app: { uid: `app-suggest-${uuidv4()}` },
|
||||
};
|
||||
const target = `/${username}/Documents/probe.txt`;
|
||||
await withActor(userActor, () =>
|
||||
controller.touch(
|
||||
makeReq({
|
||||
body: { path: target, set_modified_to_now: true },
|
||||
actor: userActor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
|
||||
const spy = vi.spyOn(
|
||||
server.services.suggestedApps,
|
||||
'getSuggestedApps',
|
||||
);
|
||||
try {
|
||||
const { res } = makeRes();
|
||||
await withActor(appActor, () =>
|
||||
controller.suggestApps(
|
||||
makeReq({ body: { path: target }, actor: appActor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(spy).toHaveBeenLastCalledWith({
|
||||
name: undefined,
|
||||
path: undefined,
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards entry name/path to the suggester for the owning user', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const target = `/${username}/Documents/probe2.txt`;
|
||||
await withActor(actor, () =>
|
||||
controller.touch(
|
||||
makeReq({
|
||||
body: { path: target, set_modified_to_now: true },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
|
||||
const spy = vi.spyOn(
|
||||
server.services.suggestedApps,
|
||||
'getSuggestedApps',
|
||||
);
|
||||
try {
|
||||
const { res } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.suggestApps(
|
||||
makeReq({ body: { path: target }, actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(spy).toHaveBeenLastCalledWith({
|
||||
name: 'probe2.txt',
|
||||
path: target,
|
||||
});
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── readdirSubdomains ───────────────────────────────────────────────
|
||||
|
||||
describe('LegacyFSController.readdirSubdomains', () => {
|
||||
it('returns an empty array for app-under-user actors', async () => {
|
||||
const { actor: userActor, userId } = await makeUser();
|
||||
const appActor: Actor = {
|
||||
...userActor,
|
||||
app: { uid: `app-subd-${uuidv4()}` },
|
||||
};
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `subdomains` (`uuid`, `subdomain`, `user_id`) VALUES (?, ?, ?)',
|
||||
[uuidv4(), `sd-${uuidv4().slice(0, 8)}`, userId],
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(appActor, () =>
|
||||
controller.readdirSubdomains(
|
||||
makeReq({ body: {}, actor: appActor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
expect(captured.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the user actor's own subdomain rows", async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const sd = `sd-${uuidv4().slice(0, 8)}`;
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `subdomains` (`uuid`, `subdomain`, `user_id`) VALUES (?, ?, ?)',
|
||||
[uuidv4(), sd, userId],
|
||||
);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.readdirSubdomains(
|
||||
makeReq({ body: {}, actor }),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const rows = captured.body as Array<{ subdomain: string }>;
|
||||
expect(rows.some((r) => r.subdomain === sd)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateFsentryThumbnail ──────────────────────────────────────────
|
||||
|
||||
describe('LegacyFSController.updateFsentryThumbnail', () => {
|
||||
it('rejects an app actor probing entries outside AppData with 404', async () => {
|
||||
const { actor: userActor } = await makeUser();
|
||||
const username = userActor.user!.username!;
|
||||
const appActor: Actor = {
|
||||
...userActor,
|
||||
app: { uid: `app-thumb-${uuidv4()}` },
|
||||
};
|
||||
const target = `/${username}/Documents/thumbme.txt`;
|
||||
await withActor(userActor, () =>
|
||||
controller.touch(
|
||||
makeReq({
|
||||
body: { path: target, set_modified_to_now: true },
|
||||
actor: userActor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(target);
|
||||
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
withActor(appActor, () =>
|
||||
controller.updateFsentryThumbnail(
|
||||
makeReq({
|
||||
body: {
|
||||
uid: entry!.uuid,
|
||||
thumbnail: 'data:image/png;base64,AA==',
|
||||
},
|
||||
actor: appActor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
|
||||
it('updates the thumbnail for the owning user actor', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const username = actor.user!.username!;
|
||||
const target = `/${username}/Documents/thumbme2.txt`;
|
||||
await withActor(actor, () =>
|
||||
controller.touch(
|
||||
makeReq({
|
||||
body: { path: target, set_modified_to_now: true },
|
||||
actor,
|
||||
}),
|
||||
makeRes().res,
|
||||
),
|
||||
);
|
||||
const entry = await server.stores.fsEntry.getEntryByPath(target);
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await withActor(actor, () =>
|
||||
controller.updateFsentryThumbnail(
|
||||
makeReq({
|
||||
body: {
|
||||
uid: entry!.uuid,
|
||||
thumbnail: 'data:image/png;base64,AA==',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
),
|
||||
);
|
||||
const body = captured.body as { thumbnail: string };
|
||||
expect(typeof body.thumbnail).toBe('string');
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ import type { Request, RequestHandler, Response } from 'express';
|
||||
import { contentType as contentTypeFromMime } from 'mime-types';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { isAccessTokenActor } from '../../core/actor.js';
|
||||
import { effectiveActorApp, isAccessTokenActor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
signingConfigFromAppConfig,
|
||||
toLegacyEntry,
|
||||
} from './legacyFsHelpers.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
|
||||
/**
|
||||
* Legacy FS routes, implemented as thin shims over `FSService`.
|
||||
@@ -56,6 +57,16 @@ type RouterCache = Map<string, RequestHandler | null>;
|
||||
|
||||
const additionalRoutePaths: Record<string, string> = {};
|
||||
|
||||
// Legacy `/batch` multipart upload caps. Each file is buffered fully into
|
||||
// memory before any quota / storage check runs, so without these limits an
|
||||
// authenticated caller could grow the process heap proportional to whatever
|
||||
// they sent. Streaming uploads go through `/writeFile`; this path is for
|
||||
// pre-v2 clients only.
|
||||
const BATCH_MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MiB per file
|
||||
const BATCH_MAX_FILES = 64;
|
||||
const BATCH_MAX_PARTS = 256;
|
||||
const BATCH_MAX_FIELD_SIZE = 1 * 1024 * 1024; // 1 MiB per operation/fileinfo JSON
|
||||
|
||||
async function loadAdditionalRouter(
|
||||
key: string,
|
||||
): Promise<RequestHandler | null> {
|
||||
@@ -73,17 +84,22 @@ async function loadAdditionalRouter(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Controller ──────────────────────────────────────────────────────
|
||||
// -- Controller ------------------------------------------------------
|
||||
|
||||
export class LegacyFSController extends PuterController {
|
||||
#additionalCache: RouterCache = new Map();
|
||||
|
||||
registerRoutes(router: PuterRouter): void {
|
||||
const apiOptions = { subdomain: 'api', requireVerified: true } as const;
|
||||
const apiOptions = {
|
||||
subdomain: 'api',
|
||||
requireVerified: true,
|
||||
} as RouteOptions;
|
||||
// Signed-URL routes: the handler validates the URL signature itself,
|
||||
// so no auth gate is applied (matches v1, which mounted these routers
|
||||
// with no middleware).
|
||||
const signedOptions = { subdomain: 'api' } as const;
|
||||
const signedOptions = {
|
||||
subdomain: 'api',
|
||||
} as RouteOptions;
|
||||
|
||||
// Core filesystem_api routes — direct handlers over the FS service.
|
||||
router.post('/stat', apiOptions, this.stat);
|
||||
@@ -98,7 +114,11 @@ export class LegacyFSController extends PuterController {
|
||||
router.get('/read', apiOptions, this.read);
|
||||
router.get(
|
||||
'/token-read',
|
||||
{ subdomain: 'api', requireVerified: false },
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireVerified: false,
|
||||
allowAccessToken: true,
|
||||
},
|
||||
this.tokenRead,
|
||||
);
|
||||
|
||||
@@ -185,35 +205,7 @@ export class LegacyFSController extends PuterController {
|
||||
res.json({ recommended, recent });
|
||||
});
|
||||
|
||||
router.post('/suggest_apps', apiOptions, async (req, res) => {
|
||||
const suggestSvc = this.services.suggestedApps;
|
||||
if (!suggestSvc?.getSuggestedApps) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
const body = req.body ?? {};
|
||||
// Client sends { uid } or { path } identifying a file entry.
|
||||
// Resolve the entry to get its name/path for extension detection.
|
||||
let entryName: string | undefined;
|
||||
let entryPath: string | undefined;
|
||||
if (body.uid || body.path) {
|
||||
try {
|
||||
const entry = await resolveV1Selector(
|
||||
this.stores.fsEntry,
|
||||
body,
|
||||
);
|
||||
entryName = entry?.name;
|
||||
entryPath = entry?.path;
|
||||
} catch {
|
||||
// If we can't resolve, fall back to empty
|
||||
}
|
||||
}
|
||||
const suggestions = await suggestSvc.getSuggestedApps({
|
||||
name: entryName,
|
||||
path: entryPath,
|
||||
});
|
||||
res.json(suggestions);
|
||||
});
|
||||
router.post('/suggest_apps', apiOptions, this.suggestApps);
|
||||
|
||||
// puter-js polls this to decide whether to purge its in-memory FS
|
||||
// cache. SocketService bumps a per-user Redis key on every
|
||||
@@ -241,70 +233,11 @@ export class LegacyFSController extends PuterController {
|
||||
},
|
||||
);
|
||||
|
||||
// ── POST /readdir-subdomains ────────────────────────────────
|
||||
router.post(
|
||||
'/readdir-subdomains',
|
||||
apiOptions,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = req.actor?.user?.id;
|
||||
if (!userId)
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT `subdomain`, `root_dir_id`, `uuid`, `ts` FROM `subdomains` WHERE `user_id` = ?',
|
||||
[userId],
|
||||
);
|
||||
res.json(rows);
|
||||
},
|
||||
);
|
||||
|
||||
// ── POST /update-fsentry-thumbnail ──────────────────────────
|
||||
router.post('/readdir-subdomains', apiOptions, this.readdirSubdomains);
|
||||
router.post(
|
||||
'/update-fsentry-thumbnail',
|
||||
apiOptions,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = req.actor?.user?.id;
|
||||
if (!userId)
|
||||
throw new HttpError(401, 'Authentication required', {
|
||||
legacyCode: 'unauthorized',
|
||||
});
|
||||
const { uid, thumbnail } = (req.body ?? {}) as {
|
||||
uid?: string;
|
||||
thumbnail?: string;
|
||||
};
|
||||
if (!uid)
|
||||
throw new HttpError(400, 'Missing `uid`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
if (!thumbnail)
|
||||
throw new HttpError(400, 'Missing `thumbnail`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const entry = await this.stores.fsEntry.getEntryByUuid(uid);
|
||||
if (!entry || entry.userId !== userId)
|
||||
throw new HttpError(403, 'Access denied', {
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
|
||||
// Emit thumbnail.created so the thumbnails extension can S3-upload.
|
||||
// emitAndWait is required — the extension rewrites `event.url`
|
||||
// from a data URL to an `s3://` pointer, and the DB write below
|
||||
// needs to see that rewrite.
|
||||
const event = { url: thumbnail };
|
||||
await this.clients.event.emitAndWait(
|
||||
'thumbnail.created',
|
||||
event,
|
||||
{},
|
||||
);
|
||||
|
||||
await this.clients.db.write(
|
||||
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
|
||||
[event.url, uid],
|
||||
);
|
||||
res.json({ thumbnail: event.url });
|
||||
},
|
||||
this.updateFsentryThumbnail,
|
||||
);
|
||||
|
||||
for (const key of Object.keys(additionalRoutePaths)) {
|
||||
@@ -318,7 +251,7 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Route implementations ───────────────────────────────────────────
|
||||
// -- Route implementations -------------------------------------------
|
||||
//
|
||||
// Handlers are public arrow class fields so they auto-bind `this` and can
|
||||
// be passed directly to `router.post(...)` without `.bind(this)`. Express
|
||||
@@ -746,8 +679,108 @@ export class LegacyFSController extends PuterController {
|
||||
res.send('');
|
||||
};
|
||||
|
||||
suggestApps = async (req: Request, res: Response): Promise<void> => {
|
||||
const suggestSvc = this.services.suggestedApps;
|
||||
if (!suggestSvc?.getSuggestedApps) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
const actor = this.#requireActor(req);
|
||||
const body = asRecord(req.body);
|
||||
let entryName: string | undefined;
|
||||
let entryPath: string | undefined;
|
||||
if (body.uid || body.path) {
|
||||
try {
|
||||
const entry = await resolveV1Selector(
|
||||
this.stores.fsEntry,
|
||||
body,
|
||||
);
|
||||
// Suggestions leak the entry's extension and existence;
|
||||
// gate on `see` so app actors can't probe outside scope.
|
||||
if (entry?.path) {
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
actor,
|
||||
entry.path,
|
||||
'see',
|
||||
);
|
||||
}
|
||||
entryName = entry?.name;
|
||||
entryPath = entry?.path;
|
||||
} catch {
|
||||
// Unresolvable or ACL-denied → empty suggestions.
|
||||
}
|
||||
}
|
||||
const suggestions = await suggestSvc.getSuggestedApps({
|
||||
name: entryName,
|
||||
path: entryPath,
|
||||
});
|
||||
res.json(suggestions);
|
||||
};
|
||||
|
||||
readdirSubdomains = async (req: Request, res: Response): Promise<void> => {
|
||||
const actor = this.#requireActor(req);
|
||||
// Subdomain enumeration is a user-level concern; app actors would
|
||||
// otherwise see root_dir uids pointing outside their AppData scope.
|
||||
if (effectiveActorApp(actor)) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
const userId = this.#getActorUserId(req);
|
||||
const rows = await this.clients.db.read(
|
||||
'SELECT `subdomain`, `root_dir_id`, `uuid`, `ts` FROM `subdomains` WHERE `user_id` = ?',
|
||||
[userId],
|
||||
);
|
||||
res.json(rows);
|
||||
};
|
||||
|
||||
updateFsentryThumbnail = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const actor = this.#requireActor(req);
|
||||
const { uid, thumbnail } = asRecord(req.body) as {
|
||||
uid?: string;
|
||||
thumbnail?: string;
|
||||
};
|
||||
if (!uid)
|
||||
throw new HttpError(400, 'Missing `uid`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
if (!thumbnail)
|
||||
throw new HttpError(400, 'Missing `thumbnail`', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const entry = await this.stores.fsEntry.getEntryByUuid(uid);
|
||||
if (!entry || !entry.path)
|
||||
throw new HttpError(404, `Entry not found: uid=${uid}`, {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
actor,
|
||||
entry.path,
|
||||
'write',
|
||||
);
|
||||
|
||||
// emitAndWait is required: the thumbnails extension rewrites
|
||||
// `event.url` from a data URL to an `s3://` pointer, and the DB
|
||||
// write below needs to see that rewrite.
|
||||
const event = { url: thumbnail };
|
||||
await this.clients.event.emitAndWait('thumbnail.created', event, {});
|
||||
|
||||
await this.clients.db.write(
|
||||
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
|
||||
[event.url, uid],
|
||||
);
|
||||
res.json({ thumbnail: event.url });
|
||||
};
|
||||
|
||||
search = async (req: Request, res: Response): Promise<void> => {
|
||||
this.#requireActor(req);
|
||||
const actor = this.#requireActor(req);
|
||||
const userId = this.#getActorUserId(req);
|
||||
const body = asRecord(req.body);
|
||||
const query = getString(body, 'query', 'text') ?? '';
|
||||
@@ -756,7 +789,21 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
const results = await this.services.fs.searchByName(userId, query, 200);
|
||||
// App-under-user actors only see entries within their AppData root;
|
||||
// user actors are unscoped. Mirrors the ACL short-circuit in
|
||||
// ACLService.check.
|
||||
const app = effectiveActorApp(actor);
|
||||
const username = actor.user?.username;
|
||||
const pathScope =
|
||||
app && typeof username === 'string' && username.length > 0
|
||||
? `/${username}/AppData/${app.uid}`
|
||||
: undefined;
|
||||
const results = await this.services.fs.searchByName(
|
||||
userId,
|
||||
query,
|
||||
200,
|
||||
pathScope,
|
||||
);
|
||||
const shaped = await Promise.all(
|
||||
results.map((r) => toLegacyEntry(this.clients.event, r)),
|
||||
);
|
||||
@@ -878,7 +925,7 @@ export class LegacyFSController extends PuterController {
|
||||
return this.read(req, res, { realMime: true });
|
||||
};
|
||||
|
||||
// ── Signed-URL + meta routes ────────────────────────────────────────
|
||||
// -- Signed-URL + meta routes ----------------------------------------
|
||||
|
||||
/**
|
||||
* POST /sign
|
||||
@@ -927,7 +974,10 @@ export class LegacyFSController extends PuterController {
|
||||
legacyCode: 'not_found',
|
||||
});
|
||||
grantApp = { uid: app.uid };
|
||||
result.token = this.services.auth.getUserAppToken(actor, app.uid);
|
||||
result.token = await this.services.auth.getUserAppToken(
|
||||
actor,
|
||||
app.uid,
|
||||
);
|
||||
}
|
||||
|
||||
for (const rawItem of items) {
|
||||
@@ -991,7 +1041,12 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
const signed = signEntry(entry, signingCfg);
|
||||
result.signatures.push({ ...signed, path: entry.path });
|
||||
if (finalAction !== 'write') {
|
||||
const { write_url: _, ...rest } = signed;
|
||||
result.signatures.push({ ...rest, path: entry.path });
|
||||
} else {
|
||||
result.signatures.push({ ...signed, path: entry.path });
|
||||
}
|
||||
} catch {
|
||||
// Silently skip unresolvable items.
|
||||
result.signatures.push({});
|
||||
@@ -1044,7 +1099,32 @@ export class LegacyFSController extends PuterController {
|
||||
const operation =
|
||||
typeof query.operation === 'string' ? query.operation : 'write';
|
||||
|
||||
// `write` — multipart upload, streamed directly to the v2 write path.
|
||||
// A valid write signature authorises overwriting the file's bytes,
|
||||
// not structural changes. Restrict copy/move/mkdir/rename/delete/trash
|
||||
// to a caller authenticated as the owner — otherwise a recipient of a
|
||||
// write-share could relocate or destroy the source via this endpoint.
|
||||
if (operation !== 'write' && req.actor?.user?.id !== userId) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
`'${operation}' via signed URL requires owner authentication`,
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
|
||||
// ACL re-check: require an authenticated actor with write
|
||||
// permission. A valid write signature alone is not sufficient —
|
||||
// the caller must also pass ACL, preventing exploitation of
|
||||
// leaked write URLs by read-only share recipients.
|
||||
const callerActor = this.#requireActor(req);
|
||||
if (operation === 'write') {
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
callerActor,
|
||||
targetEntry.path,
|
||||
'write',
|
||||
);
|
||||
}
|
||||
if (operation === 'write') {
|
||||
const body = asRecord(req.body);
|
||||
const parentEntry = targetEntry.isDir
|
||||
@@ -1077,8 +1157,18 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
// Non-write operations: route to existing service methods and sign the result.
|
||||
// The signature alone authorises only byte writes to `targetEntry`. Structural
|
||||
// ops (mkdir/rename/copy/move/delete) require a caller actor with explicit
|
||||
// ACL on the affected paths — mirroring the unsigned counterparts above.
|
||||
const record = asRecord(req.body);
|
||||
if (operation === 'mkdir') {
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
callerActor,
|
||||
targetEntry.path,
|
||||
'write',
|
||||
);
|
||||
const folderName =
|
||||
typeof record.name === 'string'
|
||||
? record.name
|
||||
@@ -1100,12 +1190,26 @@ export class LegacyFSController extends PuterController {
|
||||
throw new HttpError(400, '`new_name` required', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
callerActor,
|
||||
targetEntry.path,
|
||||
'write',
|
||||
);
|
||||
const renamed = await this.services.fs.rename(targetEntry, newName);
|
||||
await this.#emitGuiEvent('outer.gui.item.updated', renamed);
|
||||
res.json({ ...signEntry(renamed, signingCfg), path: renamed.path });
|
||||
return;
|
||||
}
|
||||
if (operation === 'delete' || operation === 'trash') {
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
callerActor,
|
||||
targetEntry.path,
|
||||
'write',
|
||||
);
|
||||
// Treat trash == delete (recursive). Most clients just call delete
|
||||
// directly; if a trash folder becomes important we can revisit.
|
||||
await this.services.fs.remove(userId, {
|
||||
@@ -1129,6 +1233,20 @@ export class LegacyFSController extends PuterController {
|
||||
this.stores.fsEntry,
|
||||
destRef,
|
||||
);
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
callerActor,
|
||||
targetEntry.path,
|
||||
operation === 'copy' ? 'read' : 'write',
|
||||
);
|
||||
await assertAccess(
|
||||
this.services.acl,
|
||||
this.services.fs,
|
||||
callerActor,
|
||||
destinationParent.path,
|
||||
'write',
|
||||
);
|
||||
const method = operation === 'copy' ? 'copy' : 'move';
|
||||
const result = await this.services.fs[method](userId, {
|
||||
source: targetEntry,
|
||||
@@ -1193,12 +1311,14 @@ export class LegacyFSController extends PuterController {
|
||||
}
|
||||
|
||||
// Directory: return a signed listing of direct children.
|
||||
// The caller only proved read access, so strip write_url from
|
||||
// each child to prevent privilege escalation via /writeFile.
|
||||
if (entry.isDir) {
|
||||
const children = await this.services.fs.listDirectory(entry.uuid);
|
||||
const signedChildren = children.map((child) => ({
|
||||
...signEntry(child, signingCfg),
|
||||
path: child.path,
|
||||
}));
|
||||
const signedChildren = children.map((child) => {
|
||||
const { write_url: _, ...rest } = signEntry(child, signingCfg);
|
||||
return { ...rest, path: child.path };
|
||||
});
|
||||
res.json(signedChildren);
|
||||
return;
|
||||
}
|
||||
@@ -1290,7 +1410,10 @@ export class LegacyFSController extends PuterController {
|
||||
{},
|
||||
{ reason: 'open_item' },
|
||||
);
|
||||
token = this.services.auth.getUserAppToken(actor, defaultAppUid);
|
||||
token = await this.services.auth.getUserAppToken(
|
||||
actor,
|
||||
defaultAppUid,
|
||||
);
|
||||
}
|
||||
|
||||
const signingCfg = signingConfigFromAppConfig(this.config);
|
||||
@@ -1460,7 +1583,7 @@ export class LegacyFSController extends PuterController {
|
||||
};
|
||||
|
||||
// Helpers for writeFile
|
||||
// ── GUI event emission ───────────────────────────────────────────
|
||||
// -- GUI event emission -------------------------------------------
|
||||
//
|
||||
// Fire-and-forget `outer.gui.item.*` events so SocketService,
|
||||
// BroadcastService, WorkerDriver (hot-reload), and cache-invalidation
|
||||
@@ -1590,7 +1713,7 @@ export class LegacyFSController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Batch route ─────────────────────────────────────────────────────
|
||||
// -- Batch route -----------------------------------------------------
|
||||
//
|
||||
// `/batch` interleaves multipart JSON operations with optional file
|
||||
// uploads. puter-js uses it for `write`, `shortcut`, `mkdir`, `move`,
|
||||
@@ -1899,7 +2022,46 @@ export class LegacyFSController extends PuterController {
|
||||
}> = [];
|
||||
const fileinfos: Array<Record<string, unknown>> = [];
|
||||
let parseError: Error | null = null;
|
||||
const bb = Busboy({ headers: req.headers });
|
||||
const bb = Busboy({
|
||||
headers: req.headers,
|
||||
limits: {
|
||||
fileSize: BATCH_MAX_FILE_SIZE,
|
||||
files: BATCH_MAX_FILES,
|
||||
parts: BATCH_MAX_PARTS,
|
||||
fieldSize: BATCH_MAX_FIELD_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
// Busboy emits these `*Limit` events when a configured cap is
|
||||
// hit. Capture the first one as a 413 so callers get a clean
|
||||
// signal instead of a silently-truncated upload.
|
||||
bb.on('filesLimit', () => {
|
||||
if (!parseError) {
|
||||
parseError = new HttpError(
|
||||
413,
|
||||
`Too many files in batch (max ${BATCH_MAX_FILES})`,
|
||||
{ legacyCode: 'too_large' as never },
|
||||
);
|
||||
}
|
||||
});
|
||||
bb.on('partsLimit', () => {
|
||||
if (!parseError) {
|
||||
parseError = new HttpError(
|
||||
413,
|
||||
`Too many parts in batch (max ${BATCH_MAX_PARTS})`,
|
||||
{ legacyCode: 'too_large' as never },
|
||||
);
|
||||
}
|
||||
});
|
||||
bb.on('fieldsLimit', () => {
|
||||
if (!parseError) {
|
||||
parseError = new HttpError(
|
||||
413,
|
||||
'Too many fields in batch',
|
||||
{ legacyCode: 'too_large' as never },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
bb.on('field', (fieldName, value) => {
|
||||
try {
|
||||
@@ -1926,8 +2088,24 @@ export class LegacyFSController extends PuterController {
|
||||
// For streaming uploads use the signed `/writeFile` endpoint.
|
||||
bb.on('file', (_fieldName, stream, info) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let truncated = false;
|
||||
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
// Busboy emits `limit` after writing the first byte past
|
||||
// `fileSize`. The stream continues being drained so the
|
||||
// multipart parser stays in sync, but we discard the (now
|
||||
// truncated) buffer and mark the batch as failed.
|
||||
stream.on('limit', () => {
|
||||
truncated = true;
|
||||
if (!parseError) {
|
||||
parseError = new HttpError(
|
||||
413,
|
||||
`File in batch exceeds ${BATCH_MAX_FILE_SIZE} bytes`,
|
||||
{ legacyCode: 'too_large' as never },
|
||||
);
|
||||
}
|
||||
});
|
||||
stream.on('end', () => {
|
||||
if (truncated) return;
|
||||
files.push({
|
||||
content: Buffer.concat(chunks),
|
||||
mimeType:
|
||||
@@ -1987,7 +2165,7 @@ export class LegacyFSController extends PuterController {
|
||||
return { error: true, status: 500, message: 'Unknown batch error' };
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
// -- Helpers ---------------------------------------------------------
|
||||
|
||||
#parsePositiveIntegerQuery(
|
||||
query: Record<string, unknown>,
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
|
||||
import type { FSService } from '../../services/fs/FSService.js';
|
||||
import type { ACLService, AclMode } from '../../services/acl/ACLService.js';
|
||||
import type { Actor } from '../../core/actor.js';
|
||||
import { isAppActor, type Actor } from '../../core/actor.js';
|
||||
import { Context } from '../../core/context.js';
|
||||
import type { EventClient } from '../../clients/event/EventClient.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
@@ -48,7 +48,7 @@ import type { IConfig } from '../../types.js';
|
||||
* terse.
|
||||
*/
|
||||
|
||||
// ── Body parsing ─────────────────────────────────────────────────────
|
||||
// -- Body parsing -----------------------------------------------------
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||
@@ -160,7 +160,7 @@ export async function resolveV1Selector(
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ── ACL ──────────────────────────────────────────────────────────────
|
||||
// -- ACL --------------------------------------------------------------
|
||||
|
||||
export async function assertAccess(
|
||||
aclService: ACLService,
|
||||
@@ -202,8 +202,8 @@ export async function assertAccess(
|
||||
// App-under-user actors see denials as 404 "subject_does_not_exist"
|
||||
// so existence of a sibling user's / other-app's files isn't leaked
|
||||
// through the error code. User-actor denials keep the real 403.
|
||||
const isAppActor = Boolean((actor as { app?: unknown })?.app);
|
||||
if (isAppActor) {
|
||||
|
||||
if (isAppActor(actor)) {
|
||||
throw new HttpError(404, `Entry not found: path=${path}`, {
|
||||
legacyCode: 'subject_does_not_exist',
|
||||
});
|
||||
@@ -261,7 +261,7 @@ export async function assertCanCreate(
|
||||
await assertAccess(aclService, fsService, actor, parentForCheck, 'write');
|
||||
}
|
||||
|
||||
// ── Response shaping ────────────────────────────────────────────────
|
||||
// -- Response shaping ------------------------------------------------
|
||||
|
||||
type AppRowLookup = {
|
||||
getByIds: (ids: number[]) => Promise<Map<number, Record<string, unknown>>>;
|
||||
@@ -461,7 +461,7 @@ export async function toLegacyEntry(
|
||||
|
||||
export { normalizeAbsolutePath };
|
||||
|
||||
// ── Signing ─────────────────────────────────────────────────────────
|
||||
// -- Signing ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pull the signing config off the app config. Throws if either value is
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface ThumbnailUploadPrepareItem {
|
||||
size?: number;
|
||||
uploadUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
item_uid: string;
|
||||
}
|
||||
|
||||
export interface ThumbnailUploadPreparePayload {
|
||||
|
||||
@@ -72,7 +72,7 @@ export class HomepageController extends PuterController {
|
||||
await homepage.send({ req, res, actor }, meta, launch);
|
||||
};
|
||||
|
||||
// ── Root + path-aliased shell routes ────────────────────────
|
||||
// -- Root + path-aliased shell routes ------------------------
|
||||
|
||||
router.get('/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
@@ -86,7 +86,7 @@ export class HomepageController extends PuterController {
|
||||
|
||||
router.get('/@:username', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
// ── /app/:name ─ app metadata baked into the shell ──────────
|
||||
// -- /app/:name - app metadata baked into the shell ----------
|
||||
|
||||
router.get('/app/:name', {}, async (req, res) => {
|
||||
const name = String(req.params.name ?? '');
|
||||
@@ -122,7 +122,7 @@ export class HomepageController extends PuterController {
|
||||
});
|
||||
});
|
||||
|
||||
// ── /show/* ─ launch explorer with the requested file path ──
|
||||
// -- /show/* - launch explorer with the requested file path --
|
||||
|
||||
router.get('/show/*splat', {}, (req, res) => {
|
||||
const filePath = req.path.slice('/show'.length);
|
||||
@@ -138,7 +138,7 @@ export class HomepageController extends PuterController {
|
||||
return sendShell(req, res, {}, launch);
|
||||
});
|
||||
|
||||
// ── Fallback static mount ───────────────────────────────────
|
||||
// -- Fallback static mount -----------------------------------
|
||||
// Serves lingering GUI assets (images, fonts, lib files, etc.)
|
||||
// out of <gui_assets_root>/src. Falls through to the 404 handler
|
||||
// when the file doesn't exist.
|
||||
|
||||
@@ -37,7 +37,7 @@ export class HostingController extends PuterController {
|
||||
}
|
||||
|
||||
registerRoutes(router) {
|
||||
// ── Delete site ─────────────────────────────────────────────
|
||||
// -- Delete site ---------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/delete-site',
|
||||
|
||||
@@ -38,7 +38,7 @@ export class NotificationController extends PuterController {
|
||||
* POST /notif/mark-ack — user dismissed a notification.
|
||||
* Sets `acknowledged` timestamp; pushes ack event to sockets.
|
||||
*/
|
||||
@Post('/mark-ack', { subdomain: 'api', requireAuth: true })
|
||||
@Post('/mark-ack', { subdomain: 'api', requireUserActor: true })
|
||||
async markAck(req: Request, res: Response): Promise<void> {
|
||||
const uid = req.body?.uid;
|
||||
if (typeof uid !== 'string' || uid.length === 0) {
|
||||
@@ -77,7 +77,7 @@ export class NotificationController extends PuterController {
|
||||
* POST /notif/mark-read — user saw a notification.
|
||||
* Sets `shown` timestamp; pushes ack event to sockets.
|
||||
*/
|
||||
@Post('/mark-read', { subdomain: 'api', requireAuth: true })
|
||||
@Post('/mark-read', { subdomain: 'api', requireUserActor: true })
|
||||
async markRead(req: Request, res: Response): Promise<void> {
|
||||
const uid = req.body?.uid;
|
||||
if (typeof uid !== 'string' || uid.length === 0) {
|
||||
|
||||
@@ -93,7 +93,7 @@ function isSameOrigin(target: string, origin: string): boolean {
|
||||
*/
|
||||
export class OIDCController extends PuterController {
|
||||
registerRoutes(router: PuterRouter): void {
|
||||
// ── GET /auth/oidc/providers ────────────────────────────────
|
||||
// -- GET /auth/oidc/providers --------------------------------
|
||||
// Public — list enabled provider IDs for the frontend.
|
||||
|
||||
router.get(
|
||||
@@ -106,7 +106,7 @@ export class OIDCController extends PuterController {
|
||||
},
|
||||
);
|
||||
|
||||
// ── GET /auth/oidc/:provider/start ──────────────────────────
|
||||
// -- GET /auth/oidc/:provider/start --------------------------
|
||||
// Redirect user to IdP authorization endpoint.
|
||||
|
||||
router.get(
|
||||
@@ -215,7 +215,7 @@ export class OIDCController extends PuterController {
|
||||
},
|
||||
);
|
||||
|
||||
// ── /auth/oidc/callback/login (GET + POST) ────────────────
|
||||
// -- /auth/oidc/callback/login (GET + POST) ----------------
|
||||
|
||||
const cbOpts = {
|
||||
subdomain: '',
|
||||
@@ -283,7 +283,7 @@ export class OIDCController extends PuterController {
|
||||
router.get('/auth/oidc/callback/login', cbOpts, loginCb);
|
||||
router.post('/auth/oidc/callback/login', cbOpts, loginCb);
|
||||
|
||||
// ── /auth/oidc/callback/signup (GET + POST) ────────────────
|
||||
// -- /auth/oidc/callback/signup (GET + POST) ----------------
|
||||
|
||||
const signupCb = async (req: Request, res: Response) => {
|
||||
const origin = this.config.origin ?? '';
|
||||
@@ -346,7 +346,7 @@ export class OIDCController extends PuterController {
|
||||
router.get('/auth/oidc/callback/signup', cbOpts, signupCb);
|
||||
router.post('/auth/oidc/callback/signup', cbOpts, signupCb);
|
||||
|
||||
// ── /auth/oidc/callback/revalidate (GET + POST) ────────────
|
||||
// -- /auth/oidc/callback/revalidate (GET + POST) ------------
|
||||
|
||||
const revalidateCb = async (
|
||||
req: Request,
|
||||
@@ -404,7 +404,7 @@ export class OIDCController extends PuterController {
|
||||
router.get('/auth/oidc/callback/revalidate', cbOpts, revalidateCb);
|
||||
router.post('/auth/oidc/callback/revalidate', cbOpts, revalidateCb);
|
||||
|
||||
// ── GET /auth/revalidate-done ───────────────────────────────
|
||||
// -- GET /auth/revalidate-done -------------------------------
|
||||
// Landing page after revalidation; posts to opener for popup flow.
|
||||
|
||||
router.get(
|
||||
@@ -428,7 +428,7 @@ if (window.opener) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared helpers ──────────────────────────────────────────────
|
||||
// -- Shared helpers ----------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve an OIDC callback to a Puter user. In order:
|
||||
|
||||
@@ -73,11 +73,6 @@ const makeUserActor = (): Actor => ({
|
||||
user: { id: 7, uuid: 'u-7', username: 'alice' },
|
||||
});
|
||||
|
||||
const makeAppActor = (): Actor => ({
|
||||
user: { id: 7, uuid: 'u-7', username: 'alice' },
|
||||
app: { id: 1, uid: 'app-uid' },
|
||||
});
|
||||
|
||||
interface CapturedResponse {
|
||||
statusCode: number;
|
||||
body: unknown;
|
||||
@@ -193,13 +188,23 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
const chatRoute = calls.find(
|
||||
(c) => c.path === '/puterai/openai/v1/chat/completions',
|
||||
);
|
||||
expect(chatRoute?.opts).toEqual({
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
});
|
||||
// The four upstream-proxy routes are user-only: app actors must
|
||||
// call puter-chat-completion directly. The `requireUserActor`
|
||||
// route option wires up the gate that enforces this, so each
|
||||
// proxy route gets the assertion (not just chat/completions).
|
||||
const userOnlyPaths = [
|
||||
'/puterai/openai/v1/chat/completions',
|
||||
'/puterai/openai/v1/completions',
|
||||
'/puterai/openai/v1/responses',
|
||||
'/puterai/anthropic/v1/messages',
|
||||
];
|
||||
for (const path of userOnlyPaths) {
|
||||
const route = calls.find((c) => c.path === path);
|
||||
expect(route?.opts).toEqual({
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
});
|
||||
}
|
||||
const modelsRoute = calls.find(
|
||||
(c) => c.path === '/puterai/chat/models',
|
||||
);
|
||||
@@ -213,18 +218,10 @@ describe('PuterAIController.registerRoutes', () => {
|
||||
// ── /openai/v1/chat/completions ─────────────────────────────────────
|
||||
|
||||
describe('PuterAIController.openaiChatCompletions', () => {
|
||||
it('rejects app actors with HttpError 403', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
controller.openaiChatCompletions(
|
||||
makeReq({
|
||||
body: { messages: [], model: 'gpt-test' },
|
||||
actor: makeAppActor(),
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
// Note: app-actor rejection (403) is enforced by the `requireUserActor`
|
||||
// route gate and is asserted in the registerRoutes test above. The
|
||||
// handler itself no longer does that check, so unit-testing it here
|
||||
// would require invoking the gate stack.
|
||||
|
||||
it('rejects bodies missing a messages array with HttpError 400', async () => {
|
||||
const { res } = makeRes();
|
||||
@@ -272,13 +269,13 @@ describe('PuterAIController.openaiChatCompletions', () => {
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.object).toBe('chat.completion');
|
||||
expect(body.model).toBe('gpt-test');
|
||||
expect((body.choices as Array<Record<string, unknown>>)[0]).toMatchObject(
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'hi there' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(body.choices as Array<Record<string, unknown>>)[0],
|
||||
).toMatchObject({
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'hi there' },
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
// total_tokens is computed from prompt + completion.
|
||||
expect(body.usage).toEqual({
|
||||
prompt_tokens: 4,
|
||||
@@ -392,18 +389,8 @@ describe('PuterAIController.openaiChatCompletions', () => {
|
||||
// ── /openai/v1/completions ──────────────────────────────────────────
|
||||
|
||||
describe('PuterAIController.openaiCompletions', () => {
|
||||
it('rejects app actors with HttpError 403', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
controller.openaiCompletions(
|
||||
makeReq({
|
||||
body: { prompt: 'hi', model: 'gpt-test' },
|
||||
actor: makeAppActor(),
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
// App-actor rejection lives in the `requireUserActor` gate now;
|
||||
// see the registerRoutes assertion.
|
||||
|
||||
it('rejects a non-string prompt with HttpError 400', async () => {
|
||||
const { res } = makeRes();
|
||||
@@ -443,13 +430,13 @@ describe('PuterAIController.openaiCompletions', () => {
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.object).toBe('text_completion');
|
||||
expect((body.choices as Array<Record<string, unknown>>)[0]).toMatchObject(
|
||||
{
|
||||
text: 'response',
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(body.choices as Array<Record<string, unknown>>)[0],
|
||||
).toMatchObject({
|
||||
text: 'response',
|
||||
index: 0,
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
expect((body.id as string).startsWith('cmpl-')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -457,18 +444,8 @@ describe('PuterAIController.openaiCompletions', () => {
|
||||
// ── /openai/v1/responses ────────────────────────────────────────────
|
||||
|
||||
describe('PuterAIController.openaiResponses', () => {
|
||||
it('rejects app actors with HttpError 403', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
controller.openaiResponses(
|
||||
makeReq({
|
||||
body: { input: 'hi', model: 'gpt-test' },
|
||||
actor: makeAppActor(),
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
// App-actor rejection lives in the `requireUserActor` gate now;
|
||||
// see the registerRoutes assertion.
|
||||
|
||||
it('rejects providers other than openai-responses with HttpError 400', async () => {
|
||||
const { res } = makeRes();
|
||||
@@ -537,18 +514,8 @@ describe('PuterAIController.openaiResponses', () => {
|
||||
// ── /anthropic/v1/messages ──────────────────────────────────────────
|
||||
|
||||
describe('PuterAIController.anthropicMessages', () => {
|
||||
it('rejects app actors with HttpError 403', async () => {
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
controller.anthropicMessages(
|
||||
makeReq({
|
||||
body: { messages: [], model: 'claude-test' },
|
||||
actor: makeAppActor(),
|
||||
}),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
// App-actor rejection lives in the `requireUserActor` gate now;
|
||||
// see the registerRoutes assertion.
|
||||
|
||||
it('rejects bodies missing a messages array with HttpError 400', async () => {
|
||||
const { res } = makeRes();
|
||||
@@ -655,9 +622,8 @@ describe('PuterAIController model listing', () => {
|
||||
const captureGetHandler = (
|
||||
path: string,
|
||||
): ((req: Request, res: Response) => Promise<void>) => {
|
||||
let handler:
|
||||
| ((req: Request, res: Response) => Promise<void>)
|
||||
| null = null;
|
||||
let handler: ((req: Request, res: Response) => Promise<void>) | null =
|
||||
null;
|
||||
const router = {
|
||||
post: vi.fn(),
|
||||
get: vi.fn((p: string, _opts: unknown, h: never) => {
|
||||
@@ -719,9 +685,8 @@ describe('PuterAIController videoProxy', () => {
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => Promise<void>) => {
|
||||
let handler:
|
||||
| ((req: Request, res: Response) => Promise<void>)
|
||||
| null = null;
|
||||
let handler: ((req: Request, res: Response) => Promise<void>) | null =
|
||||
null;
|
||||
const router = {
|
||||
post: vi.fn(),
|
||||
get: vi.fn((path: string, _opts: unknown, h: never) => {
|
||||
@@ -799,8 +764,9 @@ describe('PuterAIController videoProxy', () => {
|
||||
it('500s when url_signature_secret is not configured', async () => {
|
||||
// Temporarily blank the secret so the controller hits the
|
||||
// 500 branch instead of the constant-time-compare gate.
|
||||
const cfg = (controller as unknown as { config: Record<string, unknown> })
|
||||
.config;
|
||||
const cfg = (
|
||||
controller as unknown as { config: Record<string, unknown> }
|
||||
).config;
|
||||
const orig = cfg.url_signature_secret;
|
||||
cfg.url_signature_secret = undefined;
|
||||
try {
|
||||
@@ -826,8 +792,9 @@ describe('PuterAIController videoProxy', () => {
|
||||
// Hit the post-signature `provider !== 'gemini'` branch by
|
||||
// computing a valid signature for a known fileId/expires combo
|
||||
// and then sending a different provider in the query.
|
||||
const cfg = (controller as unknown as { config: Record<string, unknown> })
|
||||
.config;
|
||||
const cfg = (
|
||||
controller as unknown as { config: Record<string, unknown> }
|
||||
).config;
|
||||
const secret = cfg.url_signature_secret as string;
|
||||
const fileId = 'abc-123';
|
||||
const expires = String(Math.floor(Date.now() / 1000) + 60);
|
||||
@@ -854,11 +821,16 @@ describe('PuterAIController videoProxy', () => {
|
||||
});
|
||||
|
||||
it('500s when provider=gemini but no Gemini API key is configured', async () => {
|
||||
const cfg = (controller as unknown as {
|
||||
config: Record<string, unknown> & {
|
||||
providers?: Record<string, Record<string, unknown> | undefined>;
|
||||
};
|
||||
}).config;
|
||||
const cfg = (
|
||||
controller as unknown as {
|
||||
config: Record<string, unknown> & {
|
||||
providers?: Record<
|
||||
string,
|
||||
Record<string, unknown> | undefined
|
||||
>;
|
||||
};
|
||||
}
|
||||
).config;
|
||||
const secret = cfg.url_signature_secret as string;
|
||||
const fileId = 'gemini-no-key';
|
||||
const expires = String(Math.floor(Date.now() / 1000) + 60);
|
||||
@@ -1425,7 +1397,11 @@ describe('PuterAIController.anthropicMessages streaming + helpers', () => {
|
||||
);
|
||||
const tools = completeSpy.mock.calls[0]![0].tools as Array<{
|
||||
type: string;
|
||||
function: { name: string; description: string; parameters: unknown };
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: unknown;
|
||||
};
|
||||
}>;
|
||||
expect(tools[0]?.type).toBe('function');
|
||||
expect(tools[0]?.function.name).toBe('lookup');
|
||||
@@ -1483,9 +1459,7 @@ describe('PuterAIController.anthropicMessages streaming + helpers', () => {
|
||||
res,
|
||||
);
|
||||
const body = captured.body as { content: Array<{ text: string }> };
|
||||
expect(body.content).toEqual([
|
||||
{ type: 'text', text: 'hello world' },
|
||||
]);
|
||||
expect(body.content).toEqual([{ type: 'text', text: 'hello world' }]);
|
||||
});
|
||||
|
||||
it('reads tool_use blocks from message.content (not just message.tool_calls)', async () => {
|
||||
@@ -1536,9 +1510,8 @@ describe('PuterAIController model details', () => {
|
||||
const captureGetHandler = (
|
||||
path: string,
|
||||
): ((req: Request, res: Response) => Promise<void>) => {
|
||||
let handler:
|
||||
| ((req: Request, res: Response) => Promise<void>)
|
||||
| null = null;
|
||||
let handler: ((req: Request, res: Response) => Promise<void>) | null =
|
||||
null;
|
||||
const router = {
|
||||
post: vi.fn(),
|
||||
get: vi.fn((p: string, _opts: unknown, h: never) => {
|
||||
|
||||
@@ -17,19 +17,19 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { Request, Response } from 'express';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { isAppActor } from '../../core/actor.js';
|
||||
import { RouteOptions } from '../../core/http/index.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { isDriverStreamResult } from '../../drivers/meta.js';
|
||||
import type { ChatCompletionDriver } from '../../drivers/ai-chat/ChatCompletionDriver.js';
|
||||
import type {
|
||||
ICompleteArguments,
|
||||
IChatCompleteResult,
|
||||
ICompleteArguments,
|
||||
} from '../../drivers/ai-chat/types.js';
|
||||
import { isDriverStreamResult } from '../../drivers/meta.js';
|
||||
import { PuterController } from '../types.js';
|
||||
|
||||
const GEMINI_DOWNLOAD_BASE =
|
||||
'https://generativelanguage.googleapis.com/download/v1beta/files';
|
||||
@@ -49,7 +49,10 @@ const GEMINI_DOWNLOAD_BASE =
|
||||
*/
|
||||
export class PuterAIController extends PuterController {
|
||||
registerRoutes(router: PuterRouter): void {
|
||||
const apiAuthOpts = { subdomain: 'api', requireAuth: true } as const;
|
||||
const apiAuthOpts = {
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
} as RouteOptions;
|
||||
const publicOpts = { subdomain: 'api', requireAuth: false } as const;
|
||||
|
||||
// Every route below carries the `/puterai` prefix for wire
|
||||
@@ -107,7 +110,7 @@ export class PuterAIController extends PuterController {
|
||||
this.#modelDetails('aiVideo'),
|
||||
);
|
||||
|
||||
// ── Video URL proxy ─────────────────────────────────────────
|
||||
// -- Video URL proxy -----------------------------------------
|
||||
// Reverse-proxies AI-generated video URLs that can't be given
|
||||
// directly to the client (auth-gated provider downloads). The
|
||||
// URL itself is HMAC-signed, so no additional auth gate.
|
||||
@@ -223,14 +226,12 @@ export class PuterAIController extends PuterController {
|
||||
};
|
||||
}
|
||||
|
||||
// ── /openai/v1/chat/completions ─────────────────────────────────
|
||||
// -- /openai/v1/chat/completions ---------------------------------
|
||||
|
||||
openaiChatCompletions = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
this.#rejectAppActor(req);
|
||||
|
||||
const body = asRecord(req.body);
|
||||
const stream = !!body.stream;
|
||||
|
||||
@@ -393,11 +394,9 @@ export class PuterAIController extends PuterController {
|
||||
});
|
||||
};
|
||||
|
||||
// ── /openai/v1/completions ──────────────────────────────────────
|
||||
// -- /openai/v1/completions --------------------------------------
|
||||
|
||||
openaiCompletions = async (req: Request, res: Response): Promise<void> => {
|
||||
this.#rejectAppActor(req);
|
||||
|
||||
const body = asRecord(req.body);
|
||||
const stream = !!body.stream;
|
||||
|
||||
@@ -523,11 +522,9 @@ export class PuterAIController extends PuterController {
|
||||
});
|
||||
};
|
||||
|
||||
// ── /openai/v1/responses ────────────────────────────────────────
|
||||
// -- /openai/v1/responses ----------------------------------------
|
||||
|
||||
openaiResponses = async (req: Request, res: Response): Promise<void> => {
|
||||
this.#rejectAppActor(req);
|
||||
|
||||
const body = asRecord(req.body);
|
||||
const stream = !!body.stream;
|
||||
|
||||
@@ -835,11 +832,9 @@ export class PuterAIController extends PuterController {
|
||||
);
|
||||
};
|
||||
|
||||
// ── /anthropic/v1/messages ──────────────────────────────────────
|
||||
// -- /anthropic/v1/messages --------------------------------------
|
||||
|
||||
anthropicMessages = async (req: Request, res: Response): Promise<void> => {
|
||||
this.#rejectAppActor(req);
|
||||
|
||||
const body = asRecord(req.body);
|
||||
const stream = !!body.stream;
|
||||
|
||||
@@ -1042,7 +1037,7 @@ export class PuterAIController extends PuterController {
|
||||
});
|
||||
};
|
||||
|
||||
// ── Internals ───────────────────────────────────────────────────
|
||||
// -- Internals ---------------------------------------------------
|
||||
|
||||
#driver(): ChatCompletionDriver {
|
||||
const driver = this.drivers.aiChat;
|
||||
@@ -1052,20 +1047,9 @@ export class PuterAIController extends PuterController {
|
||||
});
|
||||
return driver;
|
||||
}
|
||||
|
||||
#rejectAppActor(req: Request): void {
|
||||
// Proxy routes are user-only; apps must call puter-chat-completion directly.
|
||||
if (isAppActor(req.actor)) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
'App actors may not proxy to upstream AI APIs',
|
||||
{ legacyCode: 'forbidden' },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared helpers ──────────────────────────────────────────────────
|
||||
// -- Shared helpers --------------------------------------------------
|
||||
|
||||
const DEFAULTS = {
|
||||
openaiChat: 'openai-completion',
|
||||
@@ -1151,7 +1135,7 @@ const pipeNdjsonStream = (
|
||||
stream.on('error', opts.onError);
|
||||
};
|
||||
|
||||
// ── OpenAI/Anthropic shape helpers ───────────────────────────────────
|
||||
// -- OpenAI/Anthropic shape helpers -----------------------------------
|
||||
|
||||
const extractTextContent = (content: unknown): string => {
|
||||
if (content === undefined || content === null) return '';
|
||||
@@ -1270,7 +1254,7 @@ const getPromptText = (prompt: unknown): string => {
|
||||
return prompt;
|
||||
};
|
||||
|
||||
// ── OpenAI /responses input → message list ──────────────────────────
|
||||
// -- OpenAI /responses input → message list --------------------------
|
||||
|
||||
const parseJsonMaybe = (value: unknown): unknown => {
|
||||
if (typeof value !== 'string') return value ?? {};
|
||||
@@ -1379,7 +1363,7 @@ const responseInputToMessages = (input: unknown): unknown[] => {
|
||||
return messages;
|
||||
};
|
||||
|
||||
// ── OpenAI /responses result → output items ─────────────────────────
|
||||
// -- OpenAI /responses result → output items -------------------------
|
||||
|
||||
const responseOutputFromResult = (
|
||||
result: Extract<IChatCompleteResult, { message?: unknown }>,
|
||||
@@ -1500,7 +1484,7 @@ const normalizeResponsesTool = (tool: unknown): unknown => {
|
||||
return { ...(t.function as Record<string, unknown>), type: 'function' };
|
||||
};
|
||||
|
||||
// ── Anthropic → internal messages ───────────────────────────────────
|
||||
// -- Anthropic → internal messages -----------------------------------
|
||||
|
||||
const normalizeAnthropicTools = (tools: unknown): unknown[] | undefined => {
|
||||
if (!Array.isArray(tools) || tools.length === 0) return undefined;
|
||||
|
||||
@@ -51,7 +51,7 @@ export class ShareController extends PuterController {
|
||||
// router.post('/share', { ...api, requireAuth: true }, this.#share);
|
||||
}
|
||||
|
||||
// // ── POST /sharelink/check ───────────────────────────────────────
|
||||
// // -- POST /sharelink/check ---------------------------------------
|
||||
// // Public — verify a share token from an email link.
|
||||
|
||||
// #check = async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -80,7 +80,7 @@ export class ShareController extends PuterController {
|
||||
// });
|
||||
// };
|
||||
|
||||
// // ── POST /sharelink/apply ───────────────────────────────────────
|
||||
// // -- POST /sharelink/apply ---------------------------------------
|
||||
// // Auth required — apply a pending share's permissions to the caller.
|
||||
|
||||
// #apply = async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -158,7 +158,7 @@ export class ShareController extends PuterController {
|
||||
// res.json({ $: 'api:status-report', status: 'success' });
|
||||
// };
|
||||
|
||||
// // ── POST /sharelink/request ─────────────────────────────────────
|
||||
// // -- POST /sharelink/request -------------------------------------
|
||||
// // Auth required — notify the issuer that someone is requesting access.
|
||||
|
||||
// #request = async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -207,7 +207,7 @@ export class ShareController extends PuterController {
|
||||
// res.json({ $: 'api:status-report', status: 'success' });
|
||||
// };
|
||||
|
||||
// // ── POST /share ─────────────────────────────────────────────────
|
||||
// // -- POST /share -------------------------------------------------
|
||||
// // Auth required — create shares for recipients (users or emails).
|
||||
|
||||
// #share = async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -355,7 +355,7 @@ export class ShareController extends PuterController {
|
||||
// });
|
||||
// };
|
||||
|
||||
// // ── Helpers ──────────────────────────────────────────────────────
|
||||
// // -- Helpers ------------------------------------------------------
|
||||
|
||||
// /**
|
||||
// * Convert share declarations into a flat permission list.
|
||||
|
||||
@@ -140,7 +140,7 @@ export class StaticPagesController extends PuterController {
|
||||
page('✕', 'Something went wrong', msg, '#e53e3e');
|
||||
const ok = (msg: string) => page('✓', 'Success', msg, '#38a169');
|
||||
|
||||
// ── /robots.txt ─────────────────────────────────────────────
|
||||
// -- /robots.txt ---------------------------------------------
|
||||
router.get('/robots.txt', {}, (req, res) => {
|
||||
const domain = this.config.domain ?? req.hostname;
|
||||
const disallowed = [
|
||||
@@ -160,7 +160,7 @@ export class StaticPagesController extends PuterController {
|
||||
res.type('text/plain').send(body);
|
||||
});
|
||||
|
||||
// ── /sitemap.xml ────────────────────────────────────────────
|
||||
// -- /sitemap.xml --------------------------------------------
|
||||
router.get('/sitemap.xml', {}, async (req, res) => {
|
||||
const domain = this.config.domain ?? req.hostname;
|
||||
const origin = `${req.protocol}://${domain}`;
|
||||
@@ -181,7 +181,7 @@ export class StaticPagesController extends PuterController {
|
||||
res.type('application/xml').send(body);
|
||||
});
|
||||
|
||||
// ── /unsubscribe ────────────────────────────────────────────
|
||||
// -- /unsubscribe --------------------------------------------
|
||||
router.get('/unsubscribe', {}, async (req, res) => {
|
||||
const userUuid =
|
||||
typeof req.query.user_uuid === 'string'
|
||||
@@ -206,7 +206,7 @@ export class StaticPagesController extends PuterController {
|
||||
res.send(ok('You have successfully unsubscribed from all emails.'));
|
||||
});
|
||||
|
||||
// ── /confirm-email-by-token ─────────────────────────────────
|
||||
// -- /confirm-email-by-token ---------------------------------
|
||||
router.get('/confirm-email-by-token', {}, async (req, res) => {
|
||||
const userUuid =
|
||||
typeof req.query.user_uuid === 'string'
|
||||
|
||||
@@ -35,7 +35,7 @@ export class SystemController extends PuterController {
|
||||
/** @type {import('../../core/http/PuterRouter.js').PuterRouter} */
|
||||
router,
|
||||
) {
|
||||
// ── Healthcheck ─────────────────────────────────────────────
|
||||
// -- 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
|
||||
@@ -51,7 +51,7 @@ export class SystemController extends PuterController {
|
||||
return res.json(status);
|
||||
});
|
||||
|
||||
// ── Version ─────────────────────────────────────────────────
|
||||
// -- Version -------------------------------------------------
|
||||
|
||||
router.get('/version', { subdomain: '*' }, (_req, res) => {
|
||||
const version =
|
||||
@@ -70,7 +70,7 @@ export class SystemController extends PuterController {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Contact us ──────────────────────────────────────────────
|
||||
// -- Contact us ----------------------------------------------
|
||||
|
||||
router.post(
|
||||
'/contactUs',
|
||||
@@ -129,7 +129,7 @@ export class SystemController extends PuterController {
|
||||
},
|
||||
);
|
||||
|
||||
// ── GET /whoarewe ───────────────────────────────────────────
|
||||
// -- GET /whoarewe -------------------------------------------
|
||||
|
||||
router.get('/whoarewe', {}, (_req, res) => {
|
||||
res.json({
|
||||
@@ -139,7 +139,7 @@ export class SystemController extends PuterController {
|
||||
});
|
||||
});
|
||||
|
||||
// ── GET /lsmod ──────────────────────────────────────────────
|
||||
// -- GET /lsmod ----------------------------------------------
|
||||
// Enumerates driver interfaces and their implementors.
|
||||
|
||||
router.get(
|
||||
|
||||
@@ -92,7 +92,10 @@ export const PuterController =
|
||||
public onServerShutdown() {
|
||||
return;
|
||||
}
|
||||
public async getReportedCosts() {
|
||||
public getReportedCosts(): // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Promise<Record<string, any>[]>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
| Record<string, any>[] {
|
||||
return [];
|
||||
}
|
||||
public registerRoutes(_router: PuterRouter) {}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// This suite tests basic features of puter webdav. it is not a comprehensive webdav test suite unlike litmus
|
||||
// but rather it performs some common sense checks to ensure that WebDAV support isn't irrevocably broken in puter
|
||||
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 { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import type { WebDAVController } from './WebDAVController.js';
|
||||
|
||||
let server: PuterServer;
|
||||
@@ -101,6 +104,35 @@ const basicAuth = (user: string, pass: string) =>
|
||||
|
||||
const noop = vi.fn();
|
||||
|
||||
const makeUser = async () => {
|
||||
const username = `webdav-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const created = await server.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
free_storage: 100 * 1024 * 1024,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
await generateDefaultFsentries(
|
||||
server.clients.db,
|
||||
server.stores.user,
|
||||
created,
|
||||
);
|
||||
const refreshed = (await server.stores.user.getById(created.id))!;
|
||||
return {
|
||||
userId: refreshed.id,
|
||||
username: refreshed.username,
|
||||
actor: {
|
||||
user: {
|
||||
id: refreshed.id,
|
||||
uuid: refreshed.uuid,
|
||||
username: refreshed.username,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('WebDAVController', () => {
|
||||
describe('route registration', () => {
|
||||
it('registers a single catch-all use() route', () => {
|
||||
@@ -396,6 +428,53 @@ describe('WebDAVController', () => {
|
||||
);
|
||||
expect(captured.statusCode).toBe(422);
|
||||
});
|
||||
|
||||
it('emits GUI-safe item events without leaking numeric fsentry ids', async () => {
|
||||
const { actor, userId, username } = await makeUser();
|
||||
const target = `/${username}/Documents/webdav-event.txt`;
|
||||
const { res, captured } = makeRes();
|
||||
const req = Object.assign(
|
||||
Readable.from(['hello']),
|
||||
makeReq({
|
||||
method: 'PUT',
|
||||
path: target,
|
||||
headers: { 'content-length': '5' },
|
||||
actor,
|
||||
}),
|
||||
) as Request;
|
||||
|
||||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||||
let addedCall:
|
||||
| (typeof emitSpy.mock.calls)[number]
|
||||
| undefined;
|
||||
try {
|
||||
await dispatchMiddleware(req, res, noop);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
addedCall = emitSpy.mock.calls.find(
|
||||
([eventName]) => eventName === 'outer.gui.item.added',
|
||||
);
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(captured.statusCode).toBe(201);
|
||||
expect(addedCall).toBeTruthy();
|
||||
const payload = addedCall?.[1] as {
|
||||
user_id_list?: number[];
|
||||
response?: Record<string, unknown>;
|
||||
};
|
||||
expect(payload.user_id_list).toEqual([userId]);
|
||||
expect(payload.response).toMatchObject({
|
||||
id: expect.any(String),
|
||||
uid: expect.any(String),
|
||||
uuid: expect.any(String),
|
||||
path: target,
|
||||
from_new_service: true,
|
||||
});
|
||||
expect(typeof payload.response?.id).toBe('string');
|
||||
expect(payload.response?.id).toBe(payload.response?.uuid);
|
||||
expect(payload.response).not.toHaveProperty('userId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
@@ -516,7 +595,7 @@ describe('WebDAVController', () => {
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'LOCK',
|
||||
path: '/lockable-file.txt',
|
||||
path: '/test/lockable-file.txt',
|
||||
actor: {
|
||||
user: {
|
||||
id: 1,
|
||||
@@ -539,8 +618,28 @@ describe('WebDAVController', () => {
|
||||
expect(captured.headers['lock-token']).toContain('urn:uuid:');
|
||||
});
|
||||
|
||||
it('rejects locking a path the user has no write access to (e.g. root)', async () => {
|
||||
const { res, captured } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
method: 'LOCK',
|
||||
path: '/',
|
||||
actor: {
|
||||
user: {
|
||||
id: 1,
|
||||
uuid: 'test-uuid',
|
||||
username: 'test',
|
||||
},
|
||||
},
|
||||
}),
|
||||
res,
|
||||
noop,
|
||||
);
|
||||
expect(captured.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a second exclusive lock on the same path', async () => {
|
||||
const uniquePath = `/double-lock-${Date.now()}.txt`;
|
||||
const uniquePath = `/test/double-lock-${Date.now()}.txt`;
|
||||
|
||||
// First lock
|
||||
const { res: res1, captured: cap1 } = makeRes();
|
||||
@@ -582,7 +681,7 @@ describe('WebDAVController', () => {
|
||||
});
|
||||
|
||||
it('refreshes an existing lock when If header provides the token', async () => {
|
||||
const uniquePath = `/refresh-lock-${Date.now()}.txt`;
|
||||
const uniquePath = `/test/refresh-lock-${Date.now()}.txt`;
|
||||
const { res: res1, captured: cap1 } = makeRes();
|
||||
await dispatchMiddleware(
|
||||
makeReq({
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { verify as verifyOtp } from '../../services/auth/OTPUtil.js';
|
||||
import { expandTildePath } from '../../services/fs/resolveNode.js';
|
||||
import type { FSEntry } from '../../stores/fs/FSEntry.js';
|
||||
import { toLegacyEntry } from '../fs/legacyFsHelpers.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import {
|
||||
createLock,
|
||||
@@ -128,7 +129,7 @@ export class WebDAVController extends PuterController {
|
||||
case 'MOVE':
|
||||
return this.#move(req, res, actor, davPath, redis, lockToken);
|
||||
case 'LOCK':
|
||||
return this.#lock(req, res, davPath, redis, lockToken);
|
||||
return this.#lock(req, res, actor, davPath, redis, lockToken);
|
||||
case 'UNLOCK':
|
||||
return this.#unlock(req, res, davPath, redis);
|
||||
default:
|
||||
@@ -138,7 +139,7 @@ export class WebDAVController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────────
|
||||
// -- Auth ---------------------------------------------------------
|
||||
|
||||
async #resolveActor(req: Request, res: Response): Promise<Actor | null> {
|
||||
// If the global authProbe already resolved an actor, use it.
|
||||
@@ -236,7 +237,7 @@ export class WebDAVController extends PuterController {
|
||||
};
|
||||
}
|
||||
|
||||
// ── OPTIONS ──────────────────────────────────────────────────────
|
||||
// -- OPTIONS ------------------------------------------------------
|
||||
|
||||
#options(res: Response): void {
|
||||
res.status(200)
|
||||
@@ -250,7 +251,7 @@ export class WebDAVController extends PuterController {
|
||||
.send('');
|
||||
}
|
||||
|
||||
// ── GET / HEAD ──────────────────────────────────────────────────
|
||||
// -- GET / HEAD --------------------------------------------------
|
||||
|
||||
async #get(
|
||||
req: Request,
|
||||
@@ -300,7 +301,7 @@ export class WebDAVController extends PuterController {
|
||||
result.body.pipe(res);
|
||||
}
|
||||
|
||||
// ── PROPFIND ────────────────────────────────────────────────────
|
||||
// -- PROPFIND ----------------------------------------------------
|
||||
|
||||
async #propfind(
|
||||
req: Request,
|
||||
@@ -347,7 +348,7 @@ export class WebDAVController extends PuterController {
|
||||
.send(wrapMultistatus(responses.join('\n')));
|
||||
}
|
||||
|
||||
// ── PROPPATCH (stub — acknowledges but doesn't persist props) ───
|
||||
// -- PROPPATCH (stub — acknowledges but doesn't persist props) ---
|
||||
|
||||
async #proppatch(
|
||||
res: Response,
|
||||
@@ -371,7 +372,7 @@ export class WebDAVController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
// ── MKCOL ───────────────────────────────────────────────────────
|
||||
// -- MKCOL -------------------------------------------------------
|
||||
|
||||
async #mkcol(
|
||||
req: Request,
|
||||
@@ -421,7 +422,7 @@ export class WebDAVController extends PuterController {
|
||||
.end();
|
||||
}
|
||||
|
||||
// ── PUT ─────────────────────────────────────────────────────────
|
||||
// -- PUT ---------------------------------------------------------
|
||||
|
||||
async #put(
|
||||
req: Request,
|
||||
@@ -497,7 +498,7 @@ export class WebDAVController extends PuterController {
|
||||
.end();
|
||||
}
|
||||
|
||||
// ── DELETE ───────────────────────────────────────────────────────
|
||||
// -- DELETE -------------------------------------------------------
|
||||
|
||||
async #delete(
|
||||
res: Response,
|
||||
@@ -527,7 +528,7 @@ export class WebDAVController extends PuterController {
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
// ── COPY ────────────────────────────────────────────────────────
|
||||
// -- COPY --------------------------------------------------------
|
||||
|
||||
async #copy(
|
||||
req: Request,
|
||||
@@ -585,7 +586,7 @@ export class WebDAVController extends PuterController {
|
||||
res.status(destExists ? 204 : 201).end();
|
||||
}
|
||||
|
||||
// ── MOVE ────────────────────────────────────────────────────────
|
||||
// -- MOVE --------------------------------------------------------
|
||||
|
||||
async #move(
|
||||
req: Request,
|
||||
@@ -641,17 +642,23 @@ export class WebDAVController extends PuterController {
|
||||
res.status(destExists ? 204 : 201).end();
|
||||
}
|
||||
|
||||
// ── LOCK ────────────────────────────────────────────────────────
|
||||
// -- LOCK --------------------------------------------------------
|
||||
|
||||
async #lock(
|
||||
req: Request,
|
||||
res: Response,
|
||||
actor: Actor,
|
||||
davPath: string,
|
||||
redis: unknown,
|
||||
headerToken: string | null,
|
||||
): Promise<void> {
|
||||
const r = redis as import('ioredis').Cluster;
|
||||
|
||||
// ACL must succeed before any lock state is touched — otherwise
|
||||
// an authenticated user could lock paths they don't own (e.g. `/`)
|
||||
// and block writes for everyone else.
|
||||
await this.#assertWrite(actor, davPath);
|
||||
|
||||
// Refresh existing lock
|
||||
if (headerToken) {
|
||||
const existing = await getLockIfValid(r, headerToken);
|
||||
@@ -702,7 +709,7 @@ export class WebDAVController extends PuterController {
|
||||
.send(lockResponseXml(token, davPath, lockScope));
|
||||
}
|
||||
|
||||
// ── UNLOCK ──────────────────────────────────────────────────────
|
||||
// -- UNLOCK ------------------------------------------------------
|
||||
|
||||
async #unlock(
|
||||
req: Request,
|
||||
@@ -733,7 +740,7 @@ export class WebDAVController extends PuterController {
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
// ── ACL helpers ─────────────────────────────────────────────────
|
||||
// -- ACL helpers -------------------------------------------------
|
||||
|
||||
async #assertRead(actor: Actor, path: string): Promise<void> {
|
||||
const descriptor = {
|
||||
@@ -759,32 +766,36 @@ export class WebDAVController extends PuterController {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Event emission ──────────────────────────────────────────────
|
||||
// -- Event emission ----------------------------------------------
|
||||
|
||||
#emitGuiEvent<T extends keyof EventMap>(
|
||||
eventName: T,
|
||||
entry: FSEntry,
|
||||
extra?: Record<string, unknown>,
|
||||
): void {
|
||||
const payload = {
|
||||
user_id_list: [entry.userId],
|
||||
response: { ...entry, ...extra, from_new_service: true },
|
||||
};
|
||||
const meta = {};
|
||||
void Promise.resolve()
|
||||
.then(() =>
|
||||
.then(async () => {
|
||||
const response = {
|
||||
...(await toLegacyEntry(this.clients.event, entry)),
|
||||
...extra,
|
||||
from_new_service: true,
|
||||
};
|
||||
this.clients.event.emit(
|
||||
eventName,
|
||||
payload as unknown as EventMap[T],
|
||||
{
|
||||
user_id_list: [entry.userId],
|
||||
response,
|
||||
} as unknown as EventMap[T],
|
||||
meta,
|
||||
),
|
||||
)
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// non-critical
|
||||
});
|
||||
}
|
||||
|
||||
// ── Misc helpers ────────────────────────────────────────────────
|
||||
// -- Misc helpers ------------------------------------------------
|
||||
|
||||
#parseDestination(req: Request): string {
|
||||
const dest = req.headers.destination as string | undefined;
|
||||
@@ -801,7 +812,7 @@ export class WebDAVController extends PuterController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── XML helpers ──────────────────────────────────────────────────────
|
||||
// -- XML helpers ------------------------------------------------------
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
|
||||
@@ -198,7 +198,7 @@ export async function hasWritePermission(
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Internals ───────────────────────────────────────────────────────
|
||||
// -- Internals -------------------------------------------------------
|
||||
|
||||
async function getPathLockMap(
|
||||
redis: Cluster,
|
||||
|
||||
@@ -64,7 +64,7 @@ export const isSystemActor = (actor: Actor | undefined | null): boolean => {
|
||||
};
|
||||
|
||||
export const isAppActor = (actor: Actor | undefined | null): boolean => {
|
||||
return !!actor?.app && !actor?.accessToken;
|
||||
return !!actor?.app && !isAccessTokenActor(actor);
|
||||
};
|
||||
|
||||
export const isAccessTokenActor = (
|
||||
@@ -97,3 +97,13 @@ export const userRelatedActor = (actor: Actor): Actor => {
|
||||
if (!actor.app && !actor.accessToken) return actor;
|
||||
return { user: actor.user };
|
||||
};
|
||||
|
||||
/**
|
||||
* Walk the access-token issuer chain and return the first app identity found,
|
||||
* or null if no app appears anywhere in the chain.
|
||||
*/
|
||||
export const effectiveActorApp = (actor: Actor): ActorApp | null => {
|
||||
if (actor.app) return actor.app;
|
||||
if (actor.accessToken) return effectiveActorApp(actor.accessToken.issuer);
|
||||
return null;
|
||||
};
|
||||
@@ -46,7 +46,7 @@ import type { Actor } from './actor';
|
||||
* ```
|
||||
*/
|
||||
|
||||
// ── Well-known typed keys ───────────────────────────────────────────
|
||||
// -- Well-known typed keys -------------------------------------------
|
||||
|
||||
export interface KnownContextFields {
|
||||
/** The authenticated actor, if one was resolved by the auth probe. */
|
||||
@@ -57,7 +57,7 @@ export interface KnownContextFields {
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
// ── Context store ───────────────────────────────────────────────────
|
||||
// -- Context store ---------------------------------------------------
|
||||
|
||||
interface ContextStore {
|
||||
known: Partial<KnownContextFields>;
|
||||
@@ -66,7 +66,7 @@ interface ContextStore {
|
||||
|
||||
const als = new AsyncLocalStorage<ContextStore>();
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
// -- Public API ------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Static-style context accessor.
|
||||
@@ -131,7 +131,7 @@ export class Context {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal: used by the request-context middleware ─────────────────
|
||||
// -- Internal: used by the request-context middleware -----------------
|
||||
|
||||
/**
|
||||
* Run `fn` inside a new context scope. Used by the request-context
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// NOT ALL OF THEM, ADD OLD ONES AS NEEDED, IF NEEDED. DO NOT ADD NEW ONES THOUGH.
|
||||
export type LegacyErrorCodes =
|
||||
| 'payment_account_not_set_up'
|
||||
| 'unknown_error'
|
||||
| 'disallowed_value'
|
||||
| 'invalid_token'
|
||||
|
||||
@@ -61,7 +61,7 @@ export class PuterRouter {
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
// ── use ─────────────────────────────────────────────────────────
|
||||
// -- use ---------------------------------------------------------
|
||||
//
|
||||
// `use` is the only method whose path is optional (global-ish
|
||||
// middleware) and whose options can appear with or without a path.
|
||||
@@ -77,7 +77,7 @@ export class PuterRouter {
|
||||
return this;
|
||||
}
|
||||
|
||||
// ── HTTP verbs + WebDAV ─────────────────────────────────────────
|
||||
// -- HTTP verbs + WebDAV -----------------------------------------
|
||||
//
|
||||
// All take `(path, handler)` or `(path, options, handler)`.
|
||||
|
||||
@@ -231,7 +231,7 @@ export class PuterRouter {
|
||||
return this.#push('move', args);
|
||||
}
|
||||
|
||||
// ── Internals ───────────────────────────────────────────────────
|
||||
// -- Internals ---------------------------------------------------
|
||||
|
||||
#push(method: RouteMethod, args: unknown[]): this {
|
||||
const normalized = this.#parsePathArgs(args);
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
* directly work equally well.
|
||||
*/
|
||||
|
||||
// ── Prototype shape helpers ─────────────────────────────────────────
|
||||
// -- Prototype shape helpers -----------------------------------------
|
||||
|
||||
interface DecoratedPrototype {
|
||||
[ROUTES_METADATA_KEY]?: CollectedRoute[];
|
||||
@@ -57,7 +57,7 @@ const getOrInitRoutes = (proto: DecoratedPrototype): CollectedRoute[] => {
|
||||
return proto[ROUTES_METADATA_KEY]!;
|
||||
};
|
||||
|
||||
// ── @Controller ─────────────────────────────────────────────────────
|
||||
// -- @Controller -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Class decorator.
|
||||
@@ -128,7 +128,7 @@ export function Controller(prefix: string = '') {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Method decorators (@Get, @Post, ...) ───────────────────────────
|
||||
// -- Method decorators (@Get, @Post, ...) ---------------------------
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
type AnyMethod = Function;
|
||||
|
||||
@@ -35,40 +35,25 @@ declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
interface Request {
|
||||
/**
|
||||
* Populated by the global auth probe when a valid token is
|
||||
* attached to the request (Bearer header, auth_token body/query,
|
||||
* session cookie, or socket handshake). Absent for anonymous
|
||||
* requests — route-level gates decide whether to reject.
|
||||
*/
|
||||
actor?: Actor;
|
||||
|
||||
/** The raw token string, if one was presented and parsed. */
|
||||
token?: string;
|
||||
|
||||
/**
|
||||
* Set by the global auth probe when a token was extracted from
|
||||
* the request but failed to resolve to an actor (bad signature,
|
||||
* expired, references a deleted session/user/app, or has a
|
||||
* legacy shape v2 can't authenticate). Lets `requireAuthGate`
|
||||
* distinguish "no token" from "token present but invalid" and
|
||||
* emit the legacy `token_auth_failed` error so old clients
|
||||
* trigger their re-login flow.
|
||||
*/
|
||||
tokenAuthFailed?: boolean;
|
||||
|
||||
/**
|
||||
* Raw request body bytes, captured by the global JSON parser's
|
||||
* `verify` callback. Available for any JSON request — needed by
|
||||
* webhook handlers that verify an HMAC over the exact bytes the
|
||||
* sender signed (e.g., AppStore prod webhooks, BroadcastService
|
||||
* inter-instance hooks).
|
||||
*
|
||||
* Set only when the global JSON parser actually ran (request had
|
||||
* `Content-Type: application/json` or one of the recognized
|
||||
* JSON-as-text variants). For non-JSON requests it stays
|
||||
* `undefined`.
|
||||
*/
|
||||
requiresReauth?: {
|
||||
reason: 'token_v1' | 'session_revoked' | 'session_expired';
|
||||
auth_id?: string;
|
||||
/**
|
||||
* Short-lived server-signed JWT that proves the bearer was
|
||||
* identified as `auth_id` by the rejected session. The GUI
|
||||
* must echo this back (not the raw `auth_id`) on the next
|
||||
* login/signup so the controller can rebind to the same user.
|
||||
*/
|
||||
reauth_token?: string;
|
||||
};
|
||||
|
||||
rawBody?: Buffer;
|
||||
|
||||
/** Parsed user-agent, populated by the global UA-parsing middleware. */
|
||||
@@ -80,6 +65,9 @@ declare global {
|
||||
|
||||
/** True when the request's Host is a custom domain (not one of the configured Puter domains). */
|
||||
is_custom_domain?: boolean;
|
||||
|
||||
/** Parsed cookies, populated by the global `cookie-parser` middleware. */
|
||||
cookies?: Record<string, string>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export const antiCsrf = {
|
||||
},
|
||||
};
|
||||
|
||||
// ── Route middleware ────────────────────────────────────────────────
|
||||
// -- Route middleware ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Middleware that requires a valid anti-CSRF token in `req.body.anti_csrf`.
|
||||
|
||||
@@ -34,27 +34,50 @@ import { createAuthProbe } from './authProbe';
|
||||
// test wants. This is mocking at a real boundary (a service), which the
|
||||
// AGENTS.md guidance explicitly allows.
|
||||
|
||||
type AuthResultLike =
|
||||
| { actor: Actor }
|
||||
| { reauth: { reason: string; auth_id?: string } }
|
||||
| { invalid: true };
|
||||
|
||||
interface StubAuth {
|
||||
service: AuthService;
|
||||
seenTokens: string[];
|
||||
/** What the next call to authenticateFromToken should return. */
|
||||
/** Legacy setter — accepts the old Actor|null|'throw' shape. */
|
||||
setNext: (next: Actor | null | 'throw') => void;
|
||||
/** Set the full AuthResult to be returned by `authenticate()`. */
|
||||
setNextResult: (next: AuthResultLike | 'throw') => void;
|
||||
}
|
||||
|
||||
const makeStubAuth = (defaultActor: Actor | null = null): StubAuth => {
|
||||
const seenTokens: string[] = [];
|
||||
let nextResult: Actor | null | 'throw' = defaultActor;
|
||||
let nextResult: AuthResultLike | 'throw' = defaultActor
|
||||
? { actor: defaultActor }
|
||||
: { invalid: true };
|
||||
const service = {
|
||||
authenticateFromToken: async (token: string) => {
|
||||
// Entry point used by the probe.
|
||||
authenticate: async (token: string) => {
|
||||
seenTokens.push(token);
|
||||
if (nextResult === 'throw') throw new Error('verify failed');
|
||||
return nextResult;
|
||||
},
|
||||
// Back-compat wrapper for callers that still want Actor | null.
|
||||
authenticateFromToken: async (token: string) => {
|
||||
seenTokens.push(token);
|
||||
if (nextResult === 'throw') throw new Error('verify failed');
|
||||
return 'actor' in nextResult ? nextResult.actor : null;
|
||||
},
|
||||
// Deterministic stub — production mints a real JWT here.
|
||||
signReauthToken: (authId: string) => `reauth-jwt:${authId}`,
|
||||
} as unknown as AuthService;
|
||||
return {
|
||||
service,
|
||||
seenTokens,
|
||||
setNext: (n) => {
|
||||
if (n === 'throw') nextResult = 'throw';
|
||||
else if (n === null) nextResult = { invalid: true };
|
||||
else nextResult = { actor: n };
|
||||
},
|
||||
setNextResult: (n) => {
|
||||
nextResult = n;
|
||||
},
|
||||
};
|
||||
@@ -73,8 +96,33 @@ interface ReqInit {
|
||||
query?: Record<string, unknown>;
|
||||
handshakeQuery?: Record<string, unknown>;
|
||||
actor?: Actor;
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
// Minimal stand-in for `cookie-parser` — splits on `;`, URL-decodes the
|
||||
// value, and strips a pair of surrounding double quotes (matches the
|
||||
// `cookie` package's behavior, which `cookie-parser` uses internally).
|
||||
const parseCookieHeader = (header: string): Record<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const piece of header.split(';')) {
|
||||
const eq = piece.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const name = piece.slice(0, eq).trim();
|
||||
if (!name) continue;
|
||||
let value = piece.slice(eq + 1).trim();
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
try {
|
||||
value = decodeURIComponent(value);
|
||||
} catch {
|
||||
/* leave as-is */
|
||||
}
|
||||
out[name] = value;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const makeReq = (init: ReqInit = {}): Request => {
|
||||
const headers: Record<string, string> = { ...(init.headers ?? {}) };
|
||||
if (init.cookieHeader) headers.cookie = init.cookieHeader;
|
||||
@@ -82,11 +130,15 @@ const makeReq = (init: ReqInit = {}): Request => {
|
||||
body: init.body,
|
||||
query: (init.query ?? {}) as Request['query'],
|
||||
headers: headers as unknown as Request['headers'],
|
||||
protocol: init.protocol,
|
||||
header(name: string) {
|
||||
// Express's `req.header()` is case-insensitive; mirror that.
|
||||
return headers[name.toLowerCase()];
|
||||
return headers[name.toLowerCase()] as unknown as string[] & string;
|
||||
},
|
||||
};
|
||||
if (init.cookieHeader) {
|
||||
req.cookies = parseCookieHeader(init.cookieHeader);
|
||||
}
|
||||
if (init.actor) req.actor = init.actor;
|
||||
if (init.handshakeQuery) {
|
||||
req.handshake = { query: init.handshakeQuery };
|
||||
@@ -303,10 +355,7 @@ describe('createAuthProbe — cookie reading', () => {
|
||||
cookieName: 'puter_token',
|
||||
});
|
||||
// Quoted + percent-encoded value: `"a b"` → `a b`
|
||||
await runProbe(
|
||||
probe,
|
||||
makeReq({ cookieHeader: 'puter_token="a%20b"' }),
|
||||
);
|
||||
await runProbe(probe, makeReq({ cookieHeader: 'puter_token="a%20b"' }));
|
||||
expect(stub.seenTokens).toEqual(['a b']);
|
||||
});
|
||||
|
||||
@@ -319,6 +368,114 @@ describe('createAuthProbe — cookie reading', () => {
|
||||
);
|
||||
expect(stub.seenTokens).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores session cookies on cross-origin browser requests', async () => {
|
||||
const stub = makeStubAuth();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
cookieName: 'puter_token',
|
||||
});
|
||||
const { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({
|
||||
protocol: 'https',
|
||||
headers: {
|
||||
host: 'api.puter.test',
|
||||
origin: 'https://attacker.example',
|
||||
},
|
||||
cookieHeader: 'puter_token=session-abc',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stub.seenTokens).toEqual([]);
|
||||
expect(req.actor).toBeUndefined();
|
||||
expect(req.tokenAuthFailed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still accepts bearer tokens on cross-origin browser requests', async () => {
|
||||
const stub = makeStubAuth();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
cookieName: 'puter_token',
|
||||
});
|
||||
await runProbe(
|
||||
probe,
|
||||
makeReq({
|
||||
protocol: 'https',
|
||||
headers: {
|
||||
authorization: 'Bearer header-tok',
|
||||
host: 'api.puter.test',
|
||||
origin: 'https://app.example',
|
||||
},
|
||||
cookieHeader: 'puter_token=session-abc',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stub.seenTokens).toEqual(['header-tok']);
|
||||
});
|
||||
|
||||
it('keeps session cookies for same-origin browser requests', async () => {
|
||||
const stub = makeStubAuth();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
cookieName: 'puter_token',
|
||||
});
|
||||
await runProbe(
|
||||
probe,
|
||||
makeReq({
|
||||
protocol: 'https',
|
||||
headers: {
|
||||
host: 'api.puter.test',
|
||||
origin: 'https://api.puter.test',
|
||||
},
|
||||
cookieHeader: 'puter_token=session-abc',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stub.seenTokens).toEqual(['session-abc']);
|
||||
});
|
||||
|
||||
it('normalizes default ports when comparing browser origins', async () => {
|
||||
const stub = makeStubAuth();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
cookieName: 'puter_token',
|
||||
});
|
||||
await runProbe(
|
||||
probe,
|
||||
makeReq({
|
||||
protocol: 'https',
|
||||
headers: {
|
||||
host: 'api.puter.test:443',
|
||||
origin: 'https://api.puter.test',
|
||||
},
|
||||
cookieHeader: 'puter_token=session-abc',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stub.seenTokens).toEqual(['session-abc']);
|
||||
});
|
||||
|
||||
it('treats protocol mismatches as cross-origin for session cookies', async () => {
|
||||
const stub = makeStubAuth();
|
||||
const probe = createAuthProbe({
|
||||
authService: stub.service,
|
||||
cookieName: 'puter_token',
|
||||
});
|
||||
await runProbe(
|
||||
probe,
|
||||
makeReq({
|
||||
protocol: 'https',
|
||||
headers: {
|
||||
host: 'api.puter.test',
|
||||
origin: 'http://api.puter.test',
|
||||
},
|
||||
cookieHeader: 'puter_token=session-abc',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(stub.seenTokens).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Behavior when AuthService responds ──────────────────────────────
|
||||
@@ -348,7 +505,7 @@ describe('createAuthProbe — actor attachment + failure tracking', () => {
|
||||
expect(req.tokenAuthFailed).toBe(true);
|
||||
});
|
||||
|
||||
it("never rejects — sets tokenAuthFailed even when AuthService throws", async () => {
|
||||
it('never rejects — sets tokenAuthFailed even when AuthService throws', async () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNext('throw');
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
@@ -388,6 +545,225 @@ describe('createAuthProbe — actor attachment + failure tracking', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Reauth signal + KV counters ─────────────────────────────────────
|
||||
|
||||
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 () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNextResult({
|
||||
reauth: { reason: 'token_v1', auth_id: 'u-legacy' },
|
||||
// Some legacy paths resolve an actor anyway (lazy-backfill).
|
||||
// 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 { req } = await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer v1-tok' } }),
|
||||
);
|
||||
expect(req.requiresReauth).toEqual({
|
||||
reason: 'token_v1',
|
||||
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 () => {
|
||||
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 { 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 () => {
|
||||
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 { 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 () => {
|
||||
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,
|
||||
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
|
||||
);
|
||||
expect(req.actor).toBeTruthy();
|
||||
expect(req.requiresReauth).toBeUndefined();
|
||||
});
|
||||
|
||||
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
|
||||
// '\[auth-v2\] reauth'` and trust the format won't drift.
|
||||
const stub = makeStubAuth();
|
||||
stub.setNextResult({
|
||||
reauth: { reason: 'session_revoked', auth_id: 'u-grep' },
|
||||
});
|
||||
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
try {
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer tok' } }),
|
||||
);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
'[auth-v2] reauth reason=session_revoked auth_id=u-grep',
|
||||
);
|
||||
} finally {
|
||||
infoSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs `auth_id=-` when the reauth result has no auth_id', async () => {
|
||||
const stub = makeStubAuth();
|
||||
stub.setNextResult({
|
||||
reauth: { reason: 'session_expired' },
|
||||
});
|
||||
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
try {
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer tok' } }),
|
||||
);
|
||||
expect(infoSpy).toHaveBeenCalledWith(
|
||||
'[auth-v2] reauth reason=session_expired auth_id=-',
|
||||
);
|
||||
} finally {
|
||||
infoSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not emit a reauth log line on a healthy v2 verify', async () => {
|
||||
const stub = makeStubAuth({ user: { uuid: 'u-1' } });
|
||||
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
try {
|
||||
const probe = createAuthProbe({ authService: stub.service });
|
||||
await runProbe(
|
||||
probe,
|
||||
makeReq({ headers: { authorization: 'Bearer good-tok' } }),
|
||||
);
|
||||
const reauthCalls = infoSpy.mock.calls.filter((args) =>
|
||||
String(args[0]).startsWith('[auth-v2] reauth'),
|
||||
);
|
||||
expect(reauthCalls).toHaveLength(0);
|
||||
} finally {
|
||||
infoSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Server-backed end-to-end (real AuthService + DB) ────────────────
|
||||
//
|
||||
// The unit tests above cover the extraction logic in isolation. This
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import type { AuthService } from '../../../services/auth/AuthService';
|
||||
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.
|
||||
@@ -28,8 +32,25 @@ 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
|
||||
@@ -44,12 +65,12 @@ interface AuthProbeOptions {
|
||||
* 1. `req.body.auth_token`
|
||||
* 2. `Authorization: Bearer <token>` header
|
||||
* 3. `x-api-key` header — third-party SDK convention (Anthropic etc.)
|
||||
* 4. Session cookie
|
||||
* 4. Session cookie, only when the browser request is same-origin
|
||||
* 5. `?auth_token=...` query param
|
||||
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
|
||||
*/
|
||||
export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
const { authService, cookieName } = opts;
|
||||
const { authService, cookieName, kvStore } = opts;
|
||||
return async (req, _res, next): Promise<void> => {
|
||||
// If something upstream already attached an actor, respect it.
|
||||
if (req.actor) {
|
||||
@@ -64,24 +85,45 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
|
||||
}
|
||||
|
||||
try {
|
||||
const actor = await authService.authenticateFromToken(token);
|
||||
if (actor) {
|
||||
req.actor = actor;
|
||||
// Thread the request IP and User-Agent into authenticate so
|
||||
// SessionStore.touch can refresh `last_ip` / `last_user_agent`
|
||||
// when a session roams to a new network / browser.
|
||||
const result = await authService.authenticate(token, {
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'] ?? undefined,
|
||||
});
|
||||
|
||||
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
|
||||
// only and is not accepted as authoritative on its own.
|
||||
const reauth_token = result.reauth.auth_id
|
||||
? authService.signReauthToken(result.reauth.auth_id)
|
||||
: undefined;
|
||||
req.requiresReauth = {
|
||||
reason: result.reauth.reason as ReauthReason,
|
||||
auth_id: result.reauth.auth_id,
|
||||
...(reauth_token ? { reauth_token } : {}),
|
||||
};
|
||||
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.actor) {
|
||||
req.actor = result.actor;
|
||||
req.token = token;
|
||||
} else {
|
||||
// Token was present but didn't resolve — covers v1-shape
|
||||
// tokens v2 can't authenticate (e.g. FPE-encrypted session
|
||||
// UUIDs), as well as tokens whose session/user/app rows
|
||||
// have been deleted. `requireAuthGate` reads this flag
|
||||
// to emit `token_auth_failed` (matching v1) so clients
|
||||
// trigger their re-login flow instead of retrying.
|
||||
} else if (result.invalid) {
|
||||
req.tokenAuthFailed = true;
|
||||
}
|
||||
} catch {
|
||||
// Probe never rejects — invalid tokens just leave `req.actor`
|
||||
// undefined. Same flag as above so the gate's response shape
|
||||
// is identical regardless of whether the failure came from
|
||||
// jwt.verify or a downstream lookup.
|
||||
req.tokenAuthFailed = true;
|
||||
}
|
||||
next();
|
||||
@@ -126,13 +168,26 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
|
||||
return stripBearer(xApiKey);
|
||||
}
|
||||
|
||||
// 4. Cookie (set by login flow for session tokens). We parse the
|
||||
// Cookie header directly rather than depending on `cookie-parser`
|
||||
// middleware — the probe only needs one named value.
|
||||
if (cookieName) {
|
||||
const cookieToken = readCookie(req, cookieName);
|
||||
if (cookieToken) {
|
||||
return stripBearer(cookieToken);
|
||||
// 4. Cookie (set by login flow for session tokens). Do not let an
|
||||
// arbitrary browser Origin spend an ambient session cookie against the
|
||||
// credentialed API CORS surface; bearer/body/x-api-key tokens remain
|
||||
// available for cross-origin SDK requests.
|
||||
//
|
||||
// `puter_token_v2` is the cookie companion to v2 app-under-user
|
||||
// tokens set by `POST /auth/migrate-token`. We accept it under the
|
||||
// same same-origin gate as the primary session cookie so a private
|
||||
// app iframe can authenticate subsequent calls without re-attaching
|
||||
// an `Authorization` header on every request.
|
||||
if (!isCrossOriginBrowserRequest(req)) {
|
||||
if (cookieName) {
|
||||
const cookieToken = req.cookies?.[cookieName];
|
||||
if (typeof cookieToken === 'string' && cookieToken.length > 0) {
|
||||
return stripBearer(cookieToken);
|
||||
}
|
||||
}
|
||||
const v2Token = req.cookies?.puter_token_v2;
|
||||
if (typeof v2Token === 'string' && v2Token.length > 0) {
|
||||
return stripBearer(v2Token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,27 +212,25 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
|
||||
|
||||
const stripBearer = (t: string): string => t.replace(/^Bearer\s+/i, '').trim();
|
||||
|
||||
/**
|
||||
* Minimal cookie reader. Avoids pulling in `cookie-parser` for the one
|
||||
* lookup the probe needs. Handles quoted values and URL-decodes the result.
|
||||
*/
|
||||
const readCookie = (req: Request, name: string): string | null => {
|
||||
const header =
|
||||
typeof req.header === 'function' ? req.header('cookie') : undefined;
|
||||
if (!header || typeof header !== 'string') return null;
|
||||
const target = `${name}=`;
|
||||
for (const rawPair of header.split(';')) {
|
||||
const pair = rawPair.trim();
|
||||
if (!pair.startsWith(target)) continue;
|
||||
let value = pair.slice(target.length);
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
const isCrossOriginBrowserRequest = (req: Request): boolean => {
|
||||
const origin =
|
||||
typeof req.header === 'function' ? req.header('origin') : undefined;
|
||||
if (!origin) return false;
|
||||
|
||||
const host =
|
||||
typeof req.header === 'function' ? req.header('host') : undefined;
|
||||
if (!host) return true;
|
||||
|
||||
const protocol =
|
||||
typeof req.protocol === 'string' && req.protocol.length > 0
|
||||
? req.protocol
|
||||
: undefined;
|
||||
if (!protocol) return true;
|
||||
|
||||
try {
|
||||
const requestOrigin = new URL(`${protocol}://${host.trim()}`).origin;
|
||||
return new URL(origin).origin !== requestOrigin;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -59,7 +59,7 @@ function readTransactionValue(result) {
|
||||
return result[1];
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────
|
||||
// -- Public API ------------------------------------------------------
|
||||
|
||||
/** Generate a captcha image + token pair. */
|
||||
export async function generateCaptcha(difficulty = 'medium') {
|
||||
@@ -96,7 +96,7 @@ export async function verifyCaptcha(token, answer) {
|
||||
return text === answer.toLowerCase().trim();
|
||||
}
|
||||
|
||||
// ── Route middleware ────────────────────────────────────────────────
|
||||
// -- Route middleware ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Captcha gate middleware factory.
|
||||
|
||||
@@ -80,6 +80,11 @@ export const createErrorHandler = (
|
||||
return;
|
||||
}
|
||||
|
||||
const translated = translateKnownClientError(err);
|
||||
if (translated) {
|
||||
err = translated;
|
||||
}
|
||||
|
||||
if (isHttpError(err)) {
|
||||
opts.onError?.(err, req);
|
||||
if (err.statusCode === 402 || err.statusCode === 413) {
|
||||
@@ -102,6 +107,70 @@ export const createErrorHandler = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Recognise framework-level errors that are caused by the client and
|
||||
* re-shape them as `HttpError`s with `client_*` legacy codes so the
|
||||
* alarm gate (server.ts) treats them as user-caused 4xx and does not
|
||||
* page on them.
|
||||
*
|
||||
* Sources covered:
|
||||
* - `URIError` from `decodeURIComponent` in the express router (raised
|
||||
* by path-traversal scanners hitting `%c0%ae` etc.)
|
||||
* - body-parser `entity.parse.failed` (malformed JSON in request body)
|
||||
* - body-parser `request.aborted` / `ECONNABORTED` (client closed
|
||||
* socket mid-upload)
|
||||
* - Anything else that already opted into `expose: true` with a
|
||||
* numeric `statusCode` is mapped to `client_bad_request` so it
|
||||
* surfaces with the declared status instead of becoming a 500.
|
||||
*/
|
||||
const translateKnownClientError = (err: unknown): HttpError | null => {
|
||||
if (err instanceof URIError) {
|
||||
return new HttpError(400, 'Bad request URL', {
|
||||
legacyCode: 'client_bad_url',
|
||||
});
|
||||
}
|
||||
|
||||
if (!err || typeof err !== 'object') return null;
|
||||
const e = err as {
|
||||
type?: string;
|
||||
code?: string;
|
||||
statusCode?: number;
|
||||
status?: number;
|
||||
expose?: boolean;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (e.type === 'request.aborted' || e.code === 'ECONNABORTED') {
|
||||
return new HttpError(400, 'Request aborted', {
|
||||
legacyCode: 'client_aborted',
|
||||
});
|
||||
}
|
||||
|
||||
if (e.type === 'entity.parse.failed') {
|
||||
return new HttpError(400, 'Malformed JSON in request body', {
|
||||
legacyCode: 'client_bad_json',
|
||||
});
|
||||
}
|
||||
|
||||
// Generic body-parser / http-errors convention: anything tagged
|
||||
// `expose: true` with a real 4xx statusCode is by definition meant
|
||||
// to be returned to the client, not paged on. Honour the declared
|
||||
// status; tag with a known code so the alarm gate skips it.
|
||||
const declaredStatus = e.statusCode ?? e.status;
|
||||
if (
|
||||
e.expose === true &&
|
||||
typeof declaredStatus === 'number' &&
|
||||
declaredStatus >= 400 &&
|
||||
declaredStatus < 500
|
||||
) {
|
||||
return new HttpError(declaredStatus, e.message ?? 'Bad request', {
|
||||
legacyCode: 'client_bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const serializeHttpError = (err: HttpError): Record<string, unknown> => {
|
||||
const payload: Record<string, unknown> = {
|
||||
error: err.message,
|
||||
|
||||
@@ -136,6 +136,53 @@ describe('requireAuthGate', () => {
|
||||
});
|
||||
expectHttpError(got, 403, 'forbidden');
|
||||
});
|
||||
|
||||
// ── Reauth signal ───────────────────────────────────────────────
|
||||
|
||||
it('returns 401 reauth_required for a legacy v1 token', () => {
|
||||
const got = runGate(requireAuthGate(), {
|
||||
requiresReauth: { reason: 'token_v1', auth_id: 'u-1' },
|
||||
});
|
||||
expectHttpError(got, 401, 'reauth_required');
|
||||
expect((got as HttpError).fields).toMatchObject({
|
||||
code: 'reauth_required',
|
||||
reason: 'token_v1',
|
||||
auth_id: 'u-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 401 reauth_required with reason=session_revoked', () => {
|
||||
const got = runGate(requireAuthGate(), {
|
||||
requiresReauth: { reason: 'session_revoked', auth_id: 'u-2' },
|
||||
});
|
||||
expectHttpError(got, 401, 'reauth_required');
|
||||
expect((got as HttpError).fields).toMatchObject({
|
||||
reason: 'session_revoked',
|
||||
auth_id: 'u-2',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 401 reauth_required with reason=session_expired', () => {
|
||||
const got = runGate(requireAuthGate(), {
|
||||
requiresReauth: { reason: 'session_expired' },
|
||||
});
|
||||
expectHttpError(got, 401, 'reauth_required');
|
||||
expect((got as HttpError).fields).toMatchObject({
|
||||
reason: 'session_expired',
|
||||
});
|
||||
// No auth_id field at all when none was supplied (vs. set-to-undefined).
|
||||
expect((got as HttpError).fields?.auth_id).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reauth_required takes priority over tokenAuthFailed', () => {
|
||||
// Both flags set: the structured reauth signal wins. v2 clients
|
||||
// key on `code === 'reauth_required'`; v1 clients still see a 401.
|
||||
const got = runGate(requireAuthGate(), {
|
||||
requiresReauth: { reason: 'token_v1', auth_id: 'u-1' },
|
||||
tokenAuthFailed: true,
|
||||
});
|
||||
expectHttpError(got, 401, 'reauth_required');
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireUserActorGate ────────────────────────────────────────────
|
||||
@@ -226,6 +273,35 @@ describe('adminOnlyGate', () => {
|
||||
'forbidden',
|
||||
);
|
||||
});
|
||||
|
||||
it('admits the built-ins regardless of case', () => {
|
||||
// On a SQLite self-host with case-sensitive (BINARY) collation, a
|
||||
// user row could exist with `Admin`/`SYSTEM` capitalization. The
|
||||
// gate must still admit those — case is normalized on both sides.
|
||||
for (const username of ['Admin', 'ADMIN', 'sYsTeM']) {
|
||||
const got = runGate(adminOnlyGate(), {
|
||||
actor: { user: { uuid: 'u-1', username } },
|
||||
});
|
||||
expect(got).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('admits case-mismatched extras (allowlist lowercased on construction)', () => {
|
||||
// Pass an extra in mixed case; the gate must accept the same name
|
||||
// in any case — the on-disk username row may be either, depending
|
||||
// on the DB's column collation.
|
||||
const gate = adminOnlyGate(['Daniel']);
|
||||
expect(
|
||||
runGate(gate, {
|
||||
actor: { user: { uuid: 'u-1', username: 'daniel' } },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
runGate(gate, {
|
||||
actor: { user: { uuid: 'u-1', username: 'DANIEL' } },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireVerifiedGate ─────────────────────────────────────────────
|
||||
|
||||
@@ -24,14 +24,22 @@ import { assertVerifiedEmail } from '../verifiedEmail';
|
||||
// Make sure the `Express.Request.actor` augmentation is in scope.
|
||||
import '../expressAugmentation';
|
||||
|
||||
/**
|
||||
* Build the 401 a route gate emits when `req.actor` is absent. Splits the
|
||||
* legacy code so old clients can tell the two cases apart: `token_missing`
|
||||
* means "send a token", `token_auth_failed` means "your token didn't work,
|
||||
* re-login". Matches the v1 backend's auth-failure shape so the existing
|
||||
* client retry-vs-reauth logic doesn't need to change.
|
||||
*/
|
||||
const rejectAuth = (req: Request): HttpError => {
|
||||
if (req.requiresReauth) {
|
||||
return new HttpError(401, 'Re-authentication required', {
|
||||
legacyCode: 'reauth_required',
|
||||
fields: {
|
||||
code: 'reauth_required',
|
||||
reason: req.requiresReauth.reason,
|
||||
...(req.requiresReauth.auth_id
|
||||
? { auth_id: req.requiresReauth.auth_id }
|
||||
: {}),
|
||||
...(req.requiresReauth.reauth_token
|
||||
? { reauth_token: req.requiresReauth.reauth_token }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (req.tokenAuthFailed) {
|
||||
return new HttpError(401, 'Authentication failed', {
|
||||
legacyCode: 'token_auth_failed',
|
||||
@@ -114,6 +122,27 @@ export const requireUserActorGate = (): RequestHandler => {
|
||||
};
|
||||
};
|
||||
|
||||
export const requireNonAccessTokenGate = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
const actor = req.actor;
|
||||
if (!actor) {
|
||||
next(rejectAuth(req));
|
||||
return;
|
||||
}
|
||||
if (actor.accessToken) {
|
||||
next(
|
||||
new HttpError(
|
||||
403,
|
||||
'Access tokens are not allowed to access this resource',
|
||||
{ legacyCode: 'forbidden' },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
/** Built-in admin usernames that always pass `adminOnly`. */
|
||||
export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const;
|
||||
|
||||
@@ -130,10 +159,16 @@ export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const;
|
||||
export const adminOnlyGate = (
|
||||
extras: readonly string[] = [],
|
||||
): RequestHandler => {
|
||||
const allowList = new Set<string>([...DEFAULT_ADMIN_USERNAMES, ...extras]);
|
||||
// Match the case-insensitivity guarantee of the username column
|
||||
// (MySQL: ascii_general_ci; SQLite: idx_user_username_nocase). Comparing
|
||||
// raw-case here would let a stored `Admin` bypass the lowercase allowlist
|
||||
// on any backend that lets case-collision rows exist.
|
||||
const allowList = new Set<string>(
|
||||
[...DEFAULT_ADMIN_USERNAMES, ...extras].map((u) => u.toLowerCase()),
|
||||
);
|
||||
return (req, _res, next) => {
|
||||
const username = req.actor?.user.username;
|
||||
if (!username || !allowList.has(username)) {
|
||||
if (!username || !allowList.has(username.toLowerCase())) {
|
||||
next(
|
||||
new HttpError(403, 'Only admins may request this resource', {
|
||||
legacyCode: 'forbidden',
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { RequestHandler } from 'express';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { IConfig } from '../../../types';
|
||||
import { assertNormalized } from '../../../services/fs/resolveNode.js';
|
||||
|
||||
/** Native-app subdomains served via `nativeAppStatic`. */
|
||||
const NATIVE_APP_SUBDOMAINS = [
|
||||
@@ -130,10 +131,8 @@ export const createNativeAppStatic = (config: IConfig): RequestHandler => {
|
||||
? path.join(root, active, 'dist')
|
||||
: path.join(root, active);
|
||||
|
||||
// req.path is already url-decoded by express; normalize strips any
|
||||
// `..` segments before sendFile's `root` option enforces its own
|
||||
// traversal guard.
|
||||
const requested = path.normalize(req.path);
|
||||
const requested = req.path;
|
||||
assertNormalized(requested);
|
||||
const absolute = path.join(appRoot, requested);
|
||||
|
||||
try {
|
||||
|
||||
@@ -28,12 +28,13 @@ import {
|
||||
buildAppCenterFallback,
|
||||
buildHostingConfig,
|
||||
buildPrivateHostRedirect,
|
||||
buildPublicHostRedirect,
|
||||
getBootstrapToken,
|
||||
hostMatchesPrivateDomain,
|
||||
normalizeHost,
|
||||
normalizeHostRaw,
|
||||
renderLoginBootstrapHtml,
|
||||
resolvePrivateAppForHostedSite,
|
||||
resolveOwnedAppForHostedSite,
|
||||
resolvePrivateIdentity,
|
||||
resolvePublicHostedIdentity,
|
||||
subdomainFromHost,
|
||||
@@ -356,6 +357,73 @@ describe('buildPrivateHostRedirect', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPublicHostRedirect', () => {
|
||||
// Mirror of buildPrivateHostRedirect — swaps the private hosting
|
||||
// domain for the public one. Used when a non-private app (or no app
|
||||
// at all) hits the private host, so a paid-→-free app's old
|
||||
// `puter.app` URL still resolves on `puter.site`.
|
||||
const cfg = buildHostingConfig({
|
||||
domain: 'puter.localhost',
|
||||
static_hosting_domain: 'site.puter.localhost:4100',
|
||||
static_hosting_domain_alt: null,
|
||||
private_app_hosting_domain: 'app.puter.localhost',
|
||||
private_app_hosting_domain_alt: null,
|
||||
protocol: 'http',
|
||||
} as unknown as IConfig);
|
||||
|
||||
const reqOf = (init: Partial<Request>): Request =>
|
||||
({
|
||||
hostname: init.hostname,
|
||||
originalUrl: init.originalUrl,
|
||||
protocol: init.protocol ?? 'http',
|
||||
headers: init.headers ?? {},
|
||||
}) as unknown as Request;
|
||||
|
||||
it('swaps the private hosting domain for the public one (preserving port + path + query)', () => {
|
||||
const url = buildPublicHostRedirect(
|
||||
reqOf({
|
||||
hostname: 'beans.app.puter.localhost',
|
||||
originalUrl: '/some/path?x=1',
|
||||
}),
|
||||
cfg,
|
||||
);
|
||||
expect(url).toBe(
|
||||
'http://beans.site.puter.localhost:4100/some/path?x=1',
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults the path to '/' when originalUrl is empty", () => {
|
||||
const url = buildPublicHostRedirect(
|
||||
reqOf({ hostname: 'beans.app.puter.localhost' }),
|
||||
cfg,
|
||||
);
|
||||
expect(url).toBe('http://beans.site.puter.localhost:4100/');
|
||||
});
|
||||
|
||||
it('returns null when no public hosting domain is configured', () => {
|
||||
const noPublic = {
|
||||
...cfg,
|
||||
staticDomains: [],
|
||||
staticDomainsRaw: [],
|
||||
};
|
||||
expect(
|
||||
buildPublicHostRedirect(
|
||||
reqOf({ hostname: 'beans.app.puter.localhost' }),
|
||||
noPublic,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for the bare private host (no subdomain to forward)', () => {
|
||||
expect(
|
||||
buildPublicHostRedirect(
|
||||
reqOf({ hostname: 'app.puter.localhost' }),
|
||||
cfg,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Login bootstrap HTML ────────────────────────────────────────────
|
||||
|
||||
describe('renderLoginBootstrapHtml', () => {
|
||||
@@ -383,7 +451,7 @@ describe('renderLoginBootstrapHtml', () => {
|
||||
|
||||
// ── Server-backed helpers (AuthService + DB) ─────────────────────────
|
||||
//
|
||||
// `resolvePrivateAppForHostedSite` queries the apps table directly;
|
||||
// `resolveOwnedAppForHostedSite` queries the apps table directly;
|
||||
// `resolvePrivateIdentity` / `resolvePublicHostedIdentity` rely on the
|
||||
// real AuthService for token verification + cookie naming.
|
||||
|
||||
@@ -456,7 +524,33 @@ const baseConfig = () =>
|
||||
protocol: 'http',
|
||||
} as unknown as IConfig);
|
||||
|
||||
describe('resolvePrivateAppForHostedSite', () => {
|
||||
const createApp = async (
|
||||
ownerId: number,
|
||||
indexUrl: string,
|
||||
opts: { isPrivate?: boolean } = {},
|
||||
): Promise<{ id: number; uid: string }> => {
|
||||
const uid = `app-${uuidv4()}`;
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
uid,
|
||||
`app-${uid}`,
|
||||
`app-${uid}`,
|
||||
indexUrl,
|
||||
ownerId,
|
||||
opts.isPrivate ? 1 : 0,
|
||||
],
|
||||
);
|
||||
const row = (
|
||||
await server.clients.db.read('SELECT id, uid FROM apps WHERE uid = ?', [
|
||||
uid,
|
||||
])
|
||||
)[0] as { id: number; uid: string };
|
||||
return row;
|
||||
};
|
||||
|
||||
describe('resolveOwnedAppForHostedSite', () => {
|
||||
const reqOf = (host: string): Request =>
|
||||
({
|
||||
hostname: host,
|
||||
@@ -464,84 +558,101 @@ describe('resolvePrivateAppForHostedSite', () => {
|
||||
headers: { host },
|
||||
}) as unknown as Request;
|
||||
|
||||
it('returns the associated app immediately when it is already private', async () => {
|
||||
it("matches the subdomain owner's app by index_url on the same hosting domain", async () => {
|
||||
const owner = await makeUser();
|
||||
const associatedApp = {
|
||||
id: 1,
|
||||
uid: 'app-direct',
|
||||
is_private: 1,
|
||||
owner_user_id: owner.id,
|
||||
};
|
||||
const out = await resolvePrivateAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
site: { user_id: owner.id, associated_app_id: 1 },
|
||||
associatedApp,
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
matchedHostingDomain: 'site.puter.localhost',
|
||||
});
|
||||
expect(out?.uid).toBe('app-direct');
|
||||
});
|
||||
|
||||
it('falls back to an index_url lookup when associated_app_id is unset', async () => {
|
||||
const owner = await makeUser();
|
||||
// Private app registered with a URL on the static hosting domain.
|
||||
// The visitor lands on the equivalent private host — the helper
|
||||
// should still find it.
|
||||
const app = await createPrivateApp(
|
||||
owner.id,
|
||||
'http://beans.site.puter.localhost/',
|
||||
);
|
||||
const out = await resolvePrivateAppForHostedSite({
|
||||
req: reqOf('beans.app.puter.localhost'),
|
||||
site: { user_id: owner.id, associated_app_id: null },
|
||||
associatedApp: null,
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
matchedHostingDomain: 'app.puter.localhost',
|
||||
});
|
||||
expect(out?.uid).toBe(app.uid);
|
||||
});
|
||||
|
||||
it('returns the original associated app when no index_url matches', async () => {
|
||||
it('matches across hosting variants (static URL, private host request)', async () => {
|
||||
const owner = await makeUser();
|
||||
const associatedApp = {
|
||||
id: 99,
|
||||
uid: 'app-public',
|
||||
is_private: 0,
|
||||
owner_user_id: owner.id,
|
||||
};
|
||||
const out = await resolvePrivateAppForHostedSite({
|
||||
req: reqOf('orphan.site.puter.localhost'),
|
||||
site: { user_id: owner.id, associated_app_id: 99 },
|
||||
associatedApp,
|
||||
const app = await createPrivateApp(
|
||||
owner.id,
|
||||
'http://beans.site.puter.localhost/',
|
||||
);
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.app.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
matchedHostingDomain: 'site.puter.localhost',
|
||||
});
|
||||
expect(out?.uid).toBe('app-public');
|
||||
expect(out?.uid).toBe(app.uid);
|
||||
});
|
||||
|
||||
it("ignores apps owned by a different user even when index_url matches the host", async () => {
|
||||
// Regression test for the `associated_app_uid` IDOR: an app owned
|
||||
// by user B must never resolve as "associated" with a subdomain
|
||||
// owned by user A, even when the index_url notionally matches.
|
||||
const a = await makeUser();
|
||||
const b = await makeUser();
|
||||
await createPrivateApp(b.id, 'http://beans.site.puter.localhost/');
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
site: { user_id: a.id },
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a public app when requirePrivate is not set', async () => {
|
||||
const owner = await makeUser();
|
||||
const app = await createApp(
|
||||
owner.id,
|
||||
'http://beans.site.puter.localhost/',
|
||||
{ isPrivate: false },
|
||||
);
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
});
|
||||
expect(out?.uid).toBe(app.uid);
|
||||
});
|
||||
|
||||
it('filters out non-private apps when requirePrivate is set', async () => {
|
||||
const owner = await makeUser();
|
||||
await createApp(
|
||||
owner.id,
|
||||
'http://beans.site.puter.localhost/',
|
||||
{ isPrivate: false },
|
||||
);
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
site: { user_id: owner.id },
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
requirePrivate: true,
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the site has no owner', async () => {
|
||||
const out = await resolvePrivateAppForHostedSite({
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf('beans.site.puter.localhost'),
|
||||
site: { user_id: null, associated_app_id: null },
|
||||
associatedApp: null,
|
||||
site: { user_id: null },
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
matchedHostingDomain: 'site.puter.localhost',
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on a request whose host can't be parsed", async () => {
|
||||
const out = await resolvePrivateAppForHostedSite({
|
||||
const out = await resolveOwnedAppForHostedSite({
|
||||
req: reqOf(''),
|
||||
site: { user_id: 1, associated_app_id: null },
|
||||
associatedApp: null,
|
||||
site: { user_id: 1 },
|
||||
db: server.clients.db,
|
||||
config: baseConfig(),
|
||||
matchedHostingDomain: 'site.puter.localhost',
|
||||
});
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
@@ -564,7 +675,7 @@ describe('resolvePrivateIdentity', () => {
|
||||
it('returns the sticky private-cookie identity when the token matches the expected app/subdomain/host', async () => {
|
||||
const user = await makeUser();
|
||||
const appUid = `app-${uuidv4()}`;
|
||||
const token = authService.createPrivateAssetToken({
|
||||
const token = await authService.createPrivateAssetToken({
|
||||
appUid,
|
||||
userUid: user.uuid,
|
||||
subdomain: 'beans',
|
||||
@@ -589,7 +700,7 @@ describe('resolvePrivateIdentity', () => {
|
||||
|
||||
it('falls through to req.actor when the private cookie is for a different app', async () => {
|
||||
const user = await makeUser();
|
||||
const wrongToken = authService.createPrivateAssetToken({
|
||||
const wrongToken = await authService.createPrivateAssetToken({
|
||||
appUid: `app-${uuidv4()}`,
|
||||
userUid: user.uuid,
|
||||
subdomain: 'beans',
|
||||
@@ -627,6 +738,124 @@ describe('resolvePrivateIdentity', () => {
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores req.actor whose actor.app.uid doesn't match the target app", async () => {
|
||||
// Cross-app token confusion guard: an app-under-user actor whose
|
||||
// issuing app is NOT the private host's app must not establish
|
||||
// identity here — even though the underlying user may have legit
|
||||
// entitlement to the target app, accepting the actor would let
|
||||
// attacker-app JS replay a victim's app-under-user token against
|
||||
// an unrelated private host.
|
||||
const user = await makeUser();
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: {
|
||||
user: { uuid: user.uuid },
|
||||
app: { uid: `app-attacker-${uuidv4()}` },
|
||||
session: { uid: 'sess-1' },
|
||||
},
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: `app-target-${uuidv4()}`,
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores a bootstrap query-token whose actor.app.uid doesn't match the target app", async () => {
|
||||
// Same guard for the `?puter.auth.token=` bootstrap path. A token
|
||||
// minted via getUserAppToken for the attacker's app carries
|
||||
// app_uid=attacker; using it as a bootstrap on the target host
|
||||
// must fall through to source='none' (login bootstrap), not
|
||||
// promote it to a victim identity on the target app. Both app
|
||||
// rows are seeded so authenticateFromToken returns a real actor
|
||||
// — proving the guard (not a missing row) is what blocks it.
|
||||
const user = await makeUser();
|
||||
// App uids must be `app-<hex-uuid>` — TokenService compresses the
|
||||
// `app_uid` field by stripping the `app-` prefix and decoding the
|
||||
// rest as a hex-encoded UUID (see TokenService AUTH_COMPRESSION).
|
||||
// Custom non-uuid suffixes round-trip incorrectly and break
|
||||
// authenticateFromToken's app lookup.
|
||||
const attackerUid = `app-${uuidv4()}`;
|
||||
const targetUid = `app-${uuidv4()}`;
|
||||
// index_url is NOT NULL in the schema, so supply placeholders.
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
|
||||
VALUES (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
attackerUid,
|
||||
`attacker-${attackerUid}`,
|
||||
`attacker-${attackerUid}`,
|
||||
`http://${attackerUid}.example/`,
|
||||
user.id,
|
||||
0,
|
||||
targetUid,
|
||||
`target-${targetUid}`,
|
||||
`target-${targetUid}`,
|
||||
`http://${targetUid}.example/`,
|
||||
user.id,
|
||||
1,
|
||||
],
|
||||
);
|
||||
const wrongAppToken = await authService.getUserAppToken(
|
||||
{ user: { id: user.id, uuid: user.uuid } } as unknown as Parameters<
|
||||
typeof authService.getUserAppToken
|
||||
>[0],
|
||||
attackerUid,
|
||||
);
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
query: { 'puter.auth.token': wrongAppToken },
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: targetUid,
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a bootstrap query-token whose actor.app.uid matches the target app', async () => {
|
||||
// Positive case: a token correctly bound to the target app
|
||||
// resolves the user identity. Confirms the guard isn't blocking
|
||||
// legitimate same-app traffic.
|
||||
const user = await makeUser();
|
||||
// App uids must be `app-<hex-uuid>` for token round-trip — see the
|
||||
// negative-case comment above.
|
||||
const targetUid = `app-${uuidv4()}`;
|
||||
await server.clients.db.write(
|
||||
`INSERT INTO \`apps\` (\`uid\`, \`name\`, \`title\`, \`index_url\`, \`owner_user_id\`, \`is_private\`)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
targetUid,
|
||||
`target-${targetUid}`,
|
||||
`target-${targetUid}`,
|
||||
`http://${targetUid}.example/`,
|
||||
user.id,
|
||||
1,
|
||||
],
|
||||
);
|
||||
const matchedToken = await authService.getUserAppToken(
|
||||
{ user: { id: user.id, uuid: user.uuid } } as unknown as Parameters<
|
||||
typeof authService.getUserAppToken
|
||||
>[0],
|
||||
targetUid,
|
||||
);
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
query: { 'puter.auth.token': matchedToken },
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: targetUid,
|
||||
});
|
||||
expect(out.source).toBe('query');
|
||||
expect(out.userUid).toBe(user.uuid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePublicHostedIdentity', () => {
|
||||
@@ -644,7 +873,7 @@ describe('resolvePublicHostedIdentity', () => {
|
||||
it('returns the cookie identity when present and valid', async () => {
|
||||
const user = await makeUser();
|
||||
const appUid = `app-${uuidv4()}`;
|
||||
const token = authService.createPublicHostedActorToken({
|
||||
const token = await authService.createPublicHostedActorToken({
|
||||
appUid,
|
||||
userUid: user.uuid,
|
||||
subdomain: 'beans',
|
||||
@@ -662,7 +891,7 @@ describe('resolvePublicHostedIdentity', () => {
|
||||
expectedSubdomain: 'beans',
|
||||
expectedHost: 'beans.site.puter.localhost',
|
||||
});
|
||||
expect(out.source).toBe('private-cookie');
|
||||
expect(out.source).toBe('public-cookie');
|
||||
expect(out.userUid).toBe(user.uuid);
|
||||
expect(
|
||||
(out as { hasValidPublicCookie?: boolean }).hasValidPublicCookie,
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { AbstractDatabaseClient } from '@heyputer/backend/src/clients/database/DatabaseClient';
|
||||
import type { Request } from 'express';
|
||||
import type { AuthService } from '../../../services/auth/AuthService';
|
||||
import type { IConfig } from '../../../types';
|
||||
import type { Actor } from '../../actor';
|
||||
|
||||
/**
|
||||
* Support helpers for the private-app access gate — ported from v1's
|
||||
@@ -29,9 +31,10 @@ import type { IConfig } from '../../../types';
|
||||
*
|
||||
* Covers:
|
||||
* - Host/subdomain parsing against `private_app_hosting_domain(_alt)`.
|
||||
* - Broader private-app detection for hosted sites whose subdomain row
|
||||
* has no `associated_app_id` — falls back to an `index_url` lookup
|
||||
* against the subdomain owner's private apps.
|
||||
* - Owned-app detection for hosted sites: matches the subdomain owner's
|
||||
* apps by `index_url` against the request host. Replaces the older
|
||||
* `associated_app_id`-trusting path, which was user-writable without
|
||||
* an ownership check.
|
||||
* - Bootstrap-token identity resolution (`Authorization: Bearer`,
|
||||
* `?puter.auth.token=`, `X-Puter-Auth-Token`, referrer query) so
|
||||
* private-app visitors with a valid session token (but no cookie on
|
||||
@@ -66,6 +69,7 @@ export interface PrivateHostingConfig {
|
||||
export interface PrivateIdentity {
|
||||
source:
|
||||
| 'private-cookie'
|
||||
| 'public-cookie'
|
||||
| 'session-cookie'
|
||||
| 'bootstrap-token'
|
||||
| 'authorization'
|
||||
@@ -74,32 +78,25 @@ export interface PrivateIdentity {
|
||||
| 'none';
|
||||
userUid?: string;
|
||||
sessionUuid?: string;
|
||||
/** True when resolved from the sticky `puter.private.asset.token` cookie. */
|
||||
/** True when resolved from the sticky private-asset cookie. */
|
||||
hasValidPrivateCookie?: boolean;
|
||||
}
|
||||
|
||||
interface SubdomainLike {
|
||||
user_id?: number | null;
|
||||
associated_app_id?: number | null;
|
||||
}
|
||||
|
||||
interface AppLike {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
owner_user_id?: number;
|
||||
is_private?: boolean | number | null;
|
||||
index_url?: string | null;
|
||||
}
|
||||
|
||||
interface DBClient {
|
||||
read: (
|
||||
sql: string,
|
||||
params: unknown[],
|
||||
) => Promise<Record<string, unknown>[]>;
|
||||
}
|
||||
|
||||
// ── Host helpers ────────────────────────────────────────────────────
|
||||
// -- Host helpers ----------------------------------------------------
|
||||
|
||||
export function normalizeHost(value: string | undefined | null): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
@@ -153,7 +150,7 @@ export function hostMatchesPrivateDomain(
|
||||
return privateDomains.some((pd) => host === pd || host.endsWith(`.${pd}`));
|
||||
}
|
||||
|
||||
// ── Subdomain extraction from a hosted request ─────────────────────
|
||||
// -- Subdomain extraction from a hosted request ---------------------
|
||||
|
||||
export function subdomainFromHost(
|
||||
host: string,
|
||||
@@ -173,43 +170,39 @@ export function subdomainFromHost(
|
||||
return host.split('.')[0] || '';
|
||||
}
|
||||
|
||||
// ── Private-app detection fallback (v1 `resolvePrivateAppForHostedSite`)
|
||||
// -- Hosted-site → owned-app resolution -----------------------------
|
||||
|
||||
/**
|
||||
* When the subdomain row has no `associated_app_id`, v1 looked up the
|
||||
* owner's private apps and matched `index_url` against the request host
|
||||
* variants (this-subdomain × every hosting domain). Ports that logic.
|
||||
* Resolve "what app does the subdomain owner run on this host?" by matching
|
||||
* apps owned by `site.user_id` against the request's host variants. Used by
|
||||
* the puter-site middleware to decide whether the request hits a private
|
||||
* app (gate) or a public app (sticky cookie), and by the subdomain driver
|
||||
* to populate the `associated_app` field in API responses.
|
||||
*
|
||||
* Returns the matched private app (if any) so the caller can run the
|
||||
* normal access check — closes a gap where a private app's files could
|
||||
* be served via a subdomain whose row wasn't explicitly linked.
|
||||
* Ownership-anchored on `apps.owner_user_id = site.user_id` so a subdomain
|
||||
* can never "claim" an app belonging to a different user. The `subdomains`
|
||||
* row's own `associated_app_id` column is intentionally ignored — it was
|
||||
* previously user-writable without an ownership check, so any value there
|
||||
* is either legacy or planted; deriving from `index_url` makes the answer
|
||||
* fall out of facts the system already verifies at app-create time.
|
||||
*/
|
||||
export async function resolvePrivateAppForHostedSite(opts: {
|
||||
export async function resolveOwnedAppForHostedSite(opts: {
|
||||
req: Request;
|
||||
site: SubdomainLike;
|
||||
associatedApp: AppLike | null;
|
||||
db: DBClient;
|
||||
db: AbstractDatabaseClient;
|
||||
config: PrivateHostingConfig;
|
||||
matchedHostingDomain: string;
|
||||
requirePrivate?: boolean;
|
||||
}): Promise<AppLike | null> {
|
||||
// When the subdomain row's own `associated_app_id` points at a private
|
||||
// app, that wins outright. Otherwise fall through to index_url matching
|
||||
// so a private app whose canonical URL lives on one hosting variant
|
||||
// (`beans.puter.site`) still resolves when the visitor hits another
|
||||
// (`beans.puter.app`).
|
||||
if (opts.associatedApp && Number(opts.associatedApp.is_private ?? 0) > 0) {
|
||||
return opts.associatedApp;
|
||||
}
|
||||
if (!opts.site?.user_id) return opts.associatedApp ?? null;
|
||||
if (!opts.site?.user_id) return null;
|
||||
|
||||
const host = normalizeHost(opts.req.hostname);
|
||||
if (!host) return opts.associatedApp ?? null;
|
||||
if (!host) return null;
|
||||
|
||||
const hostedSubdomain = subdomainFromHost(host, [
|
||||
...opts.config.staticDomains,
|
||||
...opts.config.privateDomains,
|
||||
]);
|
||||
if (!hostedSubdomain) return opts.associatedApp ?? null;
|
||||
if (!hostedSubdomain) return null;
|
||||
|
||||
// Build host variants with AND without port, then cross each with both
|
||||
// protocols. Apps store whatever URL the user typed at create time, so
|
||||
@@ -248,16 +241,17 @@ export async function resolvePrivateAppForHostedSite(opts: {
|
||||
}
|
||||
}
|
||||
const uniqueCandidates = [...new Set(urlCandidates)];
|
||||
if (uniqueCandidates.length === 0) return opts.associatedApp ?? null;
|
||||
if (uniqueCandidates.length === 0) return null;
|
||||
|
||||
const privateFilter = opts.requirePrivate ? 'AND `is_private` = 1 ' : '';
|
||||
const placeholders = uniqueCandidates.map(() => '?').join(', ');
|
||||
const rows = await opts.db.read(
|
||||
`SELECT * FROM apps WHERE owner_user_id = ? AND is_private = 1 AND index_url IN (${placeholders}) LIMIT 2`,
|
||||
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) LIMIT 2`,
|
||||
[opts.site.user_id, ...uniqueCandidates],
|
||||
);
|
||||
if (rows.length === 0) return opts.associatedApp ?? null;
|
||||
if (rows.length === 0) return null;
|
||||
if (rows.length > 1) {
|
||||
console.warn('[puter-site] private_access.host_match_ambiguous', {
|
||||
console.warn('[puter-site] hosted_site_app_match_ambiguous', {
|
||||
requestHost: host,
|
||||
matchCount: rows.length,
|
||||
});
|
||||
@@ -265,7 +259,7 @@ export async function resolvePrivateAppForHostedSite(opts: {
|
||||
return rows[0] as unknown as AppLike;
|
||||
}
|
||||
|
||||
// ── Bootstrap token resolution ──────────────────────────────────────
|
||||
// -- Bootstrap token resolution --------------------------------------
|
||||
|
||||
function getAuthorizationToken(req: Request): string | null {
|
||||
const header = req.headers?.authorization;
|
||||
@@ -352,12 +346,20 @@ export async function resolvePrivateIdentity(opts: {
|
||||
const cookies = (req as Request & { cookies?: Record<string, string> })
|
||||
.cookies;
|
||||
|
||||
// 1. Sticky private-asset cookie.
|
||||
const privateCookieName = authService.getPrivateAssetCookieName();
|
||||
// 1. Sticky private-asset cookie. Prefer the v2 cookie name; fall
|
||||
// back to the legacy dot-style name while the deprecation window
|
||||
// is open. A v1 (legacy-secret) token verifies but is intentionally
|
||||
// NOT treated as a valid sticky cookie — we fall through so the
|
||||
// chain re-mints under the v2 secret on this response.
|
||||
const v2CookieName = authService.getPrivateAssetCookieNameV2();
|
||||
const legacyCookieName = authService.getPrivateAssetCookieName();
|
||||
const privateCookieToken =
|
||||
typeof cookies?.[privateCookieName] === 'string'
|
||||
? cookies[privateCookieName]
|
||||
: null;
|
||||
(typeof cookies?.[v2CookieName] === 'string'
|
||||
? cookies[v2CookieName]
|
||||
: null) ??
|
||||
(typeof cookies?.[legacyCookieName] === 'string'
|
||||
? cookies[legacyCookieName]
|
||||
: null);
|
||||
if (privateCookieToken) {
|
||||
try {
|
||||
const claims = await authService.verifyPrivateAssetToken(
|
||||
@@ -368,12 +370,15 @@ export async function resolvePrivateIdentity(opts: {
|
||||
expectedPrivateHost,
|
||||
},
|
||||
);
|
||||
return {
|
||||
source: 'private-cookie',
|
||||
userUid: claims.userUid,
|
||||
sessionUuid: claims.sessionUuid,
|
||||
hasValidPrivateCookie: true,
|
||||
};
|
||||
if (!claims.legacy) {
|
||||
return {
|
||||
source: 'private-cookie',
|
||||
userUid: claims.userUid,
|
||||
sessionUuid: claims.sessionUuid,
|
||||
hasValidPrivateCookie: true,
|
||||
};
|
||||
}
|
||||
/* legacy cookie: fall through so the caller re-mints v2 */
|
||||
} catch {
|
||||
/* fall through — stale / mismatched / logged-out cookie */
|
||||
}
|
||||
@@ -381,7 +386,10 @@ export async function resolvePrivateIdentity(opts: {
|
||||
|
||||
// 2. Auth probe actor.
|
||||
const existingActor = req.actor;
|
||||
if (existingActor?.user?.uuid) {
|
||||
if (
|
||||
existingActor?.user?.uuid &&
|
||||
actorMatchesExpectedApp(existingActor, expectedAppUid)
|
||||
) {
|
||||
return {
|
||||
source: 'session-cookie',
|
||||
userUid: existingActor.user.uuid,
|
||||
@@ -397,7 +405,10 @@ export async function resolvePrivateIdentity(opts: {
|
||||
if (sessionToken) {
|
||||
try {
|
||||
const actor = await authService.authenticateFromToken(sessionToken);
|
||||
if (actor?.user?.uuid) {
|
||||
if (
|
||||
actor?.user?.uuid &&
|
||||
actorMatchesExpectedApp(actor, expectedAppUid)
|
||||
) {
|
||||
return {
|
||||
source: 'session-cookie',
|
||||
userUid: actor.user.uuid,
|
||||
@@ -416,7 +427,10 @@ export async function resolvePrivateIdentity(opts: {
|
||||
const actor = await authService.authenticateFromToken(
|
||||
bootstrap.token,
|
||||
);
|
||||
if (actor?.user?.uuid) {
|
||||
if (
|
||||
actor?.user?.uuid &&
|
||||
actorMatchesExpectedApp(actor, expectedAppUid)
|
||||
) {
|
||||
return {
|
||||
source: bootstrap.source,
|
||||
userUid: actor.user.uuid,
|
||||
@@ -431,6 +445,25 @@ export async function resolvePrivateIdentity(opts: {
|
||||
return { source: 'none' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard against token confusion across private-app boundaries: an actor
|
||||
* derived from an app-under-user token carries the *issuing* app's uid,
|
||||
* which must match the host the request is being made against. A token
|
||||
* minted for app A — e.g. when the visitor authorized a third-party app —
|
||||
* must not be honored as identity on app B's private host, even when the
|
||||
* underlying user happens to have entitlement to B. User-only actors
|
||||
* (no `actor.app`) are unaffected: a plain session token is portable by
|
||||
* design.
|
||||
*/
|
||||
function actorMatchesExpectedApp(
|
||||
actor: Actor,
|
||||
expectedAppUid: string | undefined,
|
||||
): boolean {
|
||||
if (!expectedAppUid) return true;
|
||||
if (!actor.app?.uid) return true;
|
||||
return actor.app.uid === expectedAppUid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of `resolvePrivateIdentity` for public hosted apps. Reads the
|
||||
* sticky `puter.public.hosted.actor.token` cookie first, then the same
|
||||
@@ -456,14 +489,18 @@ export async function resolvePublicHostedIdentity(opts: {
|
||||
const cookies = (req as Request & { cookies?: Record<string, string> })
|
||||
.cookies;
|
||||
|
||||
const publicCookieName = authService.getPublicHostedActorCookieName();
|
||||
const publicCookieNameV2 = authService.getPublicHostedActorCookieNameV2();
|
||||
const publicCookieNameLegacy = authService.getPublicHostedActorCookieName();
|
||||
const publicCookieToken =
|
||||
typeof cookies?.[publicCookieName] === 'string'
|
||||
? cookies[publicCookieName]
|
||||
: null;
|
||||
(typeof cookies?.[publicCookieNameV2] === 'string'
|
||||
? cookies[publicCookieNameV2]
|
||||
: null) ??
|
||||
(typeof cookies?.[publicCookieNameLegacy] === 'string'
|
||||
? cookies[publicCookieNameLegacy]
|
||||
: null);
|
||||
if (publicCookieToken) {
|
||||
try {
|
||||
const claims = authService.verifyPublicHostedActorToken(
|
||||
const claims = await authService.verifyPublicHostedActorToken(
|
||||
publicCookieToken,
|
||||
{
|
||||
expectedAppUid,
|
||||
@@ -471,19 +508,25 @@ export async function resolvePublicHostedIdentity(opts: {
|
||||
expectedHost,
|
||||
},
|
||||
);
|
||||
return {
|
||||
source: 'private-cookie',
|
||||
userUid: claims.userUid,
|
||||
sessionUuid: claims.sessionUuid,
|
||||
hasValidPublicCookie: true,
|
||||
};
|
||||
if (!claims.legacy) {
|
||||
return {
|
||||
source: 'public-cookie',
|
||||
userUid: claims.userUid,
|
||||
sessionUuid: claims.sessionUuid,
|
||||
hasValidPublicCookie: true,
|
||||
};
|
||||
}
|
||||
/* legacy cookie: fall through so the caller re-mints v2 */
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
|
||||
const existingActor = req.actor;
|
||||
if (existingActor?.user?.uuid) {
|
||||
if (
|
||||
existingActor?.user?.uuid &&
|
||||
actorMatchesExpectedApp(existingActor, expectedAppUid)
|
||||
) {
|
||||
return {
|
||||
source: 'session-cookie',
|
||||
userUid: existingActor.user.uuid,
|
||||
@@ -498,7 +541,10 @@ export async function resolvePublicHostedIdentity(opts: {
|
||||
if (sessionToken) {
|
||||
try {
|
||||
const actor = await authService.authenticateFromToken(sessionToken);
|
||||
if (actor?.user?.uuid) {
|
||||
if (
|
||||
actor?.user?.uuid &&
|
||||
actorMatchesExpectedApp(actor, expectedAppUid)
|
||||
) {
|
||||
return {
|
||||
source: 'session-cookie',
|
||||
userUid: actor.user.uuid,
|
||||
@@ -516,7 +562,10 @@ export async function resolvePublicHostedIdentity(opts: {
|
||||
const actor = await authService.authenticateFromToken(
|
||||
bootstrap.token,
|
||||
);
|
||||
if (actor?.user?.uuid) {
|
||||
if (
|
||||
actor?.user?.uuid &&
|
||||
actorMatchesExpectedApp(actor, expectedAppUid)
|
||||
) {
|
||||
return {
|
||||
source: bootstrap.source,
|
||||
userUid: actor.user.uuid,
|
||||
@@ -531,7 +580,7 @@ export async function resolvePublicHostedIdentity(opts: {
|
||||
return { source: 'none' };
|
||||
}
|
||||
|
||||
// ── Redirect helpers ────────────────────────────────────────────────
|
||||
// -- Redirect helpers ------------------------------------------------
|
||||
|
||||
/** Build the URL to redirect a private-app request to its private host. */
|
||||
export function buildPrivateHostRedirect(
|
||||
@@ -564,6 +613,38 @@ export function buildPrivateHostRedirect(
|
||||
void app; // reserved for future use (logging)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of {@link buildPrivateHostRedirect} — produces the public-host
|
||||
* equivalent URL for a request that landed on the private hosting domain
|
||||
* but doesn't belong there (non-private app, or no app at all). Used so a
|
||||
* formerly-paid app that's now free (or a plain hosted site) resolves on
|
||||
* `puter.site` instead of 404ing on `puter.app`.
|
||||
*/
|
||||
export function buildPublicHostRedirect(
|
||||
req: Request,
|
||||
config: PrivateHostingConfig,
|
||||
): string | null {
|
||||
const publicDomain = config.staticDomainsRaw[0] ?? config.staticDomains[0];
|
||||
if (!publicDomain) return null;
|
||||
const host = normalizeHost(req.hostname);
|
||||
if (!host) return null;
|
||||
const subdomain = subdomainFromHost(host, [
|
||||
...config.staticDomains,
|
||||
...config.privateDomains,
|
||||
]);
|
||||
if (!subdomain) return null;
|
||||
try {
|
||||
const protocol = config.protocol || req.protocol || 'https';
|
||||
const base = `${protocol}://${subdomain}.${publicDomain}`;
|
||||
const reqPath = (req.originalUrl || '/').startsWith('/')
|
||||
? req.originalUrl || '/'
|
||||
: `/${req.originalUrl}`;
|
||||
return new URL(reqPath, base).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Redirect URL when private access is denied — lands on the app-center listing. */
|
||||
export function buildAppCenterFallback(
|
||||
app: AppLike,
|
||||
@@ -580,7 +661,7 @@ export function buildAppCenterFallback(
|
||||
return `https://${config.domain}/app/app-center/?item=${encodeURIComponent(appName)}`;
|
||||
}
|
||||
|
||||
// ── Login bootstrap HTML ────────────────────────────────────────────
|
||||
// -- Login bootstrap HTML --------------------------------------------
|
||||
|
||||
/**
|
||||
* Minimal HTML page that prompts the visitor to sign in with Puter.
|
||||
|
||||
@@ -114,6 +114,7 @@ const makeRes = () => {
|
||||
const makeReq = (init: {
|
||||
hostname: string;
|
||||
path?: string;
|
||||
originalUrl?: string;
|
||||
protocol?: string;
|
||||
headers?: Record<string, string>;
|
||||
cookies?: Record<string, string>;
|
||||
@@ -121,6 +122,9 @@ const makeReq = (init: {
|
||||
({
|
||||
hostname: init.hostname,
|
||||
path: init.path ?? '/',
|
||||
// Redirect helpers use originalUrl (preserves query string).
|
||||
// Default to `path` when caller doesn't care to distinguish.
|
||||
originalUrl: init.originalUrl ?? init.path ?? '/',
|
||||
protocol: init.protocol ?? 'http',
|
||||
headers: init.headers ?? {},
|
||||
cookies: init.cookies ?? {},
|
||||
@@ -358,12 +362,15 @@ describe('createPuterSiteMiddleware — subdomain lookup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Private hosting domain refusal ──────────────────────────────────
|
||||
// ── Private hosting domain → public-host redirect ───────────────────
|
||||
|
||||
describe('createPuterSiteMiddleware — private hosting domain', () => {
|
||||
it('404s a subdomain on the private host that has no private app (prevents public-site leak via private host)', async () => {
|
||||
it('302s a subdomain on the private host with no private app to the equivalent puter.site URL (covers freed-paid-app bookmarks + plain hosted sites)', async () => {
|
||||
// Owner exists, subdomain exists, but it has no associated
|
||||
// private app. On the *private* host this must refuse, not serve.
|
||||
// private app. On the *private* host this used to 404; now it
|
||||
// mirrors the public→private redirect so a paid app whose price
|
||||
// dropped to 0 still resolves on `puter.site` when accessed via
|
||||
// its old `puter.app` URL.
|
||||
const owner = await makeUser();
|
||||
const sub = `leak-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
@@ -376,15 +383,18 @@ describe('createPuterSiteMiddleware — private hosting domain', () => {
|
||||
makeReq({
|
||||
// Note: app.puter.localhost is the *private* hosting domain.
|
||||
hostname: `${sub}.app.puter.localhost`,
|
||||
path: '/some/deep/path.html',
|
||||
}),
|
||||
);
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(out.body).toBe('Subdomain not found');
|
||||
expect(out.redirected).toEqual({
|
||||
status: 302,
|
||||
url: `http://${sub}.site.puter.localhost/some/deep/path.html`,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the alt private hosting domain when configured', async () => {
|
||||
it('uses the alt private hosting domain when configured (same redirect to the public host)', async () => {
|
||||
// Coverage for the `private_app_hosting_domain_alt` slot — same
|
||||
// refusal logic, but via the alternate host that the deployment
|
||||
// redirect logic, but via the alternate host that the deployment
|
||||
// can use for legacy traffic.
|
||||
const owner = await makeUser();
|
||||
const sub = `altleak-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -399,8 +409,40 @@ describe('createPuterSiteMiddleware — private hosting domain', () => {
|
||||
mw,
|
||||
makeReq({ hostname: `${sub}.apps.alt.localhost` }),
|
||||
);
|
||||
expect(out.redirected).toEqual({
|
||||
status: 302,
|
||||
url: `http://${sub}.site.puter.localhost/`,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to 404 when no public hosting domain is configured (no leak)', async () => {
|
||||
// Without a static_hosting_domain to redirect to we have no safe
|
||||
// target; the original refusal must still apply so a public-app
|
||||
// subdomain doesn't accidentally serve via the private host.
|
||||
const owner = await makeUser();
|
||||
const sub = `nopub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
});
|
||||
const mw = createPuterSiteMiddleware(
|
||||
{
|
||||
...hostingConfig,
|
||||
static_hosting_domain: null,
|
||||
} as unknown as IConfig,
|
||||
{
|
||||
clients: server.clients,
|
||||
stores: server.stores,
|
||||
services: server.services,
|
||||
},
|
||||
);
|
||||
const { out } = await runMiddleware(
|
||||
mw,
|
||||
makeReq({ hostname: `${sub}.app.puter.localhost` }),
|
||||
);
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(out.body).toBe('Subdomain not found');
|
||||
expect(out.redirected).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -466,12 +508,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
rootDirId: homeEntry!.id,
|
||||
});
|
||||
const body = Buffer.from('<html>hi</html>');
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/index.html`,
|
||||
body,
|
||||
'text/html',
|
||||
);
|
||||
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
@@ -546,12 +583,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
rootDirId: homeEntry!.id,
|
||||
});
|
||||
const body = Buffer.from('default doc');
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/index.html`,
|
||||
body,
|
||||
'text/html',
|
||||
);
|
||||
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
@@ -597,7 +629,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
expect(String(out.body)).toContain('Not Found');
|
||||
});
|
||||
|
||||
it('returns 404 when the resolved URL points at a directory rather than a file', async () => {
|
||||
it('returns 404 when the resolved URL points at a directory with no index.html', async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
|
||||
@@ -613,8 +645,8 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
// Documents/ exists from generateDefaultFsentries — a
|
||||
// directory entry, which the file branch must refuse.
|
||||
// Documents/ exists from generateDefaultFsentries but has
|
||||
// no index.html — directory fallback must still 404.
|
||||
path: '/Documents',
|
||||
}),
|
||||
res,
|
||||
@@ -625,6 +657,45 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
expect(out.contentType).toBe('text/html; charset=UTF-8');
|
||||
});
|
||||
|
||||
it('serves <folder>/index.html when the URL resolves to a folder containing one', async () => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
|
||||
const sub = `folderidx-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await server.stores.subdomain.create({
|
||||
userId: owner.id,
|
||||
subdomain: sub,
|
||||
rootDirId: homeEntry!.id,
|
||||
});
|
||||
const body = Buffer.from('nested doc');
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/Documents/index.html`,
|
||||
body,
|
||||
'text/html',
|
||||
);
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
// No trailing slash; folder exists and contains index.html.
|
||||
path: '/Documents',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
// Allow the piped stream to flush.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(out.headers['Content-Type']).toMatch(/text\/html/);
|
||||
const piped = out.body as Buffer | undefined;
|
||||
expect(Buffer.isBuffer(piped)).toBe(true);
|
||||
expect(piped!.equals(body)).toBe(true);
|
||||
});
|
||||
|
||||
it("serves the HTML SUBDOMAIN_404 when the subdomain's root_dir_id points to a missing entry", async () => {
|
||||
// Subdomain row references a fsentry id that doesn't exist —
|
||||
// earlier path resolution must catch this with the HTML 404 page.
|
||||
@@ -657,11 +728,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
const body = Buffer.from('not a directory');
|
||||
// Write a file and use ITS id as the subdomain's root_dir_id —
|
||||
// the middleware must reject because root must be a directory.
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/Documents/somefile.txt`,
|
||||
body,
|
||||
);
|
||||
await writeFile(owner.id, `${homePath}/Documents/somefile.txt`, body);
|
||||
const fileEntry = await server.stores.fsEntry.getEntryByPath(
|
||||
`${homePath}/Documents/somefile.txt`,
|
||||
);
|
||||
@@ -733,12 +800,7 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
rootDirId: homeEntry!.id,
|
||||
});
|
||||
const body = Buffer.from('inside');
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/safe.txt`,
|
||||
body,
|
||||
'text/plain',
|
||||
);
|
||||
await writeFile(owner.id, `${homePath}/safe.txt`, body, 'text/plain');
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
@@ -758,3 +820,344 @@ describe('createPuterSiteMiddleware — file serving', () => {
|
||||
expect(out.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── .puter_site_config (custom error pages) ─────────────────────────
|
||||
//
|
||||
// Sites can drop a `.puter_site_config` JSON file at the root of their
|
||||
// hosting directory to map error codes onto custom pages — the
|
||||
// canonical use case is SPA fallback (404 → /index.html with status
|
||||
// 200). These tests pin the contract end-to-end through the real
|
||||
// FSService so we exercise parsing, path resolution, and the loop-
|
||||
// prevention guard together.
|
||||
|
||||
describe('createPuterSiteMiddleware — .puter_site_config', () => {
|
||||
const setupSiteWithConfig = async (config: unknown) => {
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
|
||||
const sub = `cfg-${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}/.puter_site_config`,
|
||||
Buffer.from(JSON.stringify(config)),
|
||||
'application/json',
|
||||
);
|
||||
return { owner, homePath, sub };
|
||||
};
|
||||
|
||||
it('serves /index.html with status 200 on 404 when configured (SPA fallback)', async () => {
|
||||
// The user's headline use case: route any unknown path through
|
||||
// the SPA entrypoint so client-side routing can take over,
|
||||
// while still serving HTTP 200 so search engines don't cache
|
||||
// the page as a hard 404.
|
||||
const { owner, homePath, sub } = await setupSiteWithConfig({
|
||||
errors: {
|
||||
'404': { file: '/index.html', status: 200 },
|
||||
},
|
||||
});
|
||||
const body = Buffer.from('<html>spa-shell</html>');
|
||||
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/some/client-route',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(out.statusCode).toBe(200);
|
||||
expect(out.headers['Content-Type']).toMatch(/text\/html/);
|
||||
const piped = out.body as Buffer | undefined;
|
||||
expect(Buffer.isBuffer(piped)).toBe(true);
|
||||
expect(piped!.equals(body)).toBe(true);
|
||||
});
|
||||
|
||||
it('serves the configured file with the matched code when `status` is omitted', async () => {
|
||||
// No explicit `status` → default to the matched error code
|
||||
// (404 here). Bare `{ file: '/404.html' }` should Just Work
|
||||
// for the classic "pretty 404 page" use case.
|
||||
const { owner, homePath, sub } = await setupSiteWithConfig({
|
||||
errors: { '404': { file: '/404.html' } },
|
||||
});
|
||||
const body = Buffer.from('<html>custom 404</html>');
|
||||
await writeFile(owner.id, `${homePath}/404.html`, body, 'text/html');
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/no-such-page',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(out.statusCode).toBe(404);
|
||||
const piped = out.body as Buffer | undefined;
|
||||
expect(piped!.equals(body)).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the default 404 page when the configured error file does not exist (no infinite loop)', async () => {
|
||||
// Critical loop guard: if the error page itself is missing,
|
||||
// we must NOT recurse through the error config again. The
|
||||
// request must terminate with the built-in 404 page rather
|
||||
// than spinning errors.404 → errors.404 → … .
|
||||
const { sub } = await setupSiteWithConfig({
|
||||
errors: { '404': { file: '/missing.html', status: 200 } },
|
||||
});
|
||||
// Deliberately do not write missing.html.
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/anything',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(out.contentType).toBe('text/html; charset=UTF-8');
|
||||
expect(String(out.body)).toContain('Not Found');
|
||||
});
|
||||
|
||||
it('returns 404 when a visitor requests `.puter_site_config` directly (no config leak)', async () => {
|
||||
// The config file is implementation detail — hide it the same
|
||||
// way any other missing path would 404, so visitors can't
|
||||
// enumerate deployment shape by guessing well-known names.
|
||||
const { sub } = await setupSiteWithConfig({
|
||||
errors: { '404': { file: '/index.html', status: 200 } },
|
||||
});
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/.puter_site_config',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
// No error-page fallback because we didn't write /index.html.
|
||||
expect(out.statusCode).toBe(404);
|
||||
// Body shape is the default 404, not the JSON config.
|
||||
expect(String(out.body)).not.toContain('errors');
|
||||
});
|
||||
|
||||
it('rejects `errors.404.file` paths that try to climb out of the site root', async () => {
|
||||
// The normalizer collapses `..` segments, so an attacker who
|
||||
// can edit the config can't pivot it into reading files
|
||||
// outside their own subdomain root.
|
||||
const otherOwner = await makeUserWithHome();
|
||||
const secretPath = `/${otherOwner.username}/secret.html`;
|
||||
await writeFile(
|
||||
otherOwner.id,
|
||||
secretPath,
|
||||
Buffer.from('SECRET'),
|
||||
'text/html',
|
||||
);
|
||||
|
||||
const { sub } = await setupSiteWithConfig({
|
||||
errors: {
|
||||
'404': {
|
||||
// Tries to walk up to the other user's home dir.
|
||||
file: `/../${otherOwner.username}/secret.html`,
|
||||
status: 200,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/anything',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
// The collapsed path resolves under the requesting site's
|
||||
// root, which doesn't have a `<username>/secret.html` file —
|
||||
// so the fallback fails and we get the default 404, NOT the
|
||||
// SECRET body.
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(String(out.body)).not.toContain('SECRET');
|
||||
});
|
||||
|
||||
it('ignores malformed JSON configs and behaves like there is no config', async () => {
|
||||
// A typo in the config file must not 5xx the request — the
|
||||
// visitor still gets the default 404 on a missing path.
|
||||
const owner = await makeUserWithHome();
|
||||
const homePath = `/${owner.username}`;
|
||||
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
|
||||
const sub = `bad-${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}/.puter_site_config`,
|
||||
Buffer.from('this is not json {{{'),
|
||||
'application/json',
|
||||
);
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/index.html`,
|
||||
Buffer.from('would-be-fallback'),
|
||||
'text/html',
|
||||
);
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/unknown',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(String(out.body)).toContain('Not Found');
|
||||
// Did NOT silently fall back to /index.html as if the config
|
||||
// were valid — malformed config must be ignored, not partially
|
||||
// applied.
|
||||
expect(String(out.body)).not.toContain('would-be-fallback');
|
||||
});
|
||||
|
||||
it('still serves real files normally — config only applies on 404', async () => {
|
||||
// Sanity check: when the requested path exists, the config's
|
||||
// error rules are not consulted, so the response is the live
|
||||
// file at status 200 (not the error-page status override).
|
||||
const { owner, homePath, sub } = await setupSiteWithConfig({
|
||||
errors: { '404': { file: '/index.html', status: 200 } },
|
||||
});
|
||||
const body = Buffer.from('real-page');
|
||||
await writeFile(
|
||||
owner.id,
|
||||
`${homePath}/page.html`,
|
||||
body,
|
||||
'text/html',
|
||||
);
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/page.html',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(out.statusCode).toBe(200);
|
||||
const piped = out.body as Buffer | undefined;
|
||||
expect(piped!.equals(body)).toBe(true);
|
||||
});
|
||||
|
||||
it('serves the cached config on a second request, even when the on-disk file is removed (Redis cache holds within TTL)', async () => {
|
||||
// First request seeds the cache with the parsed config. We
|
||||
// then delete the underlying file and fire a second request:
|
||||
// if the cache is wired correctly, the SPA fallback still
|
||||
// applies because we never re-read from S3. Within-TTL
|
||||
// staleness is the explicit contract (60s default).
|
||||
const { owner, homePath, sub } = await setupSiteWithConfig({
|
||||
errors: { '404': { file: '/index.html', status: 200 } },
|
||||
});
|
||||
const body = Buffer.from('<html>spa-shell</html>');
|
||||
await writeFile(owner.id, `${homePath}/index.html`, body, 'text/html');
|
||||
|
||||
const mw = buildMiddleware();
|
||||
|
||||
// First request → primes the cache.
|
||||
const first = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/route-a',
|
||||
}),
|
||||
first.res,
|
||||
vi.fn(),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(first.out.statusCode).toBe(200);
|
||||
|
||||
// Now wipe the on-disk config. If the loader hits S3 on every
|
||||
// request, the second call below would see no config and fall
|
||||
// through to a default 404. The cache contract is that it
|
||||
// does NOT — within the TTL, the prior parse stands.
|
||||
const configEntry = await server.stores.fsEntry.getEntryByPath(
|
||||
`${homePath}/.puter_site_config`,
|
||||
);
|
||||
if (configEntry) {
|
||||
await server.services.fs.remove(owner.id, { entry: configEntry });
|
||||
}
|
||||
|
||||
const second = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/route-b',
|
||||
}),
|
||||
second.res,
|
||||
vi.fn(),
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
// SPA fallback still fires → cache served the deleted config.
|
||||
expect(second.out.statusCode).toBe(200);
|
||||
expect(
|
||||
(second.out.body as Buffer | undefined)?.equals(body),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores `errors` entries with status codes outside 4xx/5xx', async () => {
|
||||
// A `200` key in errors is meaningless and a footgun (it
|
||||
// could be used to silently override the happy path). The
|
||||
// parser must drop these on the floor.
|
||||
const { sub } = await setupSiteWithConfig({
|
||||
errors: {
|
||||
'200': { file: '/oops.html', status: 200 },
|
||||
'999': { file: '/oops.html', status: 200 },
|
||||
},
|
||||
});
|
||||
|
||||
const mw = buildMiddleware();
|
||||
const { res, out } = makeRes();
|
||||
await mw(
|
||||
makeReq({
|
||||
hostname: `${sub}.site.puter.localhost`,
|
||||
path: '/missing',
|
||||
}),
|
||||
res,
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
// No valid 404 rule survives the filter → default 404.
|
||||
expect(out.statusCode).toBe(404);
|
||||
expect(String(out.body)).toContain('Not Found');
|
||||
});
|
||||
});
|
||||
@@ -21,20 +21,27 @@ import type { RequestHandler } from 'express';
|
||||
import { contentType as contentTypeFromMime } from 'mime-types';
|
||||
import { posix as pathPosix } from 'node:path';
|
||||
import type { puterClients } from '../../../clients';
|
||||
import { FS_COSTS } from '../../../controllers/fs/costs';
|
||||
import type { puterServices } from '../../../services';
|
||||
import type { puterStores } from '../../../stores';
|
||||
import { FS_COSTS } from '../../../controllers/fs/costs';
|
||||
import type { IConfig, LayerInstances } from '../../../types';
|
||||
import {
|
||||
buildAppCenterFallback,
|
||||
buildHostingConfig,
|
||||
buildPrivateHostRedirect,
|
||||
buildPublicHostRedirect,
|
||||
hostMatchesPrivateDomain,
|
||||
renderLoginBootstrapHtml,
|
||||
resolvePrivateAppForHostedSite,
|
||||
resolveOwnedAppForHostedSite,
|
||||
resolvePrivateIdentity,
|
||||
resolvePublicHostedIdentity,
|
||||
} from './privateAppGate';
|
||||
import {
|
||||
isSiteConfigPath,
|
||||
loadSiteConfig,
|
||||
resolveErrorTarget,
|
||||
type SiteConfig,
|
||||
} from './puterSiteConfig';
|
||||
|
||||
/**
|
||||
* Serves user-hosted static sites on the hosting domains (`*.puter.site`,
|
||||
@@ -51,9 +58,15 @@ import {
|
||||
*
|
||||
* Deferred (not yet implemented):
|
||||
* - `.at` username-based sites (UUIDv5-keyed `/user/Public`).
|
||||
* - `.puter_site_config` error rules (custom status-code → file mapping).
|
||||
* - Custom domains (subdomains table `domain` column) — requires host
|
||||
* validation to allow arbitrary hostnames first.
|
||||
*
|
||||
* Site config:
|
||||
* - `.puter_site_config` at the site root (see `puterSiteConfig.ts`)
|
||||
* supplies custom error pages. On a file 404, the matching rule's
|
||||
* `file` is served with the rule's `status` — supports the SPA
|
||||
* fallback pattern of `404 → /index.html with status 200`. The
|
||||
* config file itself is hidden from public serving.
|
||||
*/
|
||||
|
||||
const SUBDOMAIN_404 = `<div style="font-size: 20px;
|
||||
@@ -191,29 +204,21 @@ export const createPuterSiteMiddleware = (
|
||||
|
||||
const hostingCfg = buildHostingConfig(config);
|
||||
|
||||
let associatedApp: AppRow | null = null;
|
||||
if (
|
||||
site.associated_app_id !== null &&
|
||||
site.associated_app_id !== undefined
|
||||
) {
|
||||
associatedApp = (await layers.stores.app.getById(
|
||||
site.associated_app_id,
|
||||
)) as unknown as AppRow | null;
|
||||
}
|
||||
|
||||
const privateApp = (await resolvePrivateAppForHostedSite({
|
||||
// The subdomain row's `associated_app_id` column is intentionally
|
||||
// not consulted — it was previously user-writable without an
|
||||
// ownership check (so any value there is either legacy or planted).
|
||||
// Resolve "what app does this subdomain host?" directly from
|
||||
// `apps.owner_user_id = site.user_id` + `index_url` match against
|
||||
// the request host.
|
||||
const associatedApp = (await resolveOwnedAppForHostedSite({
|
||||
req,
|
||||
site: {
|
||||
user_id: site.user_id,
|
||||
associated_app_id: site.associated_app_id,
|
||||
},
|
||||
associatedApp,
|
||||
site: { user_id: site.user_id },
|
||||
db: layers.clients.db,
|
||||
config: hostingCfg,
|
||||
matchedHostingDomain: matched,
|
||||
})) as AppRow | null;
|
||||
|
||||
const isPrivateApp = Boolean(privateApp?.is_private);
|
||||
const isPrivateApp = Boolean(associatedApp?.is_private);
|
||||
const privateApp = isPrivateApp ? associatedApp : null;
|
||||
|
||||
if (isPrivateApp) {
|
||||
// Private apps must run on the private hosting domain. If a
|
||||
@@ -318,15 +323,16 @@ export const createPuterSiteMiddleware = (
|
||||
// visitors but still refreshes after rotation/expiry.
|
||||
if (!identity.hasValidPrivateCookie) {
|
||||
try {
|
||||
const token = layers.services.auth.createPrivateAssetToken({
|
||||
appUid: privateApp!.uid,
|
||||
userUid: identity.userUid,
|
||||
sessionUuid: identity.sessionUuid,
|
||||
subdomain,
|
||||
privateHost: host,
|
||||
});
|
||||
const token =
|
||||
await layers.services.auth.createPrivateAssetToken({
|
||||
appUid: privateApp!.uid,
|
||||
userUid: identity.userUid,
|
||||
sessionUuid: identity.sessionUuid,
|
||||
subdomain,
|
||||
privateHost: host,
|
||||
});
|
||||
res.cookie(
|
||||
layers.services.auth.getPrivateAssetCookieName(),
|
||||
layers.services.auth.getPrivateAssetCookieNameV2(),
|
||||
token,
|
||||
layers.services.auth.getPrivateAssetCookieOptions({
|
||||
requestHostname: host,
|
||||
@@ -344,8 +350,20 @@ export const createPuterSiteMiddleware = (
|
||||
// third-party resources loaded from the app.
|
||||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||||
} else if (privateHostingDomains.has(matched)) {
|
||||
// Private host with no private app → refuse. Prevents a
|
||||
// public-app subdomain from leaking via the private host.
|
||||
// Non-private content landed on the private hosting domain —
|
||||
// mirror of the private redirect above. Covers two cases:
|
||||
// - a paid app that just flipped to free (is_private 1→0)
|
||||
// whose old `puter.app` URL is still bookmarked/shared;
|
||||
// - a plain hosted site that has no associated app.
|
||||
// Redirect to the equivalent `puter.site` URL so the content
|
||||
// resolves on the correct origin instead of 404ing.
|
||||
const redirectUrl = buildPublicHostRedirect(req, hostingCfg);
|
||||
if (redirectUrl) {
|
||||
res.redirect(302, redirectUrl);
|
||||
return;
|
||||
}
|
||||
// No public hosting domain configured — fall back to refusing
|
||||
// rather than leaking via the private host.
|
||||
res.status(404).type('text/plain').send('Subdomain not found');
|
||||
return;
|
||||
} else {
|
||||
@@ -373,15 +391,17 @@ export const createPuterSiteMiddleware = (
|
||||
associatedApp?.uid
|
||||
) {
|
||||
const token =
|
||||
layers.services.auth.createPublicHostedActorToken({
|
||||
appUid: associatedApp.uid,
|
||||
userUid: identity.userUid,
|
||||
sessionUuid: identity.sessionUuid,
|
||||
subdomain,
|
||||
host,
|
||||
});
|
||||
await layers.services.auth.createPublicHostedActorToken(
|
||||
{
|
||||
appUid: associatedApp.uid,
|
||||
userUid: identity.userUid,
|
||||
sessionUuid: identity.sessionUuid,
|
||||
subdomain,
|
||||
host,
|
||||
},
|
||||
);
|
||||
res.cookie(
|
||||
layers.services.auth.getPublicHostedActorCookieName(),
|
||||
layers.services.auth.getPublicHostedActorCookieNameV2(),
|
||||
token,
|
||||
layers.services.auth.getPublicHostedActorCookieOptions({
|
||||
requestHostname: host,
|
||||
@@ -423,7 +443,16 @@ export const createPuterSiteMiddleware = (
|
||||
// Resolve URL path → absolute FS path under the site root.
|
||||
let urlPath = req.path || '/';
|
||||
if (urlPath.endsWith('/')) urlPath += 'index.html';
|
||||
const decoded = decodeURIComponent(urlPath);
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(urlPath);
|
||||
} catch {
|
||||
// Malformed `%xx` escape — treat as a missing path, not a 500.
|
||||
res.status(404)
|
||||
.type('text/html; charset=UTF-8')
|
||||
.send('<h1>404</h1><p>Not Found</p>');
|
||||
return;
|
||||
}
|
||||
// pathPosix.normalize strips `..` segments; the join with '/' anchors
|
||||
// it so traversal can't escape the site root.
|
||||
const resolvedUrlPath = pathPosix.normalize(
|
||||
@@ -436,10 +465,63 @@ export const createPuterSiteMiddleware = (
|
||||
}
|
||||
const filePath = rootPath + resolvedUrlPath;
|
||||
|
||||
// Best-effort site config load. A missing / malformed config
|
||||
// never blocks the request — `loadSiteConfig` swallows all
|
||||
// errors and returns null on any failure. The Redis cache
|
||||
// keyed on `rootDirId` keeps the hot path off S3 for the
|
||||
// common case where the same subdomain gets repeat visits.
|
||||
let siteConfig: SiteConfig | null = null;
|
||||
try {
|
||||
siteConfig = await loadSiteConfig({
|
||||
rootPath,
|
||||
rootDirId: rootEntry.id,
|
||||
fsEntryStore: layers.stores.fsEntry,
|
||||
fsService: layers.services.fs,
|
||||
cache: layers.clients.redis,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[puter-site] loadSiteConfig threw', e);
|
||||
}
|
||||
|
||||
// Hide the config file from public serving — visitors should
|
||||
// see the same 404 as for any other missing path so the
|
||||
// deployment shape isn't leaked.
|
||||
const isConfigRequest = isSiteConfigPath(resolvedUrlPath);
|
||||
|
||||
// Subdomain hosting bypasses ACL by design: anything the owner placed
|
||||
// under the registered root_dir is treated as public. Path traversal
|
||||
// is blocked above by `pathPosix.normalize` anchoring at `/`.
|
||||
const entry = await layers.stores.fsEntry.getEntryByPath(filePath);
|
||||
let entry = isConfigRequest
|
||||
? null
|
||||
: await layers.stores.fsEntry.getEntryByPath(filePath);
|
||||
if (entry?.isDir) {
|
||||
// Folder request → fall back to <folder>/index.html, the same
|
||||
// way `/` is rewritten to `/index.html` at the site root above.
|
||||
entry = await layers.stores.fsEntry.getEntryByPath(
|
||||
pathPosix.join(filePath, 'index.html'),
|
||||
);
|
||||
}
|
||||
|
||||
// Custom error page fallback. On a 404 we consult the site config
|
||||
// for a rule and, if one resolves to an existing file, serve that
|
||||
// instead. Critical: we do this exactly once — the error page
|
||||
// itself never re-enters error handling, so a misconfigured
|
||||
// `errors.404.file` that doesn't exist falls through to the
|
||||
// default 404 page rather than looping.
|
||||
let statusOverride: number | undefined;
|
||||
if (!entry || entry.isDir) {
|
||||
const errorTarget = resolveErrorTarget(siteConfig, 404, rootPath);
|
||||
if (errorTarget) {
|
||||
const candidate = await layers.stores.fsEntry.getEntryByPath(
|
||||
errorTarget.absPath,
|
||||
);
|
||||
if (candidate && !candidate.isDir) {
|
||||
entry = candidate;
|
||||
statusOverride = errorTarget.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry || entry.isDir) {
|
||||
res.status(404)
|
||||
.type('text/html; charset=UTF-8')
|
||||
@@ -448,8 +530,13 @@ export const createPuterSiteMiddleware = (
|
||||
}
|
||||
|
||||
// Stream the file. `fsEntry.readContent` honours Range + emits
|
||||
// ETag/Last-Modified when the S3 layer returns them.
|
||||
// ETag/Last-Modified when the S3 layer returns them. Range
|
||||
// requests are suppressed when serving a custom error page so
|
||||
// the visitor always receives the full document with the
|
||||
// configured status code (no 206 with a stale `bytes=...`
|
||||
// header from the original request).
|
||||
const range =
|
||||
statusOverride === undefined &&
|
||||
typeof req.headers.range === 'string'
|
||||
? req.headers.range
|
||||
: undefined;
|
||||
@@ -466,6 +553,25 @@ export const createPuterSiteMiddleware = (
|
||||
const mime =
|
||||
contentTypeFromMime(entry.name) || 'application/octet-stream';
|
||||
res.setHeader('Content-Type', mime);
|
||||
|
||||
// Fire-and-forget signal for downstream extensions
|
||||
if (mime === 'text/html' || mime === 'application/xhtml+xml') {
|
||||
try {
|
||||
layers.clients.event.emit(
|
||||
'site.htmlServed',
|
||||
{
|
||||
subdomain,
|
||||
entry,
|
||||
host,
|
||||
requestPath: req.path,
|
||||
mime,
|
||||
},
|
||||
{},
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('[puter-site] site.htmlServed emit failed', e);
|
||||
}
|
||||
}
|
||||
if (download.contentLength !== null) {
|
||||
res.setHeader('Content-Length', String(download.contentLength));
|
||||
}
|
||||
@@ -476,7 +582,7 @@ export const createPuterSiteMiddleware = (
|
||||
res.setHeader('Last-Modified', download.lastModified.toUTCString());
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.status(range ? 206 : 200);
|
||||
res.status(statusOverride ?? (range ? 206 : 200));
|
||||
|
||||
// Best-effort egress metering against the site owner. The request
|
||||
// itself is unauthenticated (public site visitor), so we can't use
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* 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 { posix as pathPosix } from 'node:path';
|
||||
import type { FSEntry } from '../../../stores/fs/FSEntry';
|
||||
|
||||
/**
|
||||
* Minimal Redis surface we need — `get` / `set` with EX TTL. Typed as a
|
||||
* subset of ioredis so callers can pass either the real cluster client
|
||||
* or a mock without needing the full Cluster type here.
|
||||
*/
|
||||
export interface SiteConfigCache {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(
|
||||
key: string,
|
||||
value: string,
|
||||
mode: 'EX',
|
||||
ttlSeconds: number,
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-level configuration loaded from `.puter_site_config` at the site
|
||||
* root. Lets a hosted site customize how the server responds for it —
|
||||
* currently limited to error-page mapping (e.g. SPA fallback that serves
|
||||
* `/index.html` for any 404 with a 200 status).
|
||||
*
|
||||
* The on-disk JSON shape:
|
||||
*
|
||||
* {
|
||||
* "errors": {
|
||||
* "404": { "file": "/index.html", "status": 200 }
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Other static-hosting platforms (Vercel `vercel.json`, Netlify/Amplify
|
||||
* `_redirects`, nginx `error_page`) express the same idea with different
|
||||
* syntax. The internal `SiteConfig` is intentionally narrow so additional
|
||||
* parsers can normalize to it without churning the consumer in
|
||||
* `puterSite.ts` — see `SITE_CONFIG_FILENAMES` for the lookup list.
|
||||
*
|
||||
* Security posture (every input here originates from a user-uploaded file
|
||||
* served on the open internet):
|
||||
* - the on-disk file is size-capped before parse (`MAX_CONFIG_BYTES`),
|
||||
* - JSON parsing is wrapped in try/catch — a malformed file silently
|
||||
* falls back to default behavior, never 5xx,
|
||||
* - every `file` value is normalized as if it were a URL path: it must
|
||||
* start with `/`, gets `pathPosix.normalize`d so `..` is collapsed,
|
||||
* and is re-anchored under the site root before any FS lookup,
|
||||
* - status codes are clamped to a strict allow-list (the request side
|
||||
* only honours 4xx/5xx error keys; the response side validates the
|
||||
* `status` is a legitimate HTTP integer),
|
||||
* - the config file itself is hidden from public serving by the caller
|
||||
* (`isSiteConfigPath`) — same status/body as any other missing path,
|
||||
* no separate 403 that would leak its existence.
|
||||
*
|
||||
* Loop safety is the consumer's responsibility: when serving an error
|
||||
* page, do NOT re-consult `errors` if the error page itself is missing,
|
||||
* otherwise a misconfigured site could spin a 404→404→404 cycle.
|
||||
*/
|
||||
export interface SiteErrorRule {
|
||||
/** Absolute path under the site root (e.g. `/index.html`). */
|
||||
file: string;
|
||||
/** HTTP status to return when serving this error page (200–599). */
|
||||
status: number;
|
||||
}
|
||||
|
||||
export interface SiteConfig {
|
||||
/** Map of HTTP status code → custom error rule. Keys are 4xx/5xx. */
|
||||
errors: Record<number, SiteErrorRule>;
|
||||
}
|
||||
|
||||
const MAX_CONFIG_BYTES = 64 * 1024;
|
||||
|
||||
// Cache key prefix is distinct from `subdomains:` (SubdomainStore) and
|
||||
// other Redis users — keep this in sync if you rename, otherwise stale
|
||||
// entries from prior deploys could be read back as configs.
|
||||
const CACHE_KEY_PREFIX = 'puter-site-config:';
|
||||
const CACHE_TTL_SECONDS = 60;
|
||||
// Sentinel for "we looked, the site has no config" so repeated visits
|
||||
// to a config-less site don't keep round-tripping S3 to confirm.
|
||||
const NEGATIVE_CACHE_MARKER = '__none__';
|
||||
|
||||
/**
|
||||
* Filenames consulted at the site root, in priority order. First file
|
||||
* that parses to a non-empty config wins. Extending this list to add
|
||||
* Vercel / Netlify / nginx adapters is the entrypoint for multi-format
|
||||
* support — each parser receives the raw text and returns a normalized
|
||||
* `SiteConfig` (or null if the file isn't valid in that format).
|
||||
*/
|
||||
const SITE_CONFIG_FILENAMES: ReadonlyArray<{
|
||||
name: string;
|
||||
parse: (text: string) => SiteConfig | null;
|
||||
}> = [{ name: '.puter_site_config', parse: parsePuterSiteConfig }];
|
||||
|
||||
/**
|
||||
* Returns true if `urlPath` (already normalized to start with `/`) names
|
||||
* the site config file. Used by `puterSite.ts` to suppress direct
|
||||
* serving so the deployment shape isn't leaked to visitors.
|
||||
*/
|
||||
export function isSiteConfigPath(urlPath: string): boolean {
|
||||
const base = pathPosix.basename(urlPath);
|
||||
return SITE_CONFIG_FILENAMES.some((f) => f.name === base);
|
||||
}
|
||||
|
||||
interface LoadSiteConfigArgs {
|
||||
/** Absolute site root path (e.g. `/<username>/Public`). */
|
||||
rootPath: string;
|
||||
/**
|
||||
* Stable identifier for the site root, used as the cache key. We
|
||||
* key on `rootDirId` (not the subdomain) so that renaming a
|
||||
* subdomain — or pointing multiple subdomains at the same
|
||||
* directory — neither orphans nor duplicates the cached entry.
|
||||
*/
|
||||
rootDirId: number;
|
||||
fsEntryStore: {
|
||||
getEntryByPath: (path: string) => Promise<FSEntry | null>;
|
||||
};
|
||||
fsService: {
|
||||
readContent: (
|
||||
entry: FSEntry,
|
||||
options?: { range?: string },
|
||||
) => Promise<{
|
||||
body: NodeJS.ReadableStream;
|
||||
contentLength: number | null;
|
||||
}>;
|
||||
};
|
||||
/**
|
||||
* Optional Redis cache. When omitted, every request re-reads the
|
||||
* config from S3 — fine for tests, slow for prod. Cache failures
|
||||
* are swallowed (best-effort): a transient Redis blip just falls
|
||||
* through to the live read, never errors the request.
|
||||
*/
|
||||
cache?: SiteConfigCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate and parse the site config. Returns null when no config file
|
||||
* exists, the file is unreadable, oversized, or fails validation —
|
||||
* callers must treat null as "behave like there is no config" and never
|
||||
* raise the error to the visitor. Errors are logged for the operator
|
||||
* but never surfaced to the request.
|
||||
*/
|
||||
export async function loadSiteConfig(
|
||||
args: LoadSiteConfigArgs,
|
||||
): Promise<SiteConfig | null> {
|
||||
const { rootPath, rootDirId, fsEntryStore, fsService, cache } = args;
|
||||
if (!rootPath || rootPath === '/') return null;
|
||||
// Reject non-positive-integer ids defensively — they'd produce a
|
||||
// weird cache key and we'd cache something nonsensical against it.
|
||||
// The store contract is positive integers, but this is a hot path
|
||||
// for untrusted-origin traffic so we belt-and-brace it.
|
||||
const cacheable =
|
||||
Number.isInteger(rootDirId) && rootDirId > 0 && cache !== undefined;
|
||||
const cacheKey = cacheable ? `${CACHE_KEY_PREFIX}${rootDirId}` : null;
|
||||
|
||||
if (cacheable && cacheKey) {
|
||||
try {
|
||||
const raw = await cache!.get(cacheKey);
|
||||
if (raw === NEGATIVE_CACHE_MARKER) return null;
|
||||
if (typeof raw === 'string' && raw.length > 0) {
|
||||
// Trust the cached shape — it was produced by this
|
||||
// same parser, validated, and the TTL is short. Still
|
||||
// wrap in try/catch in case a different deploy wrote a
|
||||
// legacy/unparseable value to the same key.
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as SiteConfig;
|
||||
if (parsed && parsed.errors) return parsed;
|
||||
} catch {
|
||||
/* fall through to a fresh load */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cache transport failure — fall through to live load.
|
||||
// Don't poison the next request with a half-applied state.
|
||||
}
|
||||
}
|
||||
|
||||
for (const { name, parse } of SITE_CONFIG_FILENAMES) {
|
||||
const filePath = pathPosix.join(rootPath, name);
|
||||
let entry: FSEntry | null;
|
||||
try {
|
||||
entry = await fsEntryStore.getEntryByPath(filePath);
|
||||
} catch (e) {
|
||||
console.warn('[puter-site] config lookup failed', {
|
||||
path: filePath,
|
||||
error: (e as Error)?.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!entry || entry.isDir) continue;
|
||||
// Reject oversized configs before paying the S3 read. `size` can
|
||||
// be null for legacy entries — accept and rely on the streaming
|
||||
// byte counter below.
|
||||
if (entry.size !== null && entry.size > MAX_CONFIG_BYTES) {
|
||||
console.warn('[puter-site] config too large, ignoring', {
|
||||
path: filePath,
|
||||
size: entry.size,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let text: string | null;
|
||||
try {
|
||||
text = await readBoundedText(entry, fsService, MAX_CONFIG_BYTES);
|
||||
} catch (e) {
|
||||
console.warn('[puter-site] config read failed', {
|
||||
path: filePath,
|
||||
error: (e as Error)?.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Null means the stream exceeded the byte cap mid-read.
|
||||
if (text === null) continue;
|
||||
|
||||
let parsed: SiteConfig | null;
|
||||
try {
|
||||
parsed = parse(text);
|
||||
} catch (e) {
|
||||
console.warn('[puter-site] config parse threw', {
|
||||
path: filePath,
|
||||
error: (e as Error)?.message,
|
||||
});
|
||||
parsed = null;
|
||||
}
|
||||
if (parsed && Object.keys(parsed.errors).length > 0) {
|
||||
if (cacheable && cacheKey) {
|
||||
writeCache(
|
||||
cache!,
|
||||
cacheKey,
|
||||
JSON.stringify(parsed),
|
||||
CACHE_TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// No file matched (or all matched files parsed to empty). Cache
|
||||
// the negative result so config-less sites — which are the common
|
||||
// case — don't keep paying the FS lookup on every visit.
|
||||
if (cacheable && cacheKey) {
|
||||
writeCache(cache!, cacheKey, NEGATIVE_CACHE_MARKER, CACHE_TTL_SECONDS);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fire-and-forget cache write. We never await it on the request path
|
||||
// because failure is non-fatal and we don't want a slow Redis to add
|
||||
// latency to the response — the next request just re-loads from FS.
|
||||
function writeCache(
|
||||
cache: SiteConfigCache,
|
||||
key: string,
|
||||
value: string,
|
||||
ttlSeconds: number,
|
||||
): void {
|
||||
cache.set(key, value, 'EX', ttlSeconds).catch(() => {
|
||||
/* swallow — cache writes are best-effort */
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a custom error rule for `statusCode` into an absolute FS path
|
||||
* under `rootPath`. Returns null if no rule applies or the rule's `file`
|
||||
* would escape the site root after normalization. Caller is responsible
|
||||
* for loop prevention (don't recurse into error handling when serving
|
||||
* the error page itself).
|
||||
*/
|
||||
export function resolveErrorTarget(
|
||||
config: SiteConfig | null,
|
||||
statusCode: number,
|
||||
rootPath: string,
|
||||
): { absPath: string; status: number } | null {
|
||||
if (!config) return null;
|
||||
const rule = config.errors[statusCode];
|
||||
if (!rule) return null;
|
||||
// Defence-in-depth: re-normalize at use time. `parsePuterSiteConfig`
|
||||
// already does this, but keeping the contract here means future
|
||||
// parsers (Vercel, Netlify) only need to return raw paths and can
|
||||
// rely on this final guard.
|
||||
const normalized = pathPosix.normalize(pathPosix.join('/', rule.file));
|
||||
if (!normalized.startsWith('/') || normalized === '/') return null;
|
||||
const absPath = rootPath.replace(/\/+$/, '') + normalized;
|
||||
return { absPath, status: rule.status };
|
||||
}
|
||||
|
||||
// -- Parsers ---------------------------------------------------------
|
||||
|
||||
function parsePuterSiteConfig(text: string): SiteConfig | null {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const errorsField = (raw as { errors?: unknown }).errors;
|
||||
const errors: Record<number, SiteErrorRule> = {};
|
||||
if (errorsField && typeof errorsField === 'object') {
|
||||
for (const [k, v] of Object.entries(
|
||||
errorsField as Record<string, unknown>,
|
||||
)) {
|
||||
const code = Number(k);
|
||||
// Only 4xx/5xx are meaningful as "error pages" — accepting
|
||||
// 2xx/3xx keys would let a config silently override the
|
||||
// happy path, which is out of scope and a footgun.
|
||||
if (!Number.isInteger(code) || code < 400 || code > 599) continue;
|
||||
if (!v || typeof v !== 'object') continue;
|
||||
const rule = v as { file?: unknown; status?: unknown };
|
||||
if (typeof rule.file !== 'string' || !rule.file.startsWith('/')) {
|
||||
continue;
|
||||
}
|
||||
// Default the response status to the matched error code so a
|
||||
// bare `{ file: '/404.html' }` Just Works. Allow overriding
|
||||
// for the SPA-fallback case where 404 should turn into 200.
|
||||
let status: number;
|
||||
if (rule.status === undefined) {
|
||||
status = code;
|
||||
} else if (
|
||||
typeof rule.status === 'number' &&
|
||||
Number.isInteger(rule.status) &&
|
||||
rule.status >= 200 &&
|
||||
rule.status <= 599
|
||||
) {
|
||||
status = rule.status;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
const normalized = pathPosix.normalize(
|
||||
pathPosix.join('/', rule.file),
|
||||
);
|
||||
// Empty/root after normalize is meaningless as an error page
|
||||
// (it would resolve to the site root itself).
|
||||
if (normalized === '/') continue;
|
||||
errors[code] = { file: normalized, status };
|
||||
}
|
||||
}
|
||||
return { errors };
|
||||
}
|
||||
|
||||
// Returns null when the stream exceeds `maxBytes` (caller treats as
|
||||
// "config too large, ignore"). The stream is always destroyed before
|
||||
// return so the S3 connection doesn't leak on early break.
|
||||
async function readBoundedText(
|
||||
entry: FSEntry,
|
||||
fsService: LoadSiteConfigArgs['fsService'],
|
||||
maxBytes: number,
|
||||
): Promise<string | null> {
|
||||
const download = await fsService.readContent(entry);
|
||||
const stream = download.body as NodeJS.ReadableStream & {
|
||||
destroy?: () => void;
|
||||
};
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
let exceeded = false;
|
||||
try {
|
||||
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
exceeded = true;
|
||||
break;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
} finally {
|
||||
stream.destroy?.();
|
||||
}
|
||||
if (exceeded) return null;
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
@@ -42,11 +42,11 @@ import { HttpError } from '../HttpError.js';
|
||||
* one via the `backend` option; omitting it uses the configured default.
|
||||
*/
|
||||
|
||||
// ── Backend names ────────────────────────────────────────────────────
|
||||
// -- Backend names ----------------------------------------------------
|
||||
|
||||
export const RATE_LIMIT_BACKENDS = ['memory', 'redis', 'kv'];
|
||||
|
||||
// ── Memory backend ──────────────────────────────────────────────────
|
||||
// -- Memory backend --------------------------------------------------
|
||||
|
||||
// Hard cap and retention bound memory in the worst case. Without them,
|
||||
// one-shot keys (visited once, never again) leak forever: their single
|
||||
@@ -87,7 +87,7 @@ async function checkMemory(key, limit, windowMs) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Redis backend ───────────────────────────────────────────────────
|
||||
// -- Redis backend ---------------------------------------------------
|
||||
|
||||
async function checkRedis(
|
||||
/** @type {import('ioredis').Cluster} */
|
||||
@@ -127,7 +127,7 @@ async function checkRedis(
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── KV backend ──────────────────────────────────────────────────────
|
||||
// -- KV backend ------------------------------------------------------
|
||||
|
||||
async function checkKv(kv, key, limit, windowMs) {
|
||||
const prefix = `rate:${key}:`;
|
||||
@@ -151,7 +151,7 @@ async function checkKv(kv, key, limit, windowMs) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Concurrent in-flight backends ───────────────────────────────────
|
||||
// -- Concurrent in-flight backends -----------------------------------
|
||||
//
|
||||
// Concurrent limiting is the *other* shape: rather than "no more than X
|
||||
// hits in Y window", it's "no more than X requests in flight at once".
|
||||
@@ -247,7 +247,7 @@ async function acquireKvConcurrent(kv, key, limit) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Backend registry ────────────────────────────────────────────────
|
||||
// -- Backend registry ------------------------------------------------
|
||||
//
|
||||
// All registered backends stay live simultaneously; selection happens
|
||||
// per call via the `backend` option. The `default` slot is what callers
|
||||
@@ -345,7 +345,7 @@ function resolveBackend(name) {
|
||||
return backends[defaultBackendName];
|
||||
}
|
||||
|
||||
// ── Key strategies ──────────────────────────────────────────────────
|
||||
// -- Key strategies --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a rate-limit key from the request.
|
||||
@@ -406,7 +406,7 @@ function fingerprint(req) {
|
||||
.slice(0, 16);
|
||||
}
|
||||
|
||||
// ── Route middleware ────────────────────────────────────────────────
|
||||
// -- Route middleware ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Express middleware factory. Reads from the materialised route option:
|
||||
@@ -455,7 +455,7 @@ export function rateLimitGate(opts) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Driver-call helper ──────────────────────────────────────────────
|
||||
// -- Driver-call helper ----------------------------------------------
|
||||
|
||||
/**
|
||||
* Check rate limit for a driver call. Called from DriverController's
|
||||
@@ -492,7 +492,30 @@ export async function checkDriverRateLimit(req, ifaceName, method, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subscription-aware limit resolution ─────────────────────────────
|
||||
// -- Imperative helper -----------------------------------------------
|
||||
|
||||
/**
|
||||
* Imperative rate-limit check (no middleware shape). For handlers that
|
||||
* need a second-axis limit after their route-level limit fires —
|
||||
* e.g. `/auth/migrate-token` limits per IP at the route, then per
|
||||
* `auth_id` once the v1 token is decoded. Returns true if allowed,
|
||||
* false if rate-limited. Fails open on backend error, matching the
|
||||
* rest of this module's policy.
|
||||
*/
|
||||
export async function checkRateLimit(key, limit, windowMs, backend) {
|
||||
const bk = resolveBackend(backend);
|
||||
try {
|
||||
return await bk.rate(key, limit, windowMs);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[rate-limit] imperative check failed, failing open:',
|
||||
err,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// -- Subscription-aware limit resolution -----------------------------
|
||||
|
||||
/**
|
||||
* Per-request limit resolution shared by `rateLimitGate` and
|
||||
@@ -517,7 +540,7 @@ async function resolveSubscriptionLimit(req, opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Concurrency gate + driver helper ────────────────────────────────
|
||||
// -- Concurrency gate + driver helper --------------------------------
|
||||
|
||||
/**
|
||||
* Express middleware factory for concurrent in-flight limiting:
|
||||
|
||||
@@ -63,6 +63,57 @@ export interface UserProtectedGateDeps {
|
||||
tokenService: TokenService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cookie-only credential check. Rejects API tokens, GUI tokens,
|
||||
* `x-api-key` headers, and query-string tokens — only a request whose
|
||||
* resolved `req.token` matches the session cookie passes. Used both by
|
||||
* `createUserProtectedGate` (as its first stage) and standalone by
|
||||
* routes that need session-cookie-only credentials without the full
|
||||
* password-revalidation gate (e.g. `/auth/revoke-*`, where an access
|
||||
* token shouldn't be able to revoke its own issuing web session).
|
||||
*/
|
||||
export const createSessionCookieGate = (config: IConfig): RequestHandler => {
|
||||
const cookieName = config.cookie_name ?? 'puter_token';
|
||||
return (req, _res, next) => {
|
||||
const cookieValue = req.cookies?.[cookieName];
|
||||
if (!cookieValue || (req.token && req.token !== cookieValue)) {
|
||||
return next(
|
||||
new HttpError(401, 'Session cookie required', {
|
||||
legacyCode: 'session_required',
|
||||
}),
|
||||
);
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Same intent as `createSessionCookieGate` ("an access token can't revoke
|
||||
* its issuing web session") but identity-shape based instead of cookie-
|
||||
* value based, so it works for the cross-subdomain GUI -> api.puter.com
|
||||
* call path where the session cookie isn't sent. Accepts a plain user
|
||||
* actor (web/session/gui-token); rejects app-under-user actors and
|
||||
* access-token actors. Pair with `antiCsrf: true` on the route.
|
||||
*/
|
||||
export const createWebSessionActorGate = (): RequestHandler => {
|
||||
return (req, _res, next) => {
|
||||
const actor = req.actor;
|
||||
if (
|
||||
!actor?.user ||
|
||||
!actor.session ||
|
||||
(actor as { app?: unknown }).app ||
|
||||
(actor as { accessToken?: unknown }).accessToken
|
||||
) {
|
||||
return next(
|
||||
new HttpError(401, 'Web session required', {
|
||||
legacyCode: 'session_required',
|
||||
}),
|
||||
);
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
export interface UserProtectedGateOptions {
|
||||
/** Allow temp accounts (no password + no email) through. Default: false. */
|
||||
allowTempUsers?: boolean;
|
||||
@@ -95,19 +146,10 @@ export const createUserProtectedGate = (
|
||||
options: UserProtectedGateOptions = {},
|
||||
): RequestHandler[] => {
|
||||
const { config, userStore, oidcService, tokenService } = deps;
|
||||
const cookieName = config.cookie_name ?? 'puter_token';
|
||||
const allowTemp = !!options.allowTempUsers;
|
||||
|
||||
// 1. Session cookie only.
|
||||
const requireSessionCookie: RequestHandler = (req, _res, next) => {
|
||||
const cookieValue = req.cookies?.[cookieName];
|
||||
if (!cookieValue || (req.token && req.token !== cookieValue)) {
|
||||
throw new HttpError(401, 'Session cookie required', {
|
||||
legacyCode: 'session_required',
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
// 1. Session cookie only. Shared with the standalone cookie-only gate.
|
||||
const requireSessionCookie = createSessionCookieGate(config);
|
||||
|
||||
// 2. Fresh user row (bypass cache to catch just-suspended accounts).
|
||||
// `getById` doesn't take options; go through `getByProperty` with
|
||||
|
||||
@@ -80,12 +80,15 @@ export interface RouteOptions {
|
||||
*/
|
||||
subdomain?: string | string[];
|
||||
|
||||
/** Reject anonymous + suspended-user requests with 401/403. */
|
||||
/** Reject anonymous + suspended-user requests with 401/403. Only allows user and app actors */
|
||||
requireAuth?: boolean;
|
||||
|
||||
/** Reject app/access-token actors. Implies `requireAuth`. */
|
||||
requireUserActor?: boolean;
|
||||
|
||||
/** Allows access-tokens */
|
||||
allowAccessToken?: boolean;
|
||||
|
||||
/**
|
||||
* Reject unless the actor's username is `admin`, `system`, or one of the
|
||||
* extras in this array. `true` means just `admin`/`system`; an array adds
|
||||
@@ -271,7 +274,7 @@ export const ROUTES_METADATA_KEY = '__puterRoutes' as const;
|
||||
/** Internal: the property name used to stash a controller's path prefix. */
|
||||
export const PREFIX_METADATA_KEY = '__puterControllerPrefix' as const;
|
||||
|
||||
// ── Type narrowing helpers ──────────────────────────────────────────
|
||||
// -- Type narrowing helpers ------------------------------------------
|
||||
//
|
||||
// When a route declares a gate option (requireAuth, requireUserActor,
|
||||
// adminOnly, allowedAppIds), the materializer guarantees the corresponding
|
||||
|
||||
@@ -41,6 +41,7 @@ import { OpenRouterProvider } from './providers/openrouter/OpenRouterProvider.js
|
||||
import { TogetherAIProvider } from './providers/together/TogetherAIProvider.js';
|
||||
import { XAIProvider } from './providers/xai/XAIProvider.js';
|
||||
import { ZAIProvider } from './providers/zai/ZAIProvider.js';
|
||||
import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js';
|
||||
import { MoonshotProvider } from './providers/moonshot/MoonshotProvider.js';
|
||||
import type {
|
||||
IChatCompleteResult,
|
||||
@@ -59,6 +60,136 @@ import { EventMap } from '../../clients/event/types.js';
|
||||
|
||||
const MAX_FALLBACKS = 4; // includes first attempt
|
||||
|
||||
type ProviderAttempt = {
|
||||
model: string;
|
||||
provider: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture what an upstream provider gave us so the classifier downstream
|
||||
* can decide a user-facing status code instead of always returning 500.
|
||||
*
|
||||
* OpenAI-SDK-based providers throw `APIError` with `.status` and a
|
||||
* structured `.error` body — pull both. For arbitrary errors we fall
|
||||
* back to the message and a status sniff so providers that throw plain
|
||||
* `Error("... 503 ...")` strings still classify correctly.
|
||||
*/
|
||||
const toAttempt = (
|
||||
modelId: string,
|
||||
providerId: string,
|
||||
err: unknown,
|
||||
): ProviderAttempt => {
|
||||
const e = err as {
|
||||
status?: number;
|
||||
statusCode?: number;
|
||||
code?: string;
|
||||
error?: { code?: string; type?: string; message?: string };
|
||||
message?: string;
|
||||
};
|
||||
const message = e?.message ?? (typeof err === 'string' ? err : String(err));
|
||||
let status = e?.status ?? e?.statusCode;
|
||||
if (status === undefined) {
|
||||
const m = message.match(/\b(4\d\d|5\d\d)\b/);
|
||||
if (m) status = Number(m[1]);
|
||||
}
|
||||
return {
|
||||
model: modelId,
|
||||
provider: providerId,
|
||||
status,
|
||||
code: e?.error?.code ?? e?.code,
|
||||
error: message,
|
||||
};
|
||||
};
|
||||
|
||||
const isRateLimit = (a: ProviderAttempt) =>
|
||||
a.status === 429 ||
|
||||
/rate[\s_-]?limit|too many requests|quota/i.test(a.error);
|
||||
|
||||
const isAuthFailure = (a: ProviderAttempt) =>
|
||||
a.status === 401 ||
|
||||
a.status === 403 ||
|
||||
/unauthorized|forbidden|invalid api key/i.test(a.error);
|
||||
|
||||
const isUpstream5xx = (a: ProviderAttempt) =>
|
||||
(a.status !== undefined && a.status >= 500) ||
|
||||
/provider returned error|internal server error|service unavailable|bad gateway/i.test(
|
||||
a.error,
|
||||
);
|
||||
|
||||
/**
|
||||
* Map an exhausted fallback chain to a single user-facing HttpError.
|
||||
*
|
||||
* Per-class rules (see also alarm gate in server.ts):
|
||||
* - all rate-limited → 429 `upstream_rate_limited` (paged: forced alert)
|
||||
* - all auth failures → 500 `upstream_auth_failed` (paged: our config)
|
||||
* - all upstream 5xx → 400 `upstream_provider_unavailable` (no page)
|
||||
* - all upstream 4xx (other) → 400 `upstream_bad_request` (no page)
|
||||
* - mixed → 400 `upstream_failed` (no page)
|
||||
*/
|
||||
const classifyAttempts = (attempts: ProviderAttempt[]): HttpError => {
|
||||
const fields = { attempts };
|
||||
if (attempts.length === 0) {
|
||||
return new HttpError(500, 'No providers attempted', {
|
||||
legacyCode: 'internal_error',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
|
||||
if (attempts.every(isRateLimit)) {
|
||||
return new HttpError(429, 'AI provider rate limit exceeded', {
|
||||
legacyCode: 'upstream_rate_limited',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
if (attempts.every(isAuthFailure)) {
|
||||
return new HttpError(500, 'AI provider authentication failed', {
|
||||
legacyCode: 'upstream_auth_failed',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
if (attempts.every(isUpstream5xx)) {
|
||||
return new HttpError(400, 'AI provider unavailable', {
|
||||
legacyCode: 'upstream_provider_unavailable',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
if (
|
||||
attempts.every(
|
||||
(a) => a.status !== undefined && a.status >= 400 && a.status < 500,
|
||||
)
|
||||
) {
|
||||
return new HttpError(400, attempts[0].error, {
|
||||
legacyCode: 'upstream_bad_request',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
|
||||
// Mixed failures where at least one attempt is clearly upstream
|
||||
// (had an HTTP status from the SDK) means "AI providers couldn't
|
||||
// satisfy the request" — expose, don't page.
|
||||
const isUpstreamSignal = (a: ProviderAttempt) =>
|
||||
a.status !== undefined ||
|
||||
isRateLimit(a) ||
|
||||
isAuthFailure(a) ||
|
||||
isUpstream5xx(a);
|
||||
if (attempts.some(isUpstreamSignal)) {
|
||||
return new HttpError(400, 'All AI providers failed', {
|
||||
legacyCode: 'upstream_failed',
|
||||
fields,
|
||||
});
|
||||
}
|
||||
|
||||
// Nothing identifiable as an upstream issue — treat as our bug
|
||||
// and let the global alarm fire so we actually find out.
|
||||
return new HttpError(500, 'All providers failed', {
|
||||
legacyCode: 'internal_error',
|
||||
fields,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Driver implementing the `puter-chat-completion` interface.
|
||||
*
|
||||
@@ -86,7 +217,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
this.#buildModelMap();
|
||||
}
|
||||
|
||||
// ── Interface methods ───────────────────────────────────────────
|
||||
// -- Interface methods -------------------------------------------
|
||||
|
||||
async models() {
|
||||
const seen = new Set<string>();
|
||||
@@ -211,7 +342,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Credit / subscription gates (metering) ────────────────────
|
||||
// -- Credit / subscription gates (metering) --------------------
|
||||
// Cheap pre-flight: reject when the user can't afford even the
|
||||
// approximate input cost, keep subscriber-only models gated, and
|
||||
// cap `max_tokens` so output can't exceed remaining credits.
|
||||
@@ -289,8 +420,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
const attempts: { model: string; provider: string; error: string }[] =
|
||||
[];
|
||||
const attempts: ProviderAttempt[] = [];
|
||||
let res: IChatCompleteResult | undefined;
|
||||
|
||||
try {
|
||||
@@ -300,17 +430,12 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
provider: model.provider,
|
||||
});
|
||||
} catch (e) {
|
||||
const error = e as Error;
|
||||
attempts.push({
|
||||
model: model.id,
|
||||
provider: model.provider!,
|
||||
error: error?.message ?? String(e),
|
||||
});
|
||||
attempts.push(toAttempt(model.id, model.provider!, e));
|
||||
|
||||
// Fallback loop
|
||||
const tried = [model.id];
|
||||
const triedProviders = [model.provider!];
|
||||
let lastError: Error | null = error;
|
||||
let lastError: Error | null = e as Error;
|
||||
|
||||
while (lastError && tried.length < MAX_FALLBACKS) {
|
||||
const fallback = this.#findFallback(
|
||||
@@ -347,20 +472,15 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
lastError = null;
|
||||
} catch (fbErr) {
|
||||
lastError = fbErr as Error;
|
||||
attempts.push({
|
||||
model: fallback.id,
|
||||
provider: fallback.provider!,
|
||||
error: lastError?.message ?? String(fbErr),
|
||||
});
|
||||
attempts.push(
|
||||
toAttempt(fallback.id, fallback.provider!, fbErr),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!res) {
|
||||
throw new HttpError(500, 'All providers failed', {
|
||||
legacyCode: 'internal_error',
|
||||
fields: { attempts },
|
||||
});
|
||||
throw classifyAttempts(attempts);
|
||||
}
|
||||
|
||||
const username = actor.user?.username;
|
||||
@@ -435,7 +555,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
return streamResult as unknown as IChatCompleteResult;
|
||||
}
|
||||
|
||||
// ── Post-completion audit event ──────────────────────────────
|
||||
// -- Post-completion audit event ------------------------------
|
||||
// Only for non-streaming results (streaming emits from the
|
||||
// `chatStream.end` wrapper above). Extensions like prompt_block /
|
||||
// prodMeteringAndBilling listen for this to log completions.
|
||||
@@ -531,8 +651,18 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
// thinking_tokens → output rate fallback
|
||||
let rate = costs[key];
|
||||
if (typeof rate !== 'number' || !Number.isFinite(rate)) {
|
||||
if (key === 'thinking_tokens' && outputRate !== undefined) {
|
||||
if (isOutputKey(key) && outputRate !== undefined) {
|
||||
rate = outputRate;
|
||||
} else if (!isOutputKey(key)) {
|
||||
const inputRateRaw = costs[inputKey];
|
||||
if (
|
||||
typeof inputRateRaw === 'number' &&
|
||||
Number.isFinite(inputRateRaw)
|
||||
) {
|
||||
rate = inputRateRaw;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
@@ -642,7 +772,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Provider registration ───────────────────────────────────────
|
||||
// -- Provider registration ---------------------------------------
|
||||
|
||||
#registerProviders() {
|
||||
const providers = this.config.providers ?? {};
|
||||
@@ -751,13 +881,13 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
const openrouter = providers['openrouter'];
|
||||
const openrouterKey = readKey(openrouter);
|
||||
if (openrouterKey) {
|
||||
this.#providers['openrouter'] = new OpenRouterProvider(
|
||||
const alibaba = providers['alibaba'];
|
||||
const alibabaKey = readKey(alibaba);
|
||||
if (alibabaKey) {
|
||||
this.#providers['alibaba'] = new AlibabaProvider(
|
||||
{
|
||||
apiKey: openrouterKey,
|
||||
apiBaseUrl: openrouter?.apiBaseUrl as string | undefined,
|
||||
apiKey: alibabaKey,
|
||||
apiBaseUrl: alibaba?.apiBaseUrl as string | undefined,
|
||||
},
|
||||
metering,
|
||||
);
|
||||
@@ -782,11 +912,23 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
);
|
||||
}
|
||||
|
||||
const openrouter = providers['openrouter'];
|
||||
const openrouterKey = readKey(openrouter);
|
||||
if (openrouterKey) {
|
||||
this.#providers['openrouter'] = new OpenRouterProvider(
|
||||
{
|
||||
apiKey: openrouterKey,
|
||||
apiBaseUrl: openrouter?.apiBaseUrl as string | undefined,
|
||||
},
|
||||
metering,
|
||||
);
|
||||
}
|
||||
|
||||
// Fake provider — always available for testing
|
||||
this.#providers['fake-chat'] = new FakeChatProvider();
|
||||
}
|
||||
|
||||
// ── Model map ───────────────────────────────────────────────────
|
||||
// -- Model map ---------------------------------------------------
|
||||
|
||||
async #buildModelMap() {
|
||||
const AGGREGATORS = new Set(['together-ai', 'openrouter']);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Integration test for the Alibaba provider.
|
||||
*
|
||||
* Uses `qwen-turbo` (cheapest generally-available model). Skipped
|
||||
* when `PUTER_TEST_AI_ALIBABA_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
INTEGRATION_TEST_TIMEOUT_MS,
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
skipUnlessEnv,
|
||||
withTestActor,
|
||||
} from '../../../integrationTestUtil.js';
|
||||
import { AlibabaProvider } from './AlibabaProvider.js';
|
||||
|
||||
const ENV_VAR = 'PUTER_TEST_AI_ALIBABA_API_KEY';
|
||||
|
||||
describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
'AlibabaProvider (integration)',
|
||||
() => {
|
||||
it('returns a non-empty completion from qwen-turbo', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
|
||||
const provider = new AlibabaProvider(
|
||||
{ apiKey: optionalEnv(ENV_VAR)! },
|
||||
makeMeteringStub(),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-turbo',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Say hi in one word.' },
|
||||
],
|
||||
max_tokens: 16,
|
||||
}),
|
||||
);
|
||||
|
||||
const text = (result as { message?: { content?: string } }).message
|
||||
?.content;
|
||||
expect(typeof text === 'string' && text.length > 0).toBe(true);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,720 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Offline unit tests for AlibabaProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs AlibabaProvider directly against the live
|
||||
* wired `MeteringService` so the recording side is exercised end-to-
|
||||
* end. Alibaba is OpenAI-compatible so the OpenAI SDK is mocked at
|
||||
* the module boundary; that's the real network egress point. The
|
||||
* companion integration test (AlibabaProvider.integration.test.ts)
|
||||
* exercises the real DashScope endpoint.
|
||||
*/
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
import { withTestActor } from '../../../integrationTestUtil.js';
|
||||
import { AIChatStream } from '../../utils/Streaming.js';
|
||||
import { ALIBABA_MODELS } from './models.js';
|
||||
import { AlibabaProvider } from './AlibabaProvider.js';
|
||||
|
||||
// ── OpenAI SDK mock ─────────────────────────────────────────────────
|
||||
|
||||
const { createMock, openAICtor } = vi.hoisted(() => {
|
||||
const createMock = vi.fn();
|
||||
const openAICtor = vi.fn();
|
||||
return { createMock, openAICtor };
|
||||
});
|
||||
|
||||
vi.mock('openai', () => {
|
||||
const OpenAICtor = vi.fn().mockImplementation(function (
|
||||
this: Record<string, unknown>,
|
||||
opts: unknown,
|
||||
) {
|
||||
openAICtor(opts);
|
||||
this.chat = { completions: { create: createMock } };
|
||||
});
|
||||
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
|
||||
});
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await setupTestServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
});
|
||||
|
||||
const makeProvider = (config?: { apiKey?: string; apiBaseUrl?: string }) => {
|
||||
const provider = new AlibabaProvider(
|
||||
{ apiKey: 'test-key', ...config },
|
||||
server.services.metering,
|
||||
);
|
||||
return { provider };
|
||||
};
|
||||
|
||||
const asAsyncIterable = <T>(items: T[]): AsyncIterable<T> => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const makeCapturingChatStream = () => {
|
||||
const chunks: string[] = [];
|
||||
const sink = new Writable({
|
||||
write(chunk, _enc, cb) {
|
||||
chunks.push(chunk.toString('utf8'));
|
||||
cb();
|
||||
},
|
||||
});
|
||||
const chatStream = new AIChatStream({ stream: sink });
|
||||
return {
|
||||
chatStream,
|
||||
events: () =>
|
||||
chunks
|
||||
.join('')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line)),
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createMock.mockReset();
|
||||
openAICtor.mockReset();
|
||||
recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── Construction ────────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider construction', () => {
|
||||
it('points the OpenAI SDK at the DashScope base URL with the configured key', () => {
|
||||
makeProvider();
|
||||
expect(openAICtor).toHaveBeenCalledTimes(1);
|
||||
expect(openAICtor).toHaveBeenCalledWith({
|
||||
apiKey: 'test-key',
|
||||
baseURL:
|
||||
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses a custom base URL when configured', () => {
|
||||
makeProvider({ apiBaseUrl: 'https://custom.endpoint/v1' });
|
||||
expect(openAICtor).toHaveBeenCalledWith({
|
||||
apiKey: 'test-key',
|
||||
baseURL: 'https://custom.endpoint/v1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider model catalog', () => {
|
||||
it('returns qwen-plus-latest as the default', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('qwen-plus-latest');
|
||||
});
|
||||
|
||||
it('exposes the static ALIBABA_MODELS list verbatim from models()', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(provider.models()).toBe(ALIBABA_MODELS);
|
||||
});
|
||||
|
||||
it('list() flattens canonical ids and aliases', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const ids = await provider.list();
|
||||
for (const m of ALIBABA_MODELS) {
|
||||
expect(ids).toContain(m.id);
|
||||
for (const a of m.aliases ?? []) {
|
||||
expect(ids).toContain(a);
|
||||
}
|
||||
}
|
||||
expect(ids).toContain('qwen-plus');
|
||||
expect(ids).toContain('qwen/qwen-plus');
|
||||
expect(ids).toContain('qwen-max');
|
||||
expect(ids).toContain('qwen/qwen-max');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Request shape ───────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider.complete request shape', () => {
|
||||
const baseCompletion = {
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'hi', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
};
|
||||
|
||||
it('forwards model + messages and defaults max_tokens to 1000 when caller omits it', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.model).toBe('qwen-plus');
|
||||
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
expect(args.max_tokens).toBe(1000);
|
||||
});
|
||||
|
||||
it('respects an explicit max_tokens override', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 256,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].max_tokens).toBe(256);
|
||||
});
|
||||
|
||||
it('forwards temperature when supplied', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
temperature: 0.7,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].temperature).toBe(0.7);
|
||||
});
|
||||
|
||||
it('omits the `tools` key entirely when no tools are supplied', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect('tools' in args).toBe(false);
|
||||
});
|
||||
|
||||
it('passes tool definitions through unchanged when supplied', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
const tools = [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
];
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
tools,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].tools).toBe(tools);
|
||||
});
|
||||
|
||||
it('only sets stream_options.include_usage when streaming', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: false,
|
||||
}),
|
||||
);
|
||||
const [nonStreamArgs] = createMock.mock.calls[0]!;
|
||||
expect(nonStreamArgs.stream).toBe(false);
|
||||
expect('stream_options' in nonStreamArgs).toBe(false);
|
||||
|
||||
createMock.mockReturnValueOnce(asAsyncIterable([]));
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
const [streamArgs] = createMock.mock.calls[1]!;
|
||||
expect(streamArgs.stream).toBe(true);
|
||||
expect(streamArgs.stream_options).toEqual({ include_usage: true });
|
||||
});
|
||||
|
||||
it('hoists Puter-style tool_use blocks into OpenAI tool_calls before sending', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'call_1',
|
||||
name: 'lookup',
|
||||
input: { q: 'puter' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.messages[0].content).toBeNull();
|
||||
expect(args.messages[0].tool_calls).toEqual([
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup',
|
||||
arguments: JSON.stringify({ q: 'puter' }),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Model resolution ────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider model resolution', () => {
|
||||
const baseCompletion = {
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
};
|
||||
|
||||
it('resolves an exact canonical id', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-max',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('qwen-max');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'alibaba:qwen-max',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves an alias to its canonical id (alias rewriting)', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen/qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('qwen-plus');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'alibaba:qwen-plus',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Non-stream completion ───────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider.complete non-stream output', () => {
|
||||
it('returns the first choice and runs the metered usage calculator', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'hi there', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 100, completion_tokens: 50 },
|
||||
});
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
message: { content: 'hi there', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
});
|
||||
expect((result as { usage: unknown }).usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 0,
|
||||
});
|
||||
|
||||
const qwenPlus = ALIBABA_MODELS.find((m) => m.id === 'qwen-plus')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 0,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('alibaba:qwen-plus');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 100 * Number(qwenPlus.costs.prompt_tokens),
|
||||
completion_tokens: 50 * Number(qwenPlus.costs.completion_tokens),
|
||||
cached_tokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves OpenAI-shaped tool_calls on the assistant response', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup',
|
||||
arguments: '{"q":"puter"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
});
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'do a tool call' }],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: { name: 'lookup', parameters: {} },
|
||||
},
|
||||
],
|
||||
}),
|
||||
)) as { message: { tool_calls?: unknown[] }; finish_reason: string };
|
||||
|
||||
expect(result.finish_reason).toBe('tool_calls');
|
||||
expect(result.message.tool_calls).toEqual([
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'lookup', arguments: '{"q":"puter"}' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('zeroes cached_tokens when prompt_tokens_details is missing', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 7, completion_tokens: 3 },
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage.cached_tokens).toBe(0);
|
||||
expect(overrides).toMatchObject({ cached_tokens: 0 });
|
||||
});
|
||||
|
||||
it('accounts for cached_tokens when prompt_tokens_details is present', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
message: { content: 'ok', role: 'assistant' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 20,
|
||||
prompt_tokens_details: { cached_tokens: 15 },
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen3.6-max-preview',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const model = ALIBABA_MODELS.find(
|
||||
(m) => m.id === 'qwen3.6-max-preview',
|
||||
)!;
|
||||
const [usage, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 20,
|
||||
cached_tokens: 15,
|
||||
});
|
||||
expect(prefix).toBe('alibaba:qwen3.6-max-preview');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 50 * Number(model.costs.prompt_tokens),
|
||||
completion_tokens: 20 * Number(model.costs.completion_tokens),
|
||||
cached_tokens: 15 * Number(model.costs.cached_tokens ?? 0),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Streaming deltas ────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider.complete streaming', () => {
|
||||
it('streams text deltas through to text events and meters final usage', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockReturnValueOnce(
|
||||
asAsyncIterable([
|
||||
{ choices: [{ delta: { content: 'hel' } }] },
|
||||
{ choices: [{ delta: { content: 'lo' } }] },
|
||||
{
|
||||
choices: [{ delta: {} }],
|
||||
usage: { prompt_tokens: 4, completion_tokens: 2 },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'say hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
expect((result as { stream: boolean }).stream).toBe(true);
|
||||
|
||||
const harness = makeCapturingChatStream();
|
||||
await (
|
||||
result as {
|
||||
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
|
||||
}
|
||||
).init_chat_stream({ chatStream: harness.chatStream });
|
||||
|
||||
const events = harness.events();
|
||||
const textEvents = events.filter((e) => e.type === 'text');
|
||||
expect(textEvents.map((e) => e.text)).toEqual(['hel', 'lo']);
|
||||
|
||||
const usageEvent = events.find((e) => e.type === 'usage');
|
||||
expect(usageEvent?.usage).toEqual({
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 2,
|
||||
cached_tokens: 0,
|
||||
});
|
||||
|
||||
const qwenPlus = ALIBABA_MODELS.find((m) => m.id === 'qwen-plus')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('alibaba:qwen-plus');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 4 * Number(qwenPlus.costs.prompt_tokens),
|
||||
completion_tokens: 2 * Number(qwenPlus.costs.completion_tokens),
|
||||
cached_tokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds a tool_use block from streamed function-call deltas', async () => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockReturnValueOnce(
|
||||
asAsyncIterable([
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
function: {
|
||||
name: 'lookup',
|
||||
arguments: '{"q":',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
function: { arguments: '"puter"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
choices: [{ delta: {} }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'do tool call' }],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: { name: 'lookup', parameters: {} },
|
||||
},
|
||||
],
|
||||
stream: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const harness = makeCapturingChatStream();
|
||||
await (
|
||||
result as {
|
||||
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
|
||||
}
|
||||
).init_chat_stream({ chatStream: harness.chatStream });
|
||||
|
||||
const events = harness.events();
|
||||
const toolEvent = events.find((e) => e.type === 'tool_use');
|
||||
expect(toolEvent).toBeDefined();
|
||||
expect(toolEvent?.id).toBe('call_1');
|
||||
expect(toolEvent?.name).toBe('lookup');
|
||||
expect(toolEvent?.input).toEqual({ q: 'puter' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error mapping ───────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider.complete error mapping', () => {
|
||||
it('rethrows errors raised by the OpenAI client unchanged', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const apiError = new Error('DashScope exploded');
|
||||
createMock.mockRejectedValueOnce(apiError);
|
||||
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'qwen-plus',
|
||||
messages: [{ role: 'user', content: 'boom' }],
|
||||
}),
|
||||
),
|
||||
).rejects.toBe(apiError);
|
||||
|
||||
expect(recordSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Moderation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('AlibabaProvider.checkModeration', () => {
|
||||
it('throws — Alibaba provider does not implement moderation', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(() => provider.checkModeration('anything')).toThrow(
|
||||
/not implemented/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 { OpenAI } from 'openai';
|
||||
import { ChatCompletionCreateParams } from 'openai/resources/index.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { IChatProvider, ICompleteArguments } from '../../types.js';
|
||||
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { ALIBABA_MODELS } from './models.js';
|
||||
|
||||
type AlibabaConfig = {
|
||||
apiKey: string;
|
||||
apiBaseUrl?: string;
|
||||
};
|
||||
|
||||
export class AlibabaProvider implements IChatProvider {
|
||||
#openai: OpenAI;
|
||||
|
||||
#meteringService: MeteringService;
|
||||
|
||||
constructor(config: AlibabaConfig, meteringService: MeteringService) {
|
||||
this.#openai = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL:
|
||||
config.apiBaseUrl ??
|
||||
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
});
|
||||
this.#meteringService = meteringService;
|
||||
}
|
||||
|
||||
getDefaultModel() {
|
||||
return 'qwen-plus-latest';
|
||||
}
|
||||
|
||||
models() {
|
||||
return ALIBABA_MODELS;
|
||||
}
|
||||
|
||||
async list() {
|
||||
const models = this.models();
|
||||
const modelNames: string[] = [];
|
||||
for (const model of models) {
|
||||
modelNames.push(model.id);
|
||||
if (model.aliases) {
|
||||
modelNames.push(...model.aliases);
|
||||
}
|
||||
}
|
||||
return modelNames;
|
||||
}
|
||||
|
||||
async complete({
|
||||
messages,
|
||||
stream,
|
||||
model,
|
||||
tools,
|
||||
max_tokens,
|
||||
temperature,
|
||||
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
|
||||
const actor = Context.get('actor');
|
||||
const availableModels = this.models();
|
||||
const modelUsed =
|
||||
availableModels.find((m) =>
|
||||
[m.id, ...(m.aliases || [])].includes(model),
|
||||
) || availableModels.find((m) => m.id === this.getDefaultModel())!;
|
||||
|
||||
messages = await OpenAIUtil.process_input_messages(messages);
|
||||
|
||||
const completion = await this.#openai.chat.completions.create({
|
||||
messages,
|
||||
model: modelUsed.id,
|
||||
...(tools ? { tools } : {}),
|
||||
max_tokens: max_tokens || 1000,
|
||||
temperature,
|
||||
stream,
|
||||
...(stream
|
||||
? {
|
||||
stream_options: { include_usage: true },
|
||||
}
|
||||
: {}),
|
||||
} as ChatCompletionCreateParams);
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const costsOverride = Object.fromEntries(
|
||||
Object.entries(trackedUsage).map(([k, v]) => {
|
||||
return [k, v * modelUsed.costs[k]];
|
||||
}),
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
`alibaba:${modelUsed.id}`,
|
||||
costsOverride,
|
||||
);
|
||||
return trackedUsage;
|
||||
},
|
||||
stream,
|
||||
completion,
|
||||
});
|
||||
}
|
||||
|
||||
checkModeration(_text: string) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -145,9 +145,9 @@ describe('DeepSeekProvider construction', () => {
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('DeepSeekProvider model catalog', () => {
|
||||
it('returns deepseek-chat as the default', () => {
|
||||
it('returns deepseek-v4-flash as the default', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('deepseek-chat');
|
||||
expect(provider.getDefaultModel()).toBe('deepseek-v4-flash');
|
||||
});
|
||||
|
||||
it('exposes the static DEEPSEEK_MODELS list verbatim from models()', () => {
|
||||
@@ -164,8 +164,10 @@ describe('DeepSeekProvider model catalog', () => {
|
||||
expect(ids).toContain(a);
|
||||
}
|
||||
}
|
||||
expect(ids).toContain('deepseek-v4-flash');
|
||||
expect(ids).toContain('deepseek-v4-pro');
|
||||
expect(ids).toContain('deepseek-chat');
|
||||
expect(ids).toContain('deepseek/deepseek-chat');
|
||||
expect(ids).toContain('deepseek/deepseek-v4-pro');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,13 +190,13 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const [args] = createMock.mock.calls[0]!;
|
||||
expect(args.model).toBe('deepseek-chat');
|
||||
expect(args.model).toBe('deepseek-v4-flash');
|
||||
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
expect(args.max_tokens).toBe(1000);
|
||||
});
|
||||
@@ -205,7 +207,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 256,
|
||||
}),
|
||||
@@ -220,7 +222,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -244,7 +246,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
];
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
tools,
|
||||
}),
|
||||
@@ -258,7 +260,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: false,
|
||||
}),
|
||||
@@ -270,7 +272,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
createMock.mockReturnValueOnce(asAsyncIterable([]));
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
@@ -286,7 +288,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
@@ -327,7 +329,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
@@ -364,7 +366,7 @@ describe('DeepSeekProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [
|
||||
{ role: 'user', content: 'do tool call' },
|
||||
{
|
||||
@@ -412,16 +414,16 @@ describe('DeepSeekProvider model resolution', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-reasoner',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-reasoner');
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-reasoner',
|
||||
'deepseek:deepseek-v4-flash',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -432,17 +434,17 @@ describe('DeepSeekProvider model resolution', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
// `deepseek/deepseek-chat` is an alias of `deepseek-chat`.
|
||||
model: 'deepseek/deepseek-chat',
|
||||
// `deepseek/deepseek-v4-flash` is an alias of `deepseek-v4-flash`.
|
||||
model: 'deepseek/deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-chat');
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-chat',
|
||||
'deepseek:deepseek-v4-flash',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -458,11 +460,41 @@ describe('DeepSeekProvider model resolution', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-chat');
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-chat',
|
||||
'deepseek:deepseek-v4-flash',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
// The legacy DeepSeek chat/reasoner ids (and their `deepseek/…` and
|
||||
// `deepseek:deepseek/…` variants) are aliased onto deepseek-v4-flash
|
||||
// so callers using the old names get transparently upgraded — and
|
||||
// metered against the v4-flash canonical prefix.
|
||||
it.each([
|
||||
'deepseek-chat',
|
||||
'deepseek/deepseek-chat',
|
||||
'deepseek:deepseek/deepseek-chat',
|
||||
'deepseek/deepseek-reasoner',
|
||||
'deepseek:deepseek/deepseek-reasoner',
|
||||
])('maps legacy alias %s onto deepseek-v4-flash', async (alias) => {
|
||||
const { provider } = makeProvider();
|
||||
createMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: alias,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'deepseek:deepseek-v4-flash',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -489,7 +521,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -504,8 +536,8 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
cached_tokens: 10,
|
||||
});
|
||||
|
||||
// deepseek-chat costs: prompt=56, completion=168, cached=0.
|
||||
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-chat')!;
|
||||
// deepseek-v4-flash costs: prompt=14, completion=28, cached=0.28
|
||||
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-flash')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
@@ -514,7 +546,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
cached_tokens: 10,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('deepseek:deepseek-chat');
|
||||
expect(prefix).toBe('deepseek:deepseek-v4-flash');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 100 * Number(chat.costs.prompt_tokens),
|
||||
completion_tokens: 50 * Number(chat.costs.completion_tokens),
|
||||
@@ -549,7 +581,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'do a tool call' }],
|
||||
tools: [
|
||||
{
|
||||
@@ -584,7 +616,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -617,7 +649,7 @@ describe('DeepSeekProvider.complete streaming', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'say hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
@@ -642,10 +674,10 @@ describe('DeepSeekProvider.complete streaming', () => {
|
||||
cached_tokens: 1,
|
||||
});
|
||||
|
||||
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-chat')!;
|
||||
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-flash')!;
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('deepseek:deepseek-chat');
|
||||
expect(prefix).toBe('deepseek:deepseek-v4-flash');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 4 * Number(chat.costs.prompt_tokens),
|
||||
completion_tokens: 2 * Number(chat.costs.completion_tokens),
|
||||
@@ -698,7 +730,7 @@ describe('DeepSeekProvider.complete streaming', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'do tool call' }],
|
||||
tools: [
|
||||
{
|
||||
@@ -737,7 +769,7 @@ describe('DeepSeekProvider.complete error mapping', () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'deepseek-chat',
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{ role: 'user', content: 'boom' }],
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -40,7 +40,7 @@ export class DeepSeekProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
getDefaultModel() {
|
||||
return 'deepseek-chat';
|
||||
return 'deepseek-v4-flash';
|
||||
}
|
||||
|
||||
models() {
|
||||
@@ -137,20 +137,18 @@ export class DeepSeekProvider implements IChatProvider {
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor,
|
||||
actor!,
|
||||
`deepseek:${modelUsed.id}`,
|
||||
costsOverrideFromModel,
|
||||
);
|
||||
return trackedUsage;
|
||||
},
|
||||
stream,
|
||||
stream: stream,
|
||||
completion,
|
||||
});
|
||||
}
|
||||
|
||||
checkModeration(
|
||||
_text: string,
|
||||
): ReturnType<IChatProvider['checkModeration']> {
|
||||
checkModeration(_text: string) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
@@ -22,47 +22,56 @@ import type { IChatModel } from '../../types.js';
|
||||
// Hardcoded from https://models.dev/api.json
|
||||
export const DEEPSEEK_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'deepseek:deepseek/deepseek-chat',
|
||||
id: 'deepseek-chat',
|
||||
puterId: 'deepseek:deepseek/deepseek-v4-flash',
|
||||
id: 'deepseek-v4-flash',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2024-07',
|
||||
release_date: '2024-12-26',
|
||||
knowledge: '2026-04',
|
||||
release_date: '2026-04-24',
|
||||
name: 'DeepSeek Chat',
|
||||
aliases: ['deepseek/deepseek-chat'],
|
||||
context: 128000,
|
||||
aliases: [
|
||||
'deepseek-v4-flash',
|
||||
'deepseek/deepseek-v4-flash',
|
||||
'deepseek-chat',
|
||||
'deepseek/deepseek-chat',
|
||||
'deepseek/deepseek-v4-flash',
|
||||
'deepseek/deepseek-reasoner',
|
||||
'deepseek:deepseek/deepseek-reasoner',
|
||||
'deepseek:deepseek/deepseek-chat',
|
||||
],
|
||||
context: 1_000_000,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 56,
|
||||
completion_tokens: 168,
|
||||
cached_tokens: 0,
|
||||
prompt_tokens: 14,
|
||||
completion_tokens: 28,
|
||||
cached_tokens: 0.28,
|
||||
},
|
||||
max_tokens: 8000,
|
||||
max_tokens: 384_000,
|
||||
},
|
||||
{
|
||||
puterId: 'deepseek:deepseek/deepseek-reasoner',
|
||||
id: 'deepseek-reasoner',
|
||||
puterId: 'deepseek:deepseek/deepseek-v4-pro',
|
||||
id: 'deepseek-v4-pro',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2024-07',
|
||||
release_date: '2025-01-20',
|
||||
name: 'DeepSeek Reasoner',
|
||||
aliases: ['deepseek/deepseek-reasoner'],
|
||||
context: 128000,
|
||||
knowledge: '2026-04',
|
||||
release_date: '2026-04-24',
|
||||
name: 'DeepSeek Chat',
|
||||
aliases: ['deepseek/deepseek-v4-pro', 'deepseek-v4-pro'],
|
||||
context: 1_000_000,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 56,
|
||||
completion_tokens: 168,
|
||||
cached_tokens: 0,
|
||||
prompt_tokens: 174,
|
||||
completion_tokens: 348,
|
||||
cached_tokens: 1.45,
|
||||
},
|
||||
max_tokens: 64000,
|
||||
max_tokens: 384_000,
|
||||
},
|
||||
];
|
||||
@@ -21,6 +21,31 @@ import type { IChatModel } from '../../types.js';
|
||||
|
||||
// Hardcoded from https://models.dev/api.json
|
||||
export const GEMINI_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'google:google/gemini-3.5-flash',
|
||||
id: 'gemini-3.5-flash',
|
||||
modalities: {
|
||||
input: ['text', 'image', 'audio', 'video', 'pdf'],
|
||||
output: ['text'],
|
||||
},
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2025-01',
|
||||
release_date: '2026-05-19',
|
||||
name: 'Gemini 3.5 Flash',
|
||||
aliases: ['google/gemini-3.5-flash'],
|
||||
context: 1_048_576,
|
||||
max_tokens: 65_536,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 150,
|
||||
completion_tokens: 900,
|
||||
cached_tokens: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'google:google/gemini-2.0-flash',
|
||||
id: 'gemini-2.0-flash',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
/**
|
||||
* Integration test for the Mistral provider.
|
||||
*
|
||||
* Uses `mistral-small-2506` (provider default, cheapest tier). Skipped
|
||||
* Uses `mistral-small-2603` (provider default, cheapest tier). Skipped
|
||||
* when `PUTER_TEST_AI_MISTRAL_API_KEY` is unset.
|
||||
*/
|
||||
|
||||
@@ -47,7 +47,7 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))(
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [
|
||||
{ role: 'user', content: 'Say hi in one word.' },
|
||||
],
|
||||
|
||||
@@ -149,9 +149,9 @@ describe('MistralAIProvider construction', () => {
|
||||
// ── Model catalog ───────────────────────────────────────────────────
|
||||
|
||||
describe('MistralAIProvider model catalog', () => {
|
||||
it('returns mistral-small-2506 as the default', () => {
|
||||
it('returns the configured small model as the default', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(provider.getDefaultModel()).toBe('mistral-small-2506');
|
||||
expect(provider.getDefaultModel()).toBe('mistral-small-2603');
|
||||
});
|
||||
|
||||
it('exposes the static MISTRAL_MODELS list verbatim from models()', async () => {
|
||||
@@ -171,7 +171,7 @@ describe('MistralAIProvider model catalog', () => {
|
||||
}
|
||||
// Sanity: a known alias is present alongside its canonical id.
|
||||
expect(ids).toContain('mistral-small-latest');
|
||||
expect(ids).toContain('mistral-small-2506');
|
||||
expect(ids).toContain('mistral-small-2603');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 256,
|
||||
temperature: 0.4,
|
||||
@@ -202,7 +202,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
);
|
||||
|
||||
const [args] = completeMock.mock.calls[0]!;
|
||||
expect(args.model).toBe('mistral-small-2506');
|
||||
expect(args.model).toBe('mistral-small-2603');
|
||||
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
|
||||
// Mistral's SDK uses maxTokens (camelCase). The provider should
|
||||
// adapt our snake_case input.
|
||||
@@ -216,7 +216,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -240,7 +240,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
];
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
tools,
|
||||
}),
|
||||
@@ -257,7 +257,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
@@ -308,7 +308,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
completeMock.mockResolvedValueOnce(baseCompletion);
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: false,
|
||||
}),
|
||||
@@ -320,7 +320,7 @@ describe('MistralAIProvider.complete request shape', () => {
|
||||
streamMock.mockReturnValueOnce(asAsyncIterable([]));
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
@@ -370,17 +370,17 @@ describe('MistralAIProvider model resolution', () => {
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
// `mistral-small-latest` is an alias of `mistral-small-2506`.
|
||||
// `mistral-small-latest` is an alias of `mistral-small-2603`.
|
||||
model: 'mistral-small-latest',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2506');
|
||||
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2603');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'mistral:mistral-small-2506',
|
||||
'mistral:mistral-small-2603',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -396,11 +396,11 @@ describe('MistralAIProvider model resolution', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2506');
|
||||
expect(completeMock.mock.calls[0]![0].model).toBe('mistral-small-2603');
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
'mistral:mistral-small-2506',
|
||||
'mistral:mistral-small-2603',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -424,7 +424,7 @@ describe('MistralAIProvider.complete non-stream output', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
@@ -450,12 +450,12 @@ describe('MistralAIProvider.complete non-stream output', () => {
|
||||
cached_tokens: 0,
|
||||
});
|
||||
expect(actor).toBe(SYSTEM_ACTOR);
|
||||
expect(prefix).toBe('mistral:mistral-small-2506');
|
||||
// mistral-small-2506 costs: prompt=10, completion=30. cached_tokens
|
||||
expect(prefix).toBe('mistral:mistral-small-2603');
|
||||
// mistral-small-2603 costs: prompt=15, completion=60. cached_tokens
|
||||
// is undefined in the model row → multiplied by 0 → NaN-safe 0.
|
||||
expect(overrides).toMatchObject({
|
||||
prompt_tokens: 100 * 10,
|
||||
completion_tokens: 50 * 30,
|
||||
prompt_tokens: 100 * 15,
|
||||
completion_tokens: 50 * 60,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -486,7 +486,7 @@ describe('MistralAIProvider.complete non-stream output', () => {
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'do a tool call' }],
|
||||
tools: [
|
||||
{
|
||||
@@ -535,7 +535,7 @@ describe('MistralAIProvider.complete streaming', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'say hi' }],
|
||||
stream: true,
|
||||
}),
|
||||
@@ -560,14 +560,14 @@ describe('MistralAIProvider.complete streaming', () => {
|
||||
cached_tokens: 0,
|
||||
});
|
||||
|
||||
// mistral-small-2506: prompt=10, completion=30.
|
||||
// mistral-small-2603: prompt=15, completion=60.
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [, , prefix, overrides] =
|
||||
recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('mistral:mistral-small-2506');
|
||||
expect(prefix).toBe('mistral:mistral-small-2603');
|
||||
expect(overrides).toMatchObject({
|
||||
prompt_tokens: 4 * 10,
|
||||
completion_tokens: 2 * 30,
|
||||
prompt_tokens: 4 * 15,
|
||||
completion_tokens: 2 * 60,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -626,7 +626,7 @@ describe('MistralAIProvider.complete streaming', () => {
|
||||
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'do tool call' }],
|
||||
tools: [
|
||||
{
|
||||
@@ -666,7 +666,7 @@ describe('MistralAIProvider.complete error mapping', () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'mistral-small-2506',
|
||||
model: 'mistral-small-2603',
|
||||
messages: [{ role: 'user', content: 'boom' }],
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -21,12 +21,12 @@ import { Mistral } from '@mistralai/mistralai';
|
||||
import { ChatCompletionResponse } from '@mistralai/mistralai/models/components/chatcompletionresponse.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import type {
|
||||
IChatCompleteResult,
|
||||
IChatProvider,
|
||||
ICompleteArguments,
|
||||
IChatCompleteResult,
|
||||
} from '../../types.js';
|
||||
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
import { MISTRAL_MODELS } from './models.js';
|
||||
|
||||
export class MistralAIProvider implements IChatProvider {
|
||||
@@ -42,7 +42,7 @@ export class MistralAIProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
getDefaultModel() {
|
||||
return 'mistral-small-2506';
|
||||
return 'mistral-small-2603';
|
||||
}
|
||||
|
||||
async models() {
|
||||
|
||||
@@ -19,8 +19,37 @@
|
||||
|
||||
import type { IChatModel } from '../../types.js';
|
||||
|
||||
// Hardcoded from https://models.dev/api.json
|
||||
// Hardcoded from https://models.dev/api.json and https://docs.mistral.ai/models/overview
|
||||
export const MISTRAL_MODELS: IChatModel[] = [
|
||||
{
|
||||
puterId: 'mistralai:mistralai/mistral-medium-3-5',
|
||||
id: 'mistral-medium-3-5',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2026-04',
|
||||
release_date: '2026-04-29',
|
||||
name: 'Mistral Medium 3.5',
|
||||
aliases: [
|
||||
'mistral-medium-3',
|
||||
'mistral-medium-2604',
|
||||
'mistralai/mistral-medium-3-5',
|
||||
'mistralai/mistral-medium-2604',
|
||||
],
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description:
|
||||
'State-of-the-art multimodal model optimized for agentic and coding tasks.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 150,
|
||||
completion_tokens: 750,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/mistral-medium-2508',
|
||||
id: 'mistral-medium-2508',
|
||||
@@ -29,106 +58,82 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
tool_call: true,
|
||||
knowledge: '2025-05',
|
||||
release_date: '2025-08-12',
|
||||
name: 'mistral-medium-2508',
|
||||
name: 'Mistral Medium 3.1',
|
||||
aliases: [
|
||||
'mistral-medium-latest',
|
||||
'mistral-medium',
|
||||
'mistralai/mistral-medium-2508',
|
||||
'mistral-medium-2508',
|
||||
'mistral-medium-latest',
|
||||
'mistralai/mistral-medium-latest',
|
||||
],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description: 'Update on Mistral Medium 3 with improved capabilities.',
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description: 'Frontier-class multimodal model.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 200,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/open-mistral-7b',
|
||||
id: 'open-mistral-7b',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2023-12',
|
||||
release_date: '2023-09-27',
|
||||
name: 'open-mistral-7b',
|
||||
aliases: [
|
||||
'mistral-tiny',
|
||||
'mistral-tiny-2312',
|
||||
'mistralai/open-mistral-7b',
|
||||
],
|
||||
context: 32768,
|
||||
max_tokens: 32768,
|
||||
description: 'Our first dense model released September 2023.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 25,
|
||||
completion_tokens: 25,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/open-mistral-nemo',
|
||||
id: 'open-mistral-nemo',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-07',
|
||||
release_date: '2024-07-01',
|
||||
name: 'open-mistral-nemo',
|
||||
aliases: [
|
||||
'open-mistral-nemo-2407',
|
||||
'mistral-tiny-2407',
|
||||
'mistral-tiny-latest',
|
||||
'mistralai/open-mistral-nemo',
|
||||
],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description:
|
||||
'Our best multilingual open source model released July 2024.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 15,
|
||||
completion_tokens: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/pixtral-large-2411',
|
||||
id: 'pixtral-large-2411',
|
||||
puterId: 'mistralai:mistralai/mistral-large-2512',
|
||||
id: 'mistral-large-2512',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-11',
|
||||
release_date: '2024-11-01',
|
||||
name: 'pixtral-large-2411',
|
||||
knowledge: '2025-12',
|
||||
release_date: '2025-12-02',
|
||||
name: 'Mistral Large 3',
|
||||
aliases: [
|
||||
'pixtral-large-latest',
|
||||
'mistral-large-pixtral-2411',
|
||||
'mistralai/pixtral-large-2411',
|
||||
'mistral-large-latest',
|
||||
'mistral-large',
|
||||
'mistralai/mistral-large-latest',
|
||||
'mistralai/mistral-large-2512',
|
||||
],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description: 'Official pixtral-large-2411 Mistral AI model',
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description:
|
||||
'State-of-the-art, open-weight, general-purpose multimodal model.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 600,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/mistral-small-2603',
|
||||
id: 'mistral-small-2603',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-06',
|
||||
release_date: '2026-03-16',
|
||||
name: 'Mistral Small 4',
|
||||
aliases: [
|
||||
'mistral-small-latest',
|
||||
'mistral-small',
|
||||
'mistralai/mistral-small-latest',
|
||||
'mistralai/mistral-small-2603',
|
||||
],
|
||||
context: 256_000,
|
||||
max_tokens: 256_000,
|
||||
description:
|
||||
'Hybrid open-weight model for instruct, reasoning, and coding tasks.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 15,
|
||||
completion_tokens: 60,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -137,116 +142,73 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-10',
|
||||
release_date: '2024-05-29',
|
||||
name: 'codestral-2508',
|
||||
knowledge: '2025-08',
|
||||
release_date: '2025-08-29',
|
||||
name: 'Codestral',
|
||||
aliases: ['codestral-latest', 'mistralai/codestral-2508'],
|
||||
context: 256000,
|
||||
max_tokens: 256000,
|
||||
context: 256_000,
|
||||
max_tokens: 256_000,
|
||||
description:
|
||||
'Our cutting-edge language model for coding released August 2025.',
|
||||
'Cutting-edge language model for code completion, proficient in over 80 programming languages.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30,
|
||||
completion_tokens: 90,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/devstral-small-2507',
|
||||
id: 'devstral-small-2507',
|
||||
puterId: 'mistralai:mistralai/devstral-2512',
|
||||
id: 'devstral-2512',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-05',
|
||||
release_date: '2025-07-10',
|
||||
name: 'devstral-small-2507',
|
||||
aliases: ['devstral-small-latest', 'mistralai/devstral-small-2507'],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description: 'Our small open-source code-agentic model.',
|
||||
knowledge: '2025-12',
|
||||
release_date: '2025-12-09',
|
||||
name: 'Devstral 2',
|
||||
aliases: [
|
||||
'devstral-latest',
|
||||
'mistralai/devstral-latest',
|
||||
'mistralai/devstral-2512',
|
||||
'devstral-medium-latest',
|
||||
'mistralai/devstral-medium-latest',
|
||||
],
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description:
|
||||
'Frontier open-source code agents model for software engineering.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 30,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/devstral-medium-2507',
|
||||
id: 'devstral-medium-2507',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-05',
|
||||
release_date: '2025-07-10',
|
||||
name: 'devstral-medium-2507',
|
||||
aliases: ['devstral-medium-latest', 'mistralai/devstral-medium-2507'],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description: 'Our medium code-agentic model.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
completion_tokens: 200,
|
||||
cached_tokens: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/mistral-small-2506',
|
||||
id: 'mistral-small-2506',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-03',
|
||||
release_date: '2025-06-20',
|
||||
name: 'mistral-small-2506',
|
||||
aliases: ['mistral-small-latest', 'mistralai/mistral-small-2506'],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description:
|
||||
'Our latest enterprise-grade small model with the latest version released June 2025.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/magistral-medium-2509',
|
||||
id: 'magistral-medium-2509',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2025-06',
|
||||
release_date: '2025-03-17',
|
||||
name: 'magistral-medium-2509',
|
||||
release_date: '2025-09-01',
|
||||
name: 'Magistral Medium 1.2',
|
||||
aliases: ['magistral-medium-latest', 'mistralai/magistral-medium-2509'],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description:
|
||||
'Our frontier-class reasoning model release candidate September 2025.',
|
||||
context: 131_072,
|
||||
max_tokens: 131_072,
|
||||
description: 'Frontier-class multimodal reasoning model.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
completion_tokens: 500,
|
||||
},
|
||||
@@ -258,143 +220,149 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-06',
|
||||
release_date: '2025-03-17',
|
||||
name: 'magistral-small-2509',
|
||||
release_date: '2025-09-01',
|
||||
name: 'Magistral Small 1.2',
|
||||
aliases: ['magistral-small-latest', 'mistralai/magistral-small-2509'],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description: 'Our efficient reasoning model released September 2025.',
|
||||
context: 131_072,
|
||||
max_tokens: 131_072,
|
||||
description: 'Efficient open-weight reasoning model.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/voxtral-mini-2507',
|
||||
id: 'voxtral-mini-2507',
|
||||
name: 'voxtral-mini-2507',
|
||||
aliases: ['voxtral-mini-latest', 'mistralai/voxtral-mini-2507'],
|
||||
context: 32768,
|
||||
max_tokens: 32768,
|
||||
description: 'A mini audio understanding model released in July 2025',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/voxtral-small-2507',
|
||||
id: 'voxtral-small-2507',
|
||||
name: 'voxtral-small-2507',
|
||||
aliases: ['voxtral-small-latest', 'mistralai/voxtral-small-2507'],
|
||||
context: 32768,
|
||||
max_tokens: 32768,
|
||||
description: 'A small audio understanding model released in July 2025',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/mistral-large-2512',
|
||||
id: 'mistral-large-latest',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-11',
|
||||
release_date: '2024-11-01',
|
||||
name: 'mistral-large-2512',
|
||||
aliases: ['mistral-large-2512', 'mistralai/mistral-large-2512'],
|
||||
context: 262144,
|
||||
max_tokens: 262144,
|
||||
description: 'Official mistral-large-2512 Mistral AI model',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 150,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/ministral-3b-2512',
|
||||
id: 'ministral-3b-2512',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-10',
|
||||
release_date: '2024-10-01',
|
||||
name: 'ministral-3b-2512',
|
||||
aliases: ['ministral-3b-latest', 'mistralai/ministral-3b-2512'],
|
||||
context: 131072,
|
||||
max_tokens: 131072,
|
||||
description: 'Ministral 3 (a.k.a. Tinystral) 3B Instruct.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/ministral-8b-2512',
|
||||
id: 'ministral-8b-2512',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-10',
|
||||
release_date: '2024-10-01',
|
||||
name: 'ministral-8b-2512',
|
||||
aliases: ['ministral-8b-latest', 'mistralai/ministral-8b-2512'],
|
||||
context: 262144,
|
||||
max_tokens: 262144,
|
||||
description: 'Ministral 3 (a.k.a. Tinystral) 8B Instruct.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
prompt_tokens: 15,
|
||||
completion_tokens: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/ministral-14b-2512',
|
||||
id: 'ministral-14b-2512',
|
||||
name: 'ministral-14b-2512',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-12',
|
||||
release_date: '2025-12-16',
|
||||
name: 'Ministral 3 14B',
|
||||
aliases: ['ministral-14b-latest', 'mistralai/ministral-14b-2512'],
|
||||
context: 262144,
|
||||
max_tokens: 262144,
|
||||
description: 'Ministral 3 (a.k.a. Tinystral) 14B Instruct.',
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description:
|
||||
'Best-in-class compact model with text and vision capabilities.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1000000,
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 20,
|
||||
completion_tokens: 20,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/ministral-8b-2512',
|
||||
id: 'ministral-8b-2512',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-12',
|
||||
release_date: '2025-12-16',
|
||||
name: 'Ministral 3 8B',
|
||||
aliases: ['ministral-8b-latest', 'mistralai/ministral-8b-2512'],
|
||||
context: 262_144,
|
||||
max_tokens: 262_144,
|
||||
description:
|
||||
'Efficient compact model with text and vision capabilities.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/ministral-3b-2512',
|
||||
id: 'ministral-3b-2512',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-12',
|
||||
release_date: '2025-12-16',
|
||||
name: 'Ministral 3 3B',
|
||||
aliases: ['ministral-3b-latest', 'mistralai/ministral-3b-2512'],
|
||||
context: 131_072,
|
||||
max_tokens: 131_072,
|
||||
description: 'Tiny, efficient model with multimodal support.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/open-mistral-nemo-2407',
|
||||
id: 'open-mistral-nemo-2407',
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2024-07',
|
||||
release_date: '2024-07-25',
|
||||
name: 'Mistral Nemo 12B',
|
||||
aliases: [
|
||||
'open-mistral-nemo',
|
||||
'mistral-nemo',
|
||||
'mistral-nemo-latest',
|
||||
'mistralai/open-mistral-nemo',
|
||||
'mistralai/open-mistral-nemo-2407',
|
||||
'mistralai/mistral-nemo',
|
||||
],
|
||||
context: 128_000,
|
||||
max_tokens: 128_000,
|
||||
description: 'Best multilingual open source model.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 15,
|
||||
completion_tokens: 15,
|
||||
},
|
||||
},
|
||||
{
|
||||
puterId: 'mistralai:mistralai/voxtral-small-2507',
|
||||
id: 'voxtral-small-2507',
|
||||
modalities: { input: ['text', 'audio'], output: ['text'] },
|
||||
open_weights: true,
|
||||
tool_call: true,
|
||||
knowledge: '2025-07',
|
||||
release_date: '2025-07-15',
|
||||
name: 'Voxtral Small',
|
||||
aliases: [
|
||||
'voxtral-small-latest',
|
||||
'mistralai/voxtral-small-2507',
|
||||
'mistralai/voxtral-small-latest',
|
||||
'voxtral-small-latest',
|
||||
],
|
||||
context: 32_768,
|
||||
max_tokens: 32_768,
|
||||
description: 'Audio-input model for instruct tasks.',
|
||||
provider: 'mistral',
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 30,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -20,7 +20,7 @@
|
||||
import type { IChatModel } from '../../types.js';
|
||||
|
||||
export const MOONSHOT_MODELS: IChatModel[] = [
|
||||
// ── Flagship ────────────────────────────────────────────────────
|
||||
// -- Flagship ----------------------------------------------------
|
||||
{
|
||||
puterId: 'moonshotai:moonshotai/kimi-k2.6',
|
||||
id: 'kimi-k2.6',
|
||||
@@ -47,7 +47,7 @@ export const MOONSHOT_MODELS: IChatModel[] = [
|
||||
knowledge: '2025-01',
|
||||
},
|
||||
|
||||
// ── Kimi K2.5 ──────────────────────────────────────────────────
|
||||
// -- Kimi K2.5 --------------------------------------------------
|
||||
{
|
||||
puterId: 'moonshotai:moonshotai/kimi-k2.5',
|
||||
id: 'kimi-k2.5',
|
||||
@@ -69,7 +69,7 @@ export const MOONSHOT_MODELS: IChatModel[] = [
|
||||
knowledge: '2025-01',
|
||||
},
|
||||
|
||||
// ── Moonshot V1 (Legacy) ───────────────────────────────────────
|
||||
// -- Moonshot V1 (Legacy) ---------------------------------------
|
||||
{
|
||||
puterId: 'moonshotai:moonshotai/moonshot-v1-8k',
|
||||
id: 'moonshot-v1-8k',
|
||||
|
||||
@@ -87,6 +87,69 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
context: 1_050_000,
|
||||
max_tokens: 1_050_000,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.4-pro',
|
||||
id: 'gpt-5.4-pro',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2025-08-31',
|
||||
release_date: '2026-03-05',
|
||||
aliases: ['gpt-5.4-pro', 'openai/gpt-5.4-pro'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 3000,
|
||||
completion_tokens: 18000,
|
||||
},
|
||||
context: 1_050_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.4-mini',
|
||||
id: 'gpt-5.4-mini',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2025-08-31',
|
||||
aliases: ['gpt-5.4-mini', 'openai/gpt-5.4-mini'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 75,
|
||||
cached_tokens: 7.5,
|
||||
completion_tokens: 450,
|
||||
},
|
||||
context: 400_000,
|
||||
max_tokens: 128_000,
|
||||
responses_api_only: true,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.4-nano',
|
||||
id: 'gpt-5.4-nano',
|
||||
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
knowledge: '2025-08-31',
|
||||
release_date: '2026-03-19',
|
||||
aliases: ['gpt-5.4-nano', 'openai/gpt-5.4-nano'],
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 20,
|
||||
cached_tokens: 2,
|
||||
completion_tokens: 125,
|
||||
},
|
||||
context: 400_000,
|
||||
max_tokens: 128_000,
|
||||
},
|
||||
{
|
||||
puterId: 'openai:openai/gpt-5.3-codex',
|
||||
id: 'gpt-5.3-codex',
|
||||
|
||||
@@ -92,6 +92,7 @@ const SAMPLE_API_MODELS = [
|
||||
{
|
||||
id: 'openai/gpt-5-nano',
|
||||
name: 'GPT-5 Nano',
|
||||
created: 1714564800,
|
||||
context_length: 128000,
|
||||
pricing: { prompt: 0.00001, completion: 0.00003 },
|
||||
top_provider: { max_completion_tokens: 16000 },
|
||||
@@ -99,6 +100,7 @@ const SAMPLE_API_MODELS = [
|
||||
{
|
||||
id: 'anthropic/claude-haiku-4.5',
|
||||
name: 'Claude Haiku 4.5',
|
||||
created: 1760529600,
|
||||
context_length: 200000,
|
||||
pricing: { prompt: 0.000002, completion: 0.00001 },
|
||||
top_provider: { max_completion_tokens: 8192 },
|
||||
@@ -107,6 +109,7 @@ const SAMPLE_API_MODELS = [
|
||||
// 'openrouter/auto' is filtered out — disallowed.
|
||||
id: 'openrouter/auto',
|
||||
name: 'Auto',
|
||||
created: 1704067200,
|
||||
context_length: 32768,
|
||||
pricing: { prompt: 0, completion: 0 },
|
||||
top_provider: { max_completion_tokens: 4096 },
|
||||
@@ -224,6 +227,23 @@ describe('OpenRouterProvider model catalog', () => {
|
||||
// Second call should be a cache hit, not a second axios request.
|
||||
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('maps OpenRouter created timestamps to release_date metadata', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const models = await provider.models();
|
||||
expect(models).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'openrouter:openai/gpt-5-nano',
|
||||
release_date: '2024-05-01',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'openrouter:anthropic/claude-haiku-4.5',
|
||||
release_date: '2025-10-15',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Disallowed model gate ───────────────────────────────────────────
|
||||
|
||||
@@ -36,6 +36,18 @@ type OpenrouterUsage = OpenAI.Completions.CompletionUsage & {
|
||||
cost?: number;
|
||||
};
|
||||
|
||||
const openRouterReleaseDate = (created: unknown): string | undefined => {
|
||||
const seconds = Number(created);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const date = new Date(seconds * 1000);
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
export class OpenRouterProvider implements IChatProvider {
|
||||
#meteringService: MeteringService;
|
||||
|
||||
@@ -230,7 +242,7 @@ export class OpenRouterProvider implements IChatProvider {
|
||||
});
|
||||
|
||||
models = resp.data.data;
|
||||
kv.set('openrouterChat:models', models);
|
||||
kv.set('openrouterChat:models', models, { EX: 15 * 60 }); // cache for 15 minutes
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
@@ -275,6 +287,7 @@ export class OpenRouterProvider implements IChatProvider {
|
||||
tokens: 1_000_000,
|
||||
...microcentCosts,
|
||||
},
|
||||
release_date: openRouterReleaseDate(model.created),
|
||||
...overridenModel,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -433,10 +433,10 @@ describe('TogetherAIProvider.complete non-stream output', () => {
|
||||
completion_tokens: 50,
|
||||
cached_tokens: 0,
|
||||
});
|
||||
// Qwen pricing: input=20, output=20 (per million).
|
||||
// Qwen pricing: input=20, output=20 (per million, dollars from API → ×100 for cents).
|
||||
expect(overrides).toMatchObject({
|
||||
prompt_tokens: 100 * 20,
|
||||
completion_tokens: 50 * 20,
|
||||
prompt_tokens: 100 * 20 * 100,
|
||||
completion_tokens: 50 * 20 * 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -484,13 +484,13 @@ describe('TogetherAIProvider.complete streaming', () => {
|
||||
cached_tokens: 0,
|
||||
});
|
||||
|
||||
// Qwen pricing: input=20, output=20.
|
||||
// Qwen pricing: input=20, output=20 (dollars from API → ×100 for cents).
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('togetherai:Qwen/Qwen2.5-7B-Instruct-Turbo');
|
||||
expect(overrides).toMatchObject({
|
||||
prompt_tokens: 4 * 20,
|
||||
completion_tokens: 2 * 20,
|
||||
prompt_tokens: 4 * 20 * 100,
|
||||
completion_tokens: 2 * 20 * 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,7 +75,11 @@ export class TogetherAIProvider implements IChatProvider {
|
||||
output_cost_key: 'output',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
...model.pricing,
|
||||
...Object.fromEntries(
|
||||
Object.entries(model.pricing ?? {}).map(
|
||||
([k, v]) => [k, (v as number) * 100],
|
||||
),
|
||||
),
|
||||
},
|
||||
max_tokens: model.context_length ?? 8000,
|
||||
});
|
||||
@@ -96,7 +100,7 @@ export class TogetherAIProvider implements IChatProvider {
|
||||
},
|
||||
max_tokens: 1000,
|
||||
});
|
||||
kv.set(this.#kvKey, models, { EX: 5 * 60 });
|
||||
kv.set(this.#kvKey, models, { EX: 15 * 60 });
|
||||
return models;
|
||||
}
|
||||
|
||||
@@ -126,9 +130,13 @@ export class TogetherAIProvider implements IChatProvider {
|
||||
|
||||
const actor = Context.get('actor');
|
||||
const models = await this.models();
|
||||
const modelLower = model.toLowerCase();
|
||||
const modelUsed =
|
||||
models.find((m) => [m.id, ...(m.aliases || [])].includes(model)) ||
|
||||
models.find((m) => m.id === this.getDefaultModel())!;
|
||||
models.find((m) =>
|
||||
[m.id, ...(m.aliases || [])].some(
|
||||
(id) => id.toLowerCase() === modelLower,
|
||||
),
|
||||
) || models.find((m) => m.id === this.getDefaultModel())!;
|
||||
const modelIdForParams = modelUsed.id.startsWith('togetherai:')
|
||||
? modelUsed.id.slice('togetherai:'.length)
|
||||
: modelUsed.id;
|
||||
|
||||
@@ -125,4 +125,5 @@ export interface IChatProvider {
|
||||
list(): string[] | Promise<string[]>;
|
||||
getDefaultModel(): string;
|
||||
complete(arg: ICompleteArguments): Promise<IChatCompleteResult>;
|
||||
checkModeration(text: string): { flagged: boolean; categories: string[] };
|
||||
}
|
||||
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user