Compare commits

..
59 Commits
Author SHA1 Message Date
Daniel Salazar dda6b82f0f fix: missing english i18n (#3176) 2026-05-27 16:31:40 -07:00
ProgrammerIn-wonderland 6c757c3fc8 Don't use passed in authorization in workers (#3175) 2026-05-27 18:09:59 -04:00
Daniel Salazar eba194936e tests: more tests for session and auth (#3174) 2026-05-27 12:32:15 -07:00
Daniel Salazar a7bdac16da tests: Add unit tests for XAISpeechToTextDriver (#3173)
closes #3002
2026-05-27 11:49:16 -07:00
Daniel Salazar 716a8d06e2 fix: session store kind column + duplicate error handling (#3172) 2026-05-27 11:44:08 -07:00
Daniel Salazar e95cf44fec fix: small fixes for perf and username checks (#3169) 2026-05-27 03:10:56 -07:00
Daniel Salazar 8f8efcb349 feat (PUT-1025 PUT-1026 and PUT-1017): better handling old token auth required in gui (#3166)
* feat (PUT-1025 PUT-1026 and PUT-1017): better handling old token forcing in gui

* fix: misc issues with token inval project
2026-05-27 02:26:35 -07:00
Daniel Salazar b188942436 feat (PUT-1016 & PUT-1020) (#3164)
* feat (PUT-1016 & PUT-1020)
temp account preservation on forced relogin
hosted asset cookies to v2 token too

* fix: remove llm dashes and ugly comments

* update agents
2026-05-26 23:35:16 -07:00
Daniel Salazar 9b7e9a98c8 fix: session revocation (#3163) 2026-05-26 22:13:12 -07:00
Daniel Salazar d6f7fb6155 chore: package-lock update (#3161) 2026-05-26 20:33:11 -07:00
Daniel Salazar bd91f5e192 feat: worker sessions get their own kind + per-(user, app, worker_name) row (#3160)
* feat: worker sessions get their own kind + per-(user, app, worker_name) row

Schema
------
- mysql_mig_11.sql + sqlite 0054: add idx_sessions_user_worker_active,
  a partial unique index over (user_id, app_uid, meta.worker_name) for
  kind='worker' rows. Active worker sessions are deduped by that triple
  so each named worker gets its own session row and they don't fight
  the existing idx_sessions_user_app_active (which still constrains
  kind='app' only). app_uid is allowed NULL for user-scoped workers
  with no app binding.

SessionStore
------------
- getOrCreateWorker(userId, { appUid, workerName, ... }): mirrors the
  getOrCreateApp pattern — cache lookup, partial-unique re-SELECT on
  insert-ignore, all keyed on the worker triple. expires_at lands at
  WORKER_WINDOW_SECONDS (~99y) so the worker doesn't have to re-mint
  on any cadence.
- #cacheKeyWorker + #allCacheKeysForRow worker branch so revoke /
  update invalidates the worker cache view alongside the by-uuid one.

AuthService
-----------
- createWorkerSessionToken(user, workerName, meta?) now takes the
  workerName explicitly and routes through getOrCreateWorker. Emits
  the same { session, token, gui_token } shape but both JWTs carry
  { worker: true, worker_name }.
- createWorkerAppToken(actor, appUid, workerName) likewise — JWT
  carries the worker_name claim so a verifier can tell two workers
  under the same app apart without a DB round-trip.
- Both methods 400 on empty workerName.

WorkerDriver
------------
- Five auth-mint call sites swapped over: app-bound deploy (3x:
  appId branch, actor.app fallback, hot-reload redeploy), user-bound
  fallback (2x: cold deploy, hot-reload). All pass `workerName` so
  the worker's session row is naturally idempotent across redeploys.

GUI manage-sessions
-------------------
- sessionTitle adds a kind='worker' branch ("name (app)" for
  app-scoped workers, just "name" for user-scoped), pulling worker_name
  from the meta-spread that listSessions already surfaces.
- en.js adds ui_session_kind_worker.

* fix(workers): MySQL JSON_EXTRACT quoting + revoke cache invalidation +
SQLite NULL-distinct in worker index

Three real bugs in the worker session plumbing from the prior commit,
plus a misleading comment. Schema design kept (worker_name lives in
`meta` rather than a dedicated column) per offline review:

1. `#selectWorkerRow` compared `JSON_EXTRACT(meta, '$.worker_name')`
   directly to a bind parameter. MySQL's `JSON_EXTRACT` returns a
   JSON-typed value with embedded quotes (`"name"`, not `name`), so
   the comparison never matched. After the first INSERT, every
   follow-up getOrCreateWorker call missed the existing row in the
   SELECT, hit INSERT-IGNORE, then missed again in the re-SELECT —
   the caller would receive whatever the INSERT-IGNORE returned (a
   no-op row in conflict cases). Wrap with `JSON_UNQUOTE` on MySQL
   via `db.case`; SQLite's `json_extract` already returns the
   unwrapped scalar so it keeps the literal form.

2. mig_11's generated `worker_unique_key` had the same JSON-quoting
   bug. Mirror the fix: `IFNULL(JSON_UNQUOTE(JSON_EXTRACT(...)), '')`
   so the concatenated unique key is a plain string that lines up
   with what `#selectWorkerRow` now binds against.

3. SQLite UNIQUE indexes treat NULL columns as distinct (per the SQL
   standard), so two user-scoped workers (app_uid NULL) with the same
   worker_name would both insert. Wrap the index expression with
   `IFNULL(app_uid, '')` so they correctly conflict — matches the
   MySQL side's `IFNULL` in the generated column.

4. `removeByUuid` / `revokeCascade` SELECTed only the identity
   columns (no `meta`), so `#allCacheKeysForRow`'s worker branch
   couldn't read `meta.worker_name` and the composite
   `sessions:v2:worker:<user>:<app>:<name>` cache key survived
   revocation. Up to CACHE_TTL_SECONDS (15min) afterwards,
   getOrCreateWorker would short-circuit to the cached (revoked) row.
   Add `meta` to both SELECTs; the existing meta-parsing logic in
   `#allCacheKeysForRow` handles the rest.
2026-05-26 20:05:31 -07:00
Daniel SalazarandClaude Opus 4.7 971a1b5071 chore: drop linear/issue ticket references from source comments (#3159)
54 references across 18 files (PUT-1010, PUT-1014, PUT-1019, PUT-1021,
PUT-1022, PUT-1023, PUT-1024 + sub-tags AUTH-2/4/5, SDK-1, PJS-1/2,
GUI-1/2, ROLLOUT-1) removed from inline comments, doc comments,
test describe blocks, and SQL migration headers. The substantive
explanations stay; only the ticket pointers go.

No behavior change. Full backend test suite: 2172 passed / 16 skipped.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 18:50:43 -07:00
Daniel SalazarandClaude Opus 4.7 ac5eecb7f3 feat (put-1019 put-1021): v2 auth revoke endpoints + silent v1->v2 to… (#3158)
* feat (put-1019 put-1021): v2 auth revoke endpoints + silent v1->v2 token migration

PUT-1019 (AUTH-5): full revoke-endpoint coverage
- /logout: soft-revoke web session + its asset cookies via revokeCascade
  (app sessions and access tokens survive)
- POST /auth/revoke-session: cascade per row kind (web/app/access_token/asset)
- POST /auth/revoke-all-sessions: revoke all web rows for user; optional
  include_apps=true nuclear option; gated by userProtected (cookie-only)
- revokeAccessToken: soft-revoke matching access_token row in addition to
  removing access_token_permissions
- All revokes are UPDATE revoked_at = now(); no DELETE statements remain

PUT-1021 (SDK-1): backend POST /auth/migrate-token
- v1 access_token/app -> mint matching-kind v2 token, idempotent on
  (auth_id, kind, token_uid)
- v1 web/session -> 409 { code: "reauth_required" } (interactive relogin only)
- Same-origin / signed-referer hardening; rate-limited per IP and auth_id
- Gated by auth.allow_v1_tokens; emits puter_token_v2 cookie for app-in-browser

DB migrations: mysql_mig_10, sqlite 0053 (sessions.access_token_uid column)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(put-1019): reject self-revoke; enrich list-sessions response

- handleRevokeSession refuses uuid === req.actor.session.uid (use /logout
  instead). The cookie that authenticated the call should never be the
  target of a self-revoke — the response can't write fresh auth state
  and the client ends up with an ambiguous identity. revoke-all-sessions
  still has the explicit include_current opt-in for the nuclear case.

- AuthService.listSessions now joins the apps table for kind='app' rows
  (returning { uid, name, title, icon } so the manage-sessions UI can
  render the authorizing app without a second round trip), surfaces
  kind / expires_at / label / last_ip / created_via, and filters out
  asset rows (per-cookie children of web rows, revoked transitively via
  cascade — surfacing them as standalone entries would be confusing).

- Sort order: current session first, then most-recently-active. UI
  relies on this to anchor "you are here" at the top.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(put-1019 put-1021): review nits — origin normalization, cookie fallback, types, migration order

B1: createAccessToken.options.expiresIn widened to string | number.
    The impl (#hardExpiryFromExpiresIn) and existing callers/tests use
    jsonwebtoken-style strings ('1h', '30d'); narrowing to number forced
    unsafe casts at every call site. Cast at the single sign() boundary
    where jsonwebtoken's typed template-literal SignOptions clashes
    with the wider runtime contract.

B2: Inline comment on the DELETE in access_token_permissions. The
    AUTH-5 "no DELETE on revoke" rule scoped to the `sessions` table
    (where the cascade graph + audit trail matter). Permissions rows
    are the grant manifest for an active token — once its session is
    soft-revoked they're dead-weight cache entries. A future audit
    requirement would land as a `revoked_at` column on this table,
    not a behavior change in this PR.

B3: handleMigrateToken now sets the puter_token_v2 cookie (with the
    shared sessionCookieFlags + httpOnly) when the migration result
    is kind='app'. The endpoint is already gated on Origin so the
    caller is by definition in a browser; access tokens deliberately
    skip the cookie since they're programmatic.

B4: #isMigrateTokenOriginAllowed normalizes both incoming origin and
    config.origin / allowlist entries (trim + strip trailing slash +
    lowercase) before equality. A misconfigured `config.origin =
    "https://puter.com/"` would otherwise reject every same-origin
    browser call.

B5: Replaced 4x `this.config.cookie_name!` non-null assertions in
    AuthController with `(this.config.cookie_name ?? 'puter_token')`.
    IConfig is `Partial<IConfigOptional>` so cookie_name is undefined
    at runtime in some deployments / test setups; the fallback matches
    the pattern in userProtected / OIDCController / puterSite.

B6: MySQLDatabaseClient sorts migrations numerically by trailing
    integer instead of lexically. Existing files use unpadded names
    (`mysql_mig_<N>.sql`), so plain `.sort()` ran mysql_mig_10 before
    mysql_mig_2 — a future migration that depended on _2..9 running
    first would break. Non-numeric filenames fall through to
    localeCompare for determinism.

B7: Restored the docstring for SessionStore.getOrCreateApp's
    `opts.auth_id` ("Stable per-user identity (survives re-login);
    carried on every v2 JWT so manage-sessions can group by identity")
    — the previous edit truncated it to a fragment.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* test + feat: backend test coverage for PUT-1019/1021 review fixes + worker session methods

Tests
-----
- compareMigrationFilenames (new) covering B6 numeric-sort: confirms
  mysql_mig_10.sql lands after mysql_mig_9.sql; non-numeric files sort
  after numbered ones; stable for already-ordered input.
- listSessions (AuthService.test.ts): excludes kind="asset" rows;
  enriches with kind/expires_at/last_ip/created_via; joins kind="app"
  rows with the apps table; sorts current first then by last_activity
  desc.
- handleRevokeSession (AuthController.test.ts): refuses self-revoke
  with 400; still allows revoking a sibling session.
- handleMigrateToken (AuthController.test.ts): rejects missing/
  disallowed Origin; tolerates trailing slash and uppercase Origin
  (B4 normalization); returns 409 reauth_required for v1 web tokens;
  does NOT set the cookie for access-token migration; DOES set the
  puter_token_v2 cookie (httpOnly + sessionCookieFlags) for
  app-under-user migration.
- SessionStore tests updated to import APP_WINDOW_SECONDS /
  WEB_WINDOW_SECONDS rather than hardcoded 30/90 day values — the
  windows just got bumped to 1y and the assertions need to follow
  the constant.

Refactor
--------
- MySQLDatabaseClient exports compareMigrationFilenames so the sort
  logic is unit-testable in isolation.

Worker tokens
-------------
- AuthService.createWorkerSessionToken(user, meta?): mints a new
  kind="web" row tagged meta.worker=true, expires_at =
  WORKER_WINDOW_SECONDS, returns { session, token, gui_token }
  with worker: true on each JWT.
- AuthService.createWorkerAppToken(actor, appUid): mints a new
  kind="app" row tagged meta.worker=true, expires_at =
  WORKER_WINDOW_SECONDS, returns an app-under-user JWT with
  worker: true. Note the existing idx_sessions_user_app_active
  unique index will collide with an existing non-worker app
  session for the same (user, app) — future schema work can
  carve workers out of that uniqueness.

SessionStore.js: WEB/APP_WINDOW_SECONDS now 1y;
WORKER_WINDOW_SECONDS = 99y added for the worker path.

Full backend suite: 2172 passed / 16 skipped / 0 failed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 18:49:36 -07:00
Daniel SalazarandClaude Opus 4.7 3ae076b73e feat (put-1022 put-1023 put-1024): puter.js reauth handling + GUI v2 modal + silent v1 migration (#3157)
* feat (put-1022 put-1023 put-1024): puter.js reauth handling + GUI v2 modal + silent v1 migration

PUT-1022 (PJS-1): puter.js handles 401 reauth_required
- Single apiCall wrapper intercepts 401 { code: "reauth_required" }
- Clears local token, emits puter.auth.reauth_required { reason, auth_id }
- Queues in-flight requests, re-issues after re-auth completes
- web/app: opens puter.com login popup forwarding auth_id
- gui: no-op (handled by GUI-1 modal)
- workers: surfaces as structured exception
- All token writes go through setAuthToken() (future-proofed for PJS-2)

PUT-1023 (GUI-1): web GUI reauth modal + v2 token storage
- Detects 401 reauth_required across http + websocket connect paths
- Soft modal preserves URL/window state; auth_id forwarded into login form
- Storage key migrated: auth_token -> auth_token_v2
- Cookie cleared on logout: puter_token (legacy) cleared, backend writes puter_token_v2
- Cross-tab propagation via localStorage storage events
- WebSocket revocation surfaces as same modal (no silent failure)

PUT-1024 (PJS-2): puter.js storage versioning + silent v1->v2 migration
- New storage key puter.auth.token.v2
- On SDK init: prefer v2 key; else legacy v1 -> silent POST /auth/migrate-token (SDK-1)
- On success: store v2, clear v1. On failure: fall back to PJS-1 reauth flow
- URL-param tokens (?puter.auth.token=, ?auth_token=) also run through silent migration
- Logout clears both keys; setAuthToken writes v2 only

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(put-1023): manage-sessions polish — hide self-revoke, fix modal stack, richer rows

Three issues surfaced during local testing of the manage-sessions UI:

1. The session row representing the caller's own cookie had a Revoke
   button that, if used, left the client in an ambiguous identity state
   (backend now rejects it too — see put-1019 backend fix). The button
   is now omitted entirely when session.current=true; /logout remains
   the right path for ending the active session.

2. The confirm-revoke prompt rendered behind the manage-sessions
   window because both share the dominant z-index pool. UIAlert calls
   now pass parent_uuid (the manage-sessions window's data-element_uuid)
   plus stay_on_top, so the prompt stacks above its parent.

3. Each row only showed the bare uuid. Now renders: title (app
   title for app sessions, label / "Browser session" / "Access token"
   otherwise), app icon when applicable, kind / current badges,
   created / last-active / expires (timeago, with absolute on hover),
   and last_ip. App metadata is joined server-side per the backend
   listSessions change.

Adds en.js strings for the new labels (ui_session_*).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

* fix(put-1022): tighten reauth replay + iframe postMessage hygiene

driverCall_ replay (G1): on 401 reauth_required, re-enter driverCall_
instead of calling replayXhrAfterReauth. The generic helper wires the
retried XHR through setupXhrEventHandlers which resolves with the
parsed response and silently skips driverCall_'s streaming detection,
usage-limit / email-confirmation handling, settings.transform, and
resp.result unwrapping — i.e. it would change the driver call API
contract on retry. One-shot via settings._reauthReplayed so a
fresh-token rejection bubbles up instead of looping.

responseType drift (G2): driverCall_ sets xhr.responseType from
settings AFTER initXhr (which captured the stale value into
xhr._puterReq). Mirror the mutation onto _puterReq so any replay path
builds the retry with the live config rather than the snapshot.

targetOrigin lockdown (G3): triggerReauth's parent.postMessage now
targets this.defaultGUIOrigin instead of '*'. The payload carries
reauth metadata + auth_id and is only meaningful to the GUI; '*'
would leak the signal to whatever frame happened to be embedding us.

event.source pinning (G4): the reauth wait listener also matches
event.source against globalThis.parent, not just event.origin —
origin alone admits any same-origin frame on the GUI domain.

event name alignment (G5): the SDK now emits and listens for
'puter.auth.reauth_required' (matches the documented name in the
PR / commit description). The old 'auth.reauth_required' key isn't
yet consumed externally so this is a safe rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 18:49:17 -07:00
Daniel Salazar 5394ccc45c feat: add verification for v2 auth (#3155) 2026-05-26 15:34:54 -07:00
Daniel Salazar 3649109f40 fix: add back AGENTS.md (#3154) 2026-05-25 16:01:35 -07:00
Daniel Salazar 0ff0ea7743 fix: fs event emissions (#3153) 2026-05-25 14:25:27 -07:00
Daniel Salazar 8be115096f feat (put-1012 put-1014): new tokens version signature and logic (#3152) 2026-05-25 14:19:23 -07:00
Daniel Salazar e66fd2373f feat: start adding support for token expiry and invalidation (#3151)
* feat: new expirable + revokable session

* fix: msql migration
2026-05-25 12:30:20 -07:00
jelveh 0e2cce152d Update PuterAlert.js 2026-05-24 17:03:34 -07:00
Daniel Salazar 101e0bc930 fix: search leaking metadata (#3150) 2026-05-24 16:21:33 -07:00
jelveh ea078250bf Strip non-essential media from docs' markdown 2026-05-24 16:15:24 -07:00
jelveh db79f8e484 Add note: serve Puter.js via HTTP server 2026-05-24 15:48:21 -07:00
Daniel Salazar 6422f2d513 fix: associated app ids as input (#3149) 2026-05-23 13:01:03 -07:00
ProgrammerIn-wonderland c60626f772 Add preamble versioning (#3147)
* Add preamble versioning

* update migrations index
2026-05-23 01:15:58 -04:00
ProgrammerIn-wonderland cafdaf7b50 fix bug with signed url directory listings (#3146) 2026-05-22 18:48:41 -04:00
Daniel Salazar bb0f56d758 fix: bring back puter site config logic (#3145) 2026-05-22 11:15:45 -07:00
NighttandReynaldi Chernando 7438db7a90 Add OpenRouter model release dates (#3144)
* Add OpenRouter model release dates

* simplify implementation

---------

Co-authored-by: Reynaldi Chernando <[email protected]>
2026-05-22 12:58:59 -04:00
Daniel Salazar 948f771838 feat: ai limits tightning and add severity alert (#3142) 2026-05-21 20:46:19 -07:00
Nariman Jelveh 82e34f014d Warn on file:// loads using PuterDialog 2026-05-21 19:15:21 -07:00
Nariman Jelveh 9e4c3f8ba3 Merge branch 'main' of https://github.com/HeyPuter/puter 2026-05-21 14:39:44 -07:00
Nariman JelvehandClaude Opus 4.7 e6acd7386a Bump @heyputer/puter.js to 2.4.2
Record the 2.4.2 release in the repo. The version was published to npm
(via prepublishOnly's `npm version patch`) but the bump was never
committed, leaving package.json stuck at 2.4.1 on GitHub.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-21 14:39:18 -07:00
Daniel Salazar b04e8b046a fix: fixing misc issues around alerting and bugs (#3141)
- decrease ai related alerts and make errors not 500
- decreased bad fs controller checks to be 404s instead of 500s
- force bucket regions for now
- validate webdav perms for locking/unlocking
2026-05-21 14:26:47 -07:00
Nariman Jelveh 3262b984b7 Unify Email/Usage dialogs with Puter dialog style 2026-05-21 14:26:26 -07:00
Nariman Jelveh 71af88c73c Bump @heyputer/puter.js to 2.4.1 2026-05-21 14:06:48 -07:00
Nariman Jelveh 2275f65169 Centralize auth popup handling and consent dialog 2026-05-21 12:06:47 -07:00
ProgrammerIn-wonderland 71b7d6367f fix together provider issues (#3140) 2026-05-21 08:58:48 -04:00
Nariman Jelveh 08369d840b Add cssstyle to docs dependencies (#3139) 2026-05-20 17:41:02 -07:00
Daniel Salazar 94236133b5 fix: event types (#3138)
* fix: agents.md

* fix weird names

* fix: weird names again

* fix: event types

* cleanup types
2026-05-20 16:26:16 -07:00
Daniel Salazar 84b48541bd fix: event types + emit subdomain serve event (#3137)
* fix: agents.md

* fix weird names

* fix: weird names again
2026-05-20 12:47:56 -07:00
Daniel Salazar a33d02f522 fix: agents.md name (#3136) 2026-05-20 12:18:35 -07:00
Reynaldi Chernando f94d4aa7d9 Update gemini 3.5 flash release date (#3135) 2026-05-20 13:25:46 -04:00
Daniel Salazar 89cfb0c634 fix: subdomain htmls (#3134) 2026-05-19 22:04:57 -07:00
ProgrammerIn-wonderland c9096e9eed more validation for signed URLs (#3133) 2026-05-19 19:04:42 -04:00
Daniel Salazar 859ca207c5 feat: new gemini 3.5 flash model (#3131) 2026-05-19 15:16:07 -07:00
Daniel Salazar 58fc122f0d fix: app creation dupped names limit (#3130)
* fix: app creation dupped names limit

* fix: app dupped names
2026-05-19 12:18:59 -07:00
ProgrammerIn-wonderland 0e990da961 import ESM by URL instead of path (#3129) 2026-05-18 19:31:03 -04:00
db8236cc97 update Indonesian section (Indonesian language) (#3117)
* Update id.js

* Update id.js

* Update id.js

* Update id.js

* Update README.id.md

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

* refine indonesia i18n

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Reynaldi Chernando <[email protected]>
2026-05-18 17:15:01 +07:00
jelveh d731ccf873 Bump puter.js version to 2.3.4 2026-05-17 23:20:41 -07:00
Daniel Salazar 6493d66901 fix: misc write issues with fs + openrouter refresh (#3125)
* feat: refresh openrouter models every 15 min

* fix: if no extensions, skip build step

* fix: misc write issues with fs
2026-05-17 12:34:08 -07:00
Daniel Salazar 9e7f38f473 fix: validate private apps token too (#3124) 2026-05-17 12:28:32 -07:00
Daniel Salazar a568ca0f99 fix: expose upstream ai errors as not 500s (#3123) 2026-05-17 12:01:22 -07:00
Nariman Jelveh 41e7228a9f Update PuterDialog.js 2026-05-16 17:01:40 -07:00
Nariman Jelveh 4568639718 Adjust PuterDialog footer and button spacing 2026-05-16 16:57:35 -07:00
Nariman Jelveh 73ecff0bd7 Tweak PuterDialog spacing and button widths 2026-05-16 16:50:31 -07:00
Nariman Jelveh f2f023880f Remove mobile column-reverse for dialog buttons 2026-05-16 16:42:27 -07:00
Nariman Jelveh f2093e9204 PuterDialog: UI/style tweaks and button reorder 2026-05-16 16:36:53 -07:00
Nariman Jelveh 23497dd371 Add dark mode for puter.js login prompt 2026-05-16 16:04:29 -07:00
Nariman Jelveh 37619fb2a3 Remove embedded background image from PuterDialog 2026-05-16 15:43:15 -07:00
179 changed files with 13926 additions and 2046 deletions

No files matched your search

+4
View File
@@ -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.
+2
View File
@@ -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,
+4
View File
@@ -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,
+1 -1
View File
@@ -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!
+4
View File
@@ -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.
+4 -4
View File
@@ -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(
+5
View File
@@ -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'
+6
View File
@@ -92,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
@@ -123,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": {
+222 -68
View File
@@ -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": {
@@ -6270,29 +6269,6 @@
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.18",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
@@ -7459,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",
@@ -7482,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",
@@ -7593,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",
@@ -8957,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": {
@@ -10179,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",
@@ -10193,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"
@@ -10248,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"
@@ -15423,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"
@@ -15504,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"
@@ -15964,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",
@@ -16627,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": {
@@ -16654,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"
@@ -17237,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"
@@ -18199,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"
@@ -18418,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",
@@ -18498,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",
@@ -18519,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",
@@ -18940,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",
@@ -19056,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",
@@ -19101,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",
@@ -19222,7 +19376,7 @@
},
"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",
+19 -8
View File
@@ -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}`,
@@ -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 {
@@ -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;
+7 -7
View File
@@ -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]';
+15 -5
View File
@@ -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);
+20 -5
View File
@@ -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 });
});
});
+429 -69
View File
@@ -26,7 +26,11 @@ 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,
@@ -91,7 +95,7 @@ const RESERVED_USERNAMES = new Set([
*/
@Controller('')
export class AuthController extends PuterController {
// ── Login ───────────────────────────────────────────────────────
// -- Login -------------------------------------------------------
@Post('/login', {
captcha: true,
@@ -158,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,
@@ -187,7 +202,7 @@ export class AuthController extends PuterController {
await this.#completeLogin(req, res, user);
}
// ── Login: OTP verification ─────────────────────────────────────
// -- Login: OTP verification -------------------------------------
@Post('/login/otp', {
captcha: true,
@@ -210,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',
@@ -238,10 +257,12 @@ 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', {
captcha: true,
@@ -264,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',
@@ -288,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);
@@ -305,10 +330,12 @@ 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', {
captcha: true,
@@ -328,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();
@@ -519,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,
@@ -560,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'];
@@ -605,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 {
@@ -618,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 &&
@@ -691,7 +761,7 @@ export class AuthController extends PuterController {
await this.#completeLogin(req, res, user!);
}
// ── Logout ──────────────────────────────────────────────────────
// -- Logout ------------------------------------------------------
@Post('/logout', {
requireAuth: true,
@@ -699,8 +769,12 @@ export class AuthController extends PuterController {
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) {
@@ -711,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);
@@ -722,7 +798,7 @@ export class AuthController extends PuterController {
res.send('logged out');
}
// ── Email confirmation ──────────────────────────────────────────
// -- Email confirmation ------------------------------------------
@Post('/send-confirm-email', {
subdomain: ['api', ''],
@@ -852,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', ''],
@@ -944,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,
@@ -956,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',
@@ -968,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;
@@ -1001,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,
@@ -1046,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
@@ -1344,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', ''],
@@ -1534,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> {
@@ -1545,7 +1631,7 @@ export class AuthController extends PuterController {
res.json({ token, image });
}
// ── Anti-CSRF token generation ──────────────────────────────────
// -- Anti-CSRF token generation ----------------------------------
@Get('/get-anticsrf-token', {
requireAuth: true,
@@ -1561,7 +1647,7 @@ export class AuthController extends PuterController {
res.json({ token });
}
// ── Permission grants ───────────────────────────────────────────
// -- Permission grants -------------------------------------------
@Post('/auth/grant-user-user', {
subdomain: 'api',
@@ -1633,7 +1719,7 @@ export class AuthController extends PuterController {
res.json({});
}
// ── Permission revokes ──────────────────────────────────────────
// -- Permission revokes ------------------------------------------
@Post('/auth/revoke-user-user', {
subdomain: 'api',
@@ -1705,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> {
@@ -1733,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> {
@@ -1741,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') {
@@ -1754,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',
@@ -1765,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> {
@@ -1820,7 +1956,7 @@ export class AuthController extends PuterController {
res.json({});
}
// ── Permission listing ──────────────────────────────────────────
// -- Permission listing ------------------------------------------
@Get('/auth/list-permissions', {
subdomain: 'api',
@@ -1882,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> {
@@ -1895,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',
@@ -1933,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.
@@ -1988,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,
);
@@ -1996,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',
@@ -2029,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') {
@@ -2049,7 +2256,7 @@ export class AuthController extends PuterController {
res.json({ ok: true });
}
// ── 2FA: configure ──────────────────────────────────────────────
// -- 2FA: configure ----------------------------------------------
@Post('/auth/configure-2fa/:action', {
subdomain: 'api',
@@ -2152,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!, {
@@ -2182,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> {
@@ -2212,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> {
@@ -2318,7 +2525,7 @@ export class AuthController extends PuterController {
});
}
// ── Session helpers ─────────────────────────────────────────────
// -- Session helpers ---------------------------------------------
@Get('/get-gui-token', {
requireUserActor: true,
@@ -2359,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
@@ -2375,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]`.
@@ -2424,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,
@@ -2522,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 {
@@ -2609,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,
@@ -2633,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) ─────────────────────────────
+37 -81
View File
@@ -20,12 +20,13 @@
import Busboy from 'busboy';
import type { Request, Response } from 'express';
import { posix as pathPosix } from 'node:path';
import { assertNormalized } from '../../services/fs/resolveNode.js';
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,
@@ -58,6 +59,7 @@ import type {
ThumbnailUploadPrepareItem,
ThumbnailUploadPreparePayload,
} from './types.js';
import { toLegacyEntry } from './legacyFsHelpers.js';
class UploadProgressTracker implements UploadProgressTrackerLike {
total = 0;
progress = 0;
@@ -421,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,
@@ -810,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) {
@@ -910,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 =
@@ -929,6 +936,7 @@ export class FSController extends PuterController {
userId,
query,
limit ?? 200,
this.#appDataScopeForActor(actor),
);
res.json(results);
}
@@ -1007,7 +1015,7 @@ export class FSController extends PuterController {
}
}
// ── Mutation routes ────────────────────────────────────────────────
// -- Mutation routes ------------------------------------------------
@Post('/mkdir', { subdomain: 'api', requireVerified: true })
async mkdirEntry(req: Request, res: Response) {
@@ -1205,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;
@@ -1649,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,
@@ -2034,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(
@@ -2093,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(
@@ -2144,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 =
@@ -2237,7 +2192,7 @@ export class FSController extends PuterController {
return null;
}
return { index, contentType, size };
return { index, contentType, size } as ThumbnailUploadPrepareItem;
}
async #attachSignedThumbnailUploadTargets(
@@ -2257,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');
});
});
+276 -108
View File
@@ -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';
@@ -57,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> {
@@ -74,7 +84,7 @@ async function loadAdditionalRouter(
}
}
// ── Controller ──────────────────────────────────────────────────────
// -- Controller ------------------------------------------------------
export class LegacyFSController extends PuterController {
#additionalCache: RouterCache = new Map();
@@ -195,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
@@ -251,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)) {
@@ -328,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
@@ -756,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') ?? '';
@@ -766,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)),
);
@@ -888,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
@@ -937,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) {
@@ -1001,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({});
@@ -1054,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
@@ -1087,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
@@ -1110,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, {
@@ -1139,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,
@@ -1203,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;
}
@@ -1300,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);
@@ -1470,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
@@ -1600,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`,
@@ -1909,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 {
@@ -1936,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:
@@ -1997,7 +2165,7 @@ export class LegacyFSController extends PuterController {
return { error: true, status: 500, message: 'Unknown batch error' };
}
// ── Helpers ─────────────────────────────────────────────────────────
// -- Helpers ---------------------------------------------------------
#parsePositiveIntegerQuery(
query: Record<string, unknown>,
@@ -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,
@@ -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
+1
View File
@@ -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',
@@ -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:
@@ -110,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.
@@ -226,7 +226,7 @@ export class PuterAIController extends PuterController {
};
}
// ── /openai/v1/chat/completions ─────────────────────────────────
// -- /openai/v1/chat/completions ---------------------------------
openaiChatCompletions = async (
req: Request,
@@ -394,7 +394,7 @@ export class PuterAIController extends PuterController {
});
};
// ── /openai/v1/completions ──────────────────────────────────────
// -- /openai/v1/completions --------------------------------------
openaiCompletions = async (req: Request, res: Response): Promise<void> => {
const body = asRecord(req.body);
@@ -522,7 +522,7 @@ export class PuterAIController extends PuterController {
});
};
// ── /openai/v1/responses ────────────────────────────────────────
// -- /openai/v1/responses ----------------------------------------
openaiResponses = async (req: Request, res: Response): Promise<void> => {
const body = asRecord(req.body);
@@ -832,7 +832,7 @@ export class PuterAIController extends PuterController {
);
};
// ── /anthropic/v1/messages ──────────────────────────────────────
// -- /anthropic/v1/messages --------------------------------------
anthropicMessages = async (req: Request, res: Response): Promise<void> => {
const body = asRecord(req.body);
@@ -1037,7 +1037,7 @@ export class PuterAIController extends PuterController {
});
};
// ── Internals ───────────────────────────────────────────────────
// -- Internals ---------------------------------------------------
#driver(): ChatCompletionDriver {
const driver = this.drivers.aiChat;
@@ -1049,7 +1049,7 @@ export class PuterAIController extends PuterController {
}
}
// ── Shared helpers ──────────────────────────────────────────────────
// -- Shared helpers --------------------------------------------------
const DEFAULTS = {
openaiChat: 'openai-completion',
@@ -1135,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 '';
@@ -1254,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 ?? {};
@@ -1363,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 }>,
@@ -1484,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('&#10005;', 'Something went wrong', msg, '#e53e3e');
const ok = (msg: string) => page('&#10003;', '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(
@@ -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
+1 -1
View File
@@ -198,7 +198,7 @@ export async function hasWritePermission(
return true;
}
// ── Internals ───────────────────────────────────────────────────────
// -- Internals -------------------------------------------------------
async function getPathLockMap(
redis: Cluster,
+10
View File
@@ -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;
};
+4 -4
View File
@@ -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
+3 -3
View File
@@ -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);
+3 -3
View File
@@ -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;
+12 -27
View File
@@ -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. */
+1 -1
View File
@@ -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;
},
};
@@ -522,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
+74 -20
View File
@@ -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
@@ -49,7 +70,7 @@ interface AuthProbeOptions {
* 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();
@@ -130,10 +172,22 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
// 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.
if (cookieName && !isCrossOriginBrowserRequest(req)) {
const cookieToken = req.cookies?.[cookieName];
if (typeof cookieToken === 'string' && cookieToken.length > 0) {
return stripBearer(cookieToken);
//
// `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);
}
}
+2 -2
View File
@@ -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 ─────────────────────────────────────────────
+23 -9
View File
@@ -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',
@@ -151,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',
@@ -34,7 +34,7 @@ import {
normalizeHost,
normalizeHostRaw,
renderLoginBootstrapHtml,
resolvePrivateAppForHostedSite,
resolveOwnedAppForHostedSite,
resolvePrivateIdentity,
resolvePublicHostedIdentity,
subdomainFromHost,
@@ -451,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.
@@ -524,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,
@@ -532,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();
});
@@ -632,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',
@@ -657,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',
@@ -695,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', () => {
@@ -712,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',
@@ -730,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(
@@ -612,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.
@@ -629,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);
@@ -645,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,
@@ -657,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.
@@ -781,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');
});
});
+134 -41
View File
@@ -21,9 +21,9 @@ 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,
@@ -32,10 +32,16 @@ import {
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`,
@@ -52,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;
@@ -192,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
@@ -319,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,
@@ -386,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,
@@ -436,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(
@@ -449,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')
@@ -461,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;
@@ -479,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));
}
@@ -489,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');
}
+34 -11
View File
@@ -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
+1 -1
View File
@@ -274,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
@@ -60,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.
*
@@ -87,7 +217,7 @@ export class ChatCompletionDriver extends PuterDriver {
this.#buildModelMap();
}
// ── Interface methods ───────────────────────────────────────────
// -- Interface methods -------------------------------------------
async models() {
const seen = new Set<string>();
@@ -212,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.
@@ -290,8 +420,7 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
const attempts: { model: string; provider: string; error: string }[] =
[];
const attempts: ProviderAttempt[] = [];
let res: IChatCompleteResult | undefined;
try {
@@ -301,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(
@@ -348,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;
@@ -436,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.
@@ -532,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;
}
@@ -643,7 +772,7 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
// ── Provider registration ───────────────────────────────────────
// -- Provider registration ---------------------------------------
#registerProviders() {
const providers = this.config.providers ?? {};
@@ -799,7 +928,7 @@ export class ChatCompletionDriver extends PuterDriver {
this.#providers['fake-chat'] = new FakeChatProvider();
}
// ── Model map ───────────────────────────────────────────────────
// -- Model map ---------------------------------------------------
async #buildModelMap() {
const AGGREGATORS = new Set(['together-ai', 'openrouter']);
@@ -21,7 +21,7 @@ import type { IChatModel } from '../../types.js';
// Hardcoded from https://models.dev/api.json
export const ALIBABA_MODELS: IChatModel[] = [
// ── Commercial flagship ────────────────────────────────────────
// -- Commercial flagship ----------------------------------------
{
puterId: 'alibaba:qwen/qwen-max',
id: 'qwen-max',
@@ -89,7 +89,7 @@ export const ALIBABA_MODELS: IChatModel[] = [
},
},
// ── Plus tier ──────────────────────────────────────────────────
// -- Plus tier --------------------------------------------------
{
puterId: 'alibaba:qwen/qwen-plus',
id: 'qwen-plus',
@@ -157,7 +157,7 @@ export const ALIBABA_MODELS: IChatModel[] = [
},
},
// ── Turbo / Flash tier ─────────────────────────────────────────
// -- Turbo / Flash tier -----------------------------------------
{
puterId: 'alibaba:qwen/qwen-turbo',
id: 'qwen-turbo',
@@ -203,7 +203,7 @@ export const ALIBABA_MODELS: IChatModel[] = [
},
},
// ── Coding models ──────────────────────────────────────────────
// -- Coding models ----------------------------------------------
{
puterId: 'alibaba:qwen/qwen3-coder-plus',
id: 'qwen3-coder-plus',
@@ -293,7 +293,7 @@ export const ALIBABA_MODELS: IChatModel[] = [
},
},
// ── Reasoning models ───────────────────────────────────────────
// -- Reasoning models -------------------------------------------
{
puterId: 'alibaba:qwen/qwq-plus',
id: 'qwq-plus',
@@ -361,7 +361,7 @@ export const ALIBABA_MODELS: IChatModel[] = [
},
},
// ── Open-weight Qwen3 ──────────────────────────────────────────
// -- Open-weight Qwen3 ------------------------------------------
{
puterId: 'alibaba:qwen/qwen3-235b-a22b',
id: 'qwen3-235b-a22b',
@@ -451,7 +451,7 @@ export const ALIBABA_MODELS: IChatModel[] = [
},
},
// ── Open-weight Qwen3.5 ────────────────────────────────────────
// -- Open-weight Qwen3.5 ----------------------------------------
{
puterId: 'alibaba:qwen/qwen3.5-397b-a17b',
id: 'qwen3.5-397b-a17b',
@@ -549,7 +549,7 @@ export const ALIBABA_MODELS: IChatModel[] = [
},
},
// ── Open-weight Qwen3.6 ────────────────────────────────────────
// -- Open-weight Qwen3.6 ----------------------------------------
{
puterId: 'alibaba:qwen/qwen3.6-35b-a3b',
id: 'qwen3.6-35b-a3b',
@@ -599,7 +599,7 @@ export const ALIBABA_MODELS: IChatModel[] = [
},
},
// ── Vision models ──────────────────────────────────────────────
// -- Vision models ----------------------------------------------
{
puterId: 'alibaba:qwen/qwen-vl-max',
id: 'qwen-vl-max',
@@ -755,7 +755,7 @@ export const ALIBABA_MODELS: IChatModel[] = [
},
},
// ── Legacy Qwen2.5 ────────────────────────────────────────────
// -- Legacy Qwen2.5 --------------------------------------------
{
puterId: 'alibaba:qwen/qwen2-5-72b-instruct',
id: 'qwen2-5-72b-instruct',
@@ -889,7 +889,7 @@ export const ALIBABA_MODELS: IChatModel[] = [
},
},
// ── Omni models (text interface, audio costs excluded) ─────────
// -- Omni models (text interface, audio costs excluded) ---------
{
puterId: 'alibaba:qwen/qwen-omni-turbo',
id: 'qwen-omni-turbo',
@@ -966,7 +966,7 @@ export const ALIBABA_MODELS: IChatModel[] = [
},
},
// ── Translation models ─────────────────────────────────────────
// -- Translation models -----------------------------------------
{
puterId: 'alibaba:qwen/qwen-mt-plus',
id: 'qwen-mt-plus',
@@ -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 @@
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',
@@ -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;
+1
View File
@@ -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[] };
}
@@ -351,9 +351,14 @@ describe('TogetherImageProvider.generate output handling', () => {
expect(result).toBe('data:image/png;base64,AAAA');
});
it('wraps SDK errors with an "image generation error:" prefix', async () => {
it('lets SDK errors bubble untouched so the driver boundary can classify them', async () => {
const provider = makeProvider();
const apiError = new Error('upstream blew up');
// Together's SDK errors carry a `.status` field — re-wrapping
// them in a plain Error stripped that out and caused the
// catch-all `translateProviderError` to fall through to 500.
const apiError = Object.assign(new Error('upstream blew up'), {
status: 400,
});
generateMock.mockRejectedValueOnce(apiError);
await expect(
@@ -363,7 +368,7 @@ describe('TogetherImageProvider.generate output handling', () => {
prompt: 'hi',
}),
),
).rejects.toThrow(/Together AI image generation error:.*upstream blew up/);
).rejects.toMatchObject({ status: 400, message: 'upstream blew up' });
// Failure path must NOT meter usage.
expect(incrementUsageSpy).not.toHaveBeenCalled();
@@ -172,43 +172,51 @@ export class TogetherImageProvider implements IImageProvider {
model: selectedModel.id.replace('togetherai:', ''),
}) as unknown as Together.Images.ImageGenerateParams;
try {
const response = await this.#client.images.generate(request);
if (!response?.data?.length) {
throw new Error(
'Together AI response did not include image data',
);
}
this.#meteringService.incrementUsage(
actor,
usageType,
usageAmount,
costInMicroCents,
);
const first = response.data[0] as {
url?: string;
b64_json?: string;
};
const url =
first.url ||
(first.b64_json
? `data:image/png;base64,${first.b64_json}`
: undefined);
if (!url) {
throw new Error(
'Together AI response did not include an image URL',
);
}
return url;
} catch (error) {
throw new Error(
`Together AI image generation error: ${(error as Error).message}`,
// Let SDK errors bubble — together-ai SDK errors carry `.status`
// which the driver-boundary `translateProviderError` maps to
// `upstream_*` HttpErrors. Re-wrapping in `new Error(...)` would
// strip the status field and cause these to surface as 500s.
const response = await this.#client.images.generate(request);
if (!response?.data?.length) {
throw new HttpError(
400,
'Together AI response did not include image data',
{
legacyCode: 'upstream_bad_request',
fields: { provider: 'together' },
},
);
}
this.#meteringService.incrementUsage(
actor,
usageType,
usageAmount,
costInMicroCents,
);
const first = response.data[0] as {
url?: string;
b64_json?: string;
};
const url =
first.url ||
(first.b64_json
? `data:image/png;base64,${first.b64_json}`
: undefined);
if (!url) {
throw new HttpError(
400,
'Together AI response did not include an image URL',
{
legacyCode: 'upstream_bad_request',
fields: { provider: 'together' },
},
);
}
return url;
}
#getModel(model?: string) {
+2 -2
View File
@@ -209,7 +209,7 @@ export class OCRDriver extends PuterDriver {
return null;
}
// ── AWS Textract ─────────────────────────────────────────────────
// -- AWS Textract -------------------------------------------------
#textractClientFor(region: string): TextractClient {
const cached = this.#textractClients[region];
@@ -317,7 +317,7 @@ export class OCRDriver extends PuterDriver {
return { blocks };
}
// ── Mistral OCR ──────────────────────────────────────────────────
// -- Mistral OCR --------------------------------------------------
async #mistralRecognize(
loaded: LoadedFile,
@@ -317,7 +317,7 @@ describe('VoiceChangerDriver.convert success path', () => {
// ── Error mapping ───────────────────────────────────────────────────
describe('VoiceChangerDriver.convert error mapping', () => {
it('rethrows the upstream status when ElevenLabs returns an error body', async () => {
it('maps upstream 4xx to HttpError upstream_bad_request (keeps the upstream status)', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
new Response(
@@ -336,9 +336,32 @@ describe('VoiceChangerDriver.convert error mapping', () => {
voice_id: 'missing-voice',
}),
),
).rejects.toMatchObject({ statusCode: 404 });
).rejects.toMatchObject({
statusCode: 404,
legacyCode: 'upstream_bad_request',
});
// No metering should be recorded on a failed call.
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('maps upstream 5xx to HttpError 400 upstream_provider_unavailable (skips alert)', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
new Response('boom', { status: 503 }),
);
await expect(
withActor(actor, () =>
driver.convert({
audio: dataUrl(Buffer.from('x'), 'audio/mpeg'),
voice_id: 'any-voice',
}),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_provider_unavailable',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
@@ -140,6 +140,21 @@ export class VoiceChangerDriver extends PuterDriver {
throw new HttpError(400, '`voice` is required', {
legacyCode: 'bad_request',
});
// `voiceId` lands in the request URL path; `modelId` lands in a
// multipart field. Both are forwarded to ElevenLabs with our
// long-lived API key, so anything other than a strict alphanumeric
// shape lets a caller steer the request at a different endpoint or
// inject parameters. ElevenLabs voice/model IDs are always
// `[A-Za-z0-9_-]+` in practice.
const ID_REGEX = /^[A-Za-z0-9_-]+$/;
if (!ID_REGEX.test(voiceId))
throw new HttpError(400, '`voice` must be alphanumeric', {
legacyCode: 'bad_request',
});
if (!ID_REGEX.test(modelId))
throw new HttpError(400, '`model` must be alphanumeric', {
legacyCode: 'bad_request',
});
// Metering: estimate duration from file size if we don't parse metadata.
// 16 kbit/s is a safe lower bound for speech audio; pre-check credits
@@ -229,8 +244,32 @@ export class VoiceChangerDriver extends PuterDriver {
detail && typeof detail === 'object' && 'detail' in detail
? String((detail as { detail: unknown }).detail)
: `ElevenLabs returned ${response.status}`;
throw new HttpError(response.status, message, {
legacyCode: 'internal_error',
// Tag upstream status as `upstream_*` so the alarm gate
// skips paging on ElevenLabs 5xx outages (we expose them
// as 400 like the TTS provider does — user can't act on
// them, but it's not our bug either).
const legacyCode =
response.status >= 500
? 'upstream_provider_unavailable'
: response.status === 401 || response.status === 403
? 'upstream_auth_failed'
: response.status === 429
? 'upstream_rate_limited'
: 'upstream_bad_request';
const exposedStatus =
legacyCode === 'upstream_rate_limited'
? 429
: legacyCode === 'upstream_auth_failed'
? 500
: legacyCode === 'upstream_provider_unavailable'
? 400
: response.status;
throw new HttpError(exposedStatus, message, {
legacyCode,
fields: {
provider: 'elevenlabs',
upstreamStatus: response.status,
},
});
}
@@ -0,0 +1,616 @@
/*
* 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 XAISpeechToTextDriver.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) configured with an xAI API key, then drives
* `server.drivers.aiSpeech2TxtXai` directly. xAI has no SDK — the
* driver calls the REST `/v1/stt` endpoint via `fetch` — so global
* `fetch` is spied for each request shape assertion. Audio inputs use
* `data:` URLs through the live `loadFileInput`, and the FS-resolution
* branch is exercised by writing a real file via `server.services.fs.write`.
*/
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { PuterServer } from '../../server.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import type { XAISpeechToTextDriver } from './XAISpeechToTextDriver.js';
// Mirror the driver's per-second cost so test expectations stay in lockstep
// with the real value rather than restating an arbitrary literal.
const UCENTS_PER_SECOND = 2778;
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
let driver: XAISpeechToTextDriver;
let fetchSpy: MockInstance<typeof fetch>;
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
beforeAll(async () => {
server = await setupTestServer({
providers: {
xai: { apiKey: 'xai-test-key' },
},
} as never);
driver = server.drivers.aiSpeech2TxtXai as unknown as XAISpeechToTextDriver;
});
afterAll(async () => {
await server?.shutdown();
});
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
});
afterEach(() => {
vi.restoreAllMocks();
});
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
const username = `xai-stt-${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,
actor: {
user: {
id: refreshed.id,
uuid: refreshed.uuid,
username: refreshed.username,
email: refreshed.email ?? null,
email_confirmed: true,
} as Actor['user'],
},
};
};
const withActor = <T>(actor: Actor, fn: () => T | Promise<T>): Promise<T> =>
Promise.resolve(runWithContext({ actor }, fn));
const dataUrl = (buffer: Buffer, mime: string) =>
`data:${mime};base64,${buffer.toString('base64')}`;
const sttResponse = (body: Record<string, unknown>, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
// ── getReportedCosts ────────────────────────────────────────────────
describe('XAISpeechToTextDriver.getReportedCosts', () => {
it('reports a single per-second line item at the documented xAI rate', () => {
const reported = driver.getReportedCosts();
expect(reported).toEqual([
{
usageType: 'xai:stt:second',
ucentsPerUnit: UCENTS_PER_SECOND,
unit: 'second',
source: 'driver:aiSpeech2Txt/xai',
},
]);
});
});
// ── list_models ─────────────────────────────────────────────────────
describe('XAISpeechToTextDriver.list_models', () => {
it('returns the single xai-stt entry with diarization support', async () => {
const models = await driver.list_models();
expect(models).toHaveLength(1);
expect(models[0]).toEqual({
id: 'xai-stt',
name: 'xAI Speech to Text',
type: 'transcription',
response_formats: ['json'],
supports_prompt: false,
supports_logprobs: false,
supports_diarization: true,
});
});
});
// ── test_mode bypass ────────────────────────────────────────────────
describe('XAISpeechToTextDriver test_mode', () => {
it('returns the canned sample for transcribe, bypassing all I/O', async () => {
const result = (await driver.transcribe({
file: undefined,
test_mode: true,
})) as { text: string; model: string; words: unknown[] };
expect(result.text).toMatch(/sample transcription/i);
expect(result.model).toBe('xai-stt');
expect(Array.isArray(result.words)).toBe(true);
// No file required, no actor required, no fetch / metering hit.
expect(fetchSpy).not.toHaveBeenCalled();
expect(hasCreditsSpy).not.toHaveBeenCalled();
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('returns the canned sample for translate (same alias, no fetch)', async () => {
const result = (await driver.translate({
file: undefined,
test_mode: true,
})) as { model: string };
expect(result.model).toBe('xai-stt');
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// ── Argument validation ─────────────────────────────────────────────
describe('XAISpeechToTextDriver argument validation', () => {
it('throws 400 when file is missing', async () => {
const { actor } = await makeUser();
await expect(
withActor(actor, () => driver.transcribe({ file: undefined })),
).rejects.toMatchObject({ statusCode: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('throws 401 when no actor is on the request context', async () => {
await expect(
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
}),
).rejects.toMatchObject({ statusCode: 401 });
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// ── Missing API key ─────────────────────────────────────────────────
describe('XAISpeechToTextDriver missing API key', () => {
it('throws 500 internal_error when no xAI key is configured', async () => {
// Boot a separate server WITHOUT an xAI provider entry so the
// driver leaves its key unset. test_mode and validation paths
// are unaffected — only the network branch should reject.
const bareServer = await setupTestServer();
try {
const bareDriver =
bareServer.drivers.aiSpeech2TxtXai as unknown as XAISpeechToTextDriver;
await expect(
bareDriver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
}),
).rejects.toMatchObject({
statusCode: 500,
legacyCode: 'internal_error',
});
} finally {
await bareServer.shutdown();
}
});
});
// ── Credit gate ─────────────────────────────────────────────────────
describe('XAISpeechToTextDriver credit gate', () => {
it('throws 402 BEFORE hitting xAI when actor lacks credits', async () => {
hasCreditsSpy.mockResolvedValueOnce(false);
const { actor } = await makeUser();
await expect(
withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('audio-bytes'), 'audio/mp3'),
}),
),
).rejects.toMatchObject({ statusCode: 402 });
expect(fetchSpy).not.toHaveBeenCalled();
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('asks hasEnoughCredits for estimated seconds × per-second ucents', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
sttResponse({ text: 'ok', duration: 1 }),
);
// 32000 bytes / 16000 bytes-per-second ⇒ 2s estimated.
const audio = Buffer.alloc(32000, 0);
await withActor(actor, () =>
driver.transcribe({ file: dataUrl(audio, 'audio/mp3') }),
);
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(UCENTS_PER_SECOND * 2);
});
it('estimates 60 seconds for URL inputs (we cannot inspect size locally)', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
sttResponse({ text: 'ok', duration: 1 }),
);
await withActor(actor, () =>
driver.transcribe({ file: 'https://example.com/clip.mp3' }),
);
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(UCENTS_PER_SECOND * 60);
});
});
// ── Audio input handling ────────────────────────────────────────────
describe('XAISpeechToTextDriver audio input handling', () => {
it('decodes a base64 data URL and POSTs it as the multipart `file` field', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
sttResponse({ text: 'hello world', duration: 1 }),
);
const audioBytes = Buffer.from('fake-mp3-bytes');
await withActor(actor, () =>
driver.transcribe({ file: dataUrl(audioBytes, 'audio/mp3') }),
);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe('https://api.x.ai/v1/stt');
const initObj = init as RequestInit;
expect(initObj.method).toBe('POST');
expect((initObj.headers as Record<string, string>).Authorization).toBe(
'Bearer xai-test-key',
);
// multipart body — `file` is present, `url` is not.
const form = initObj.body as FormData;
expect(form).toBeInstanceOf(FormData);
expect(form.get('url')).toBeNull();
const filePart = form.get('file');
expect(filePart).toBeInstanceOf(Blob);
const blob = filePart as Blob;
expect(blob.type).toBe('audio/mp3');
const sent = Buffer.from(await blob.arrayBuffer());
expect(sent.equals(audioBytes)).toBe(true);
});
it('forwards an HTTP(S) URL as the `url` field and skips local FS read', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
sttResponse({ text: 'ok', duration: 1 }),
);
await withActor(actor, () =>
driver.transcribe({ file: 'https://example.com/audio.mp3' }),
);
const form = (fetchSpy.mock.calls[0]![1] as RequestInit)
.body as FormData;
expect(form.get('url')).toBe('https://example.com/audio.mp3');
expect(form.get('file')).toBeNull();
});
it('resolves an FS path through the live FSService and preserves filename/mime', async () => {
const { actor, userId } = await makeUser();
const audioBytes = Buffer.from('fs-backed-audio-data');
await server.services.fs.write(userId, {
fileMetadata: {
path: `/${actor.user.username}/clip.mp3`,
size: audioBytes.byteLength,
contentType: 'audio/mpeg',
},
fileContent: audioBytes,
});
fetchSpy.mockResolvedValueOnce(
sttResponse({ text: 'ok', duration: 1 }),
);
await withActor(actor, () =>
driver.transcribe({
file: { path: `/${actor.user.username}/clip.mp3` },
}),
);
const form = (fetchSpy.mock.calls[0]![1] as RequestInit)
.body as FormData;
const filePart = form.get('file') as File;
expect(filePart).toBeInstanceOf(Blob);
expect(filePart.type).toBe('audio/mpeg');
// FormData.append(name, blob, filename) — Blob becomes a File with .name.
expect(filePart.name).toBe('clip.mp3');
});
});
// ── Request shape ───────────────────────────────────────────────────
describe('XAISpeechToTextDriver request shape', () => {
it('forwards language / format / diarize / multichannel / channels / audio_format / sample_rate', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
sttResponse({ text: 'ok', duration: 1 }),
);
await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
language: 'en',
format: true,
diarize: true,
multichannel: true,
channels: 2,
audio_format: 'pcm',
sample_rate: 16000,
}),
);
const form = (fetchSpy.mock.calls[0]![1] as RequestInit)
.body as FormData;
expect(form.get('language')).toBe('en');
expect(form.get('format')).toBe('true');
expect(form.get('diarize')).toBe('true');
expect(form.get('multichannel')).toBe('true');
expect(form.get('channels')).toBe('2');
expect(form.get('audio_format')).toBe('pcm');
expect(form.get('sample_rate')).toBe('16000');
});
it('omits optional fields when the caller does not supply them', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
sttResponse({ text: 'ok', duration: 1 }),
);
await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
}),
);
const form = (fetchSpy.mock.calls[0]![1] as RequestInit)
.body as FormData;
expect(form.get('language')).toBeNull();
expect(form.get('format')).toBeNull();
expect(form.get('diarize')).toBeNull();
expect(form.get('multichannel')).toBeNull();
expect(form.get('channels')).toBeNull();
expect(form.get('audio_format')).toBeNull();
expect(form.get('sample_rate')).toBeNull();
});
it('routes translate() to the same /v1/stt endpoint as transcribe()', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
sttResponse({ text: 'ok', duration: 1 }),
);
await withActor(actor, () =>
driver.translate({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
}),
);
expect(String(fetchSpy.mock.calls[0]![0])).toBe('https://api.x.ai/v1/stt');
});
});
// ── Response shape ──────────────────────────────────────────────────
describe('XAISpeechToTextDriver response shape', () => {
it('forwards the parsed xAI JSON response verbatim', async () => {
const { actor } = await makeUser();
const upstream = {
text: 'hello world',
language: 'English',
duration: 2.5,
words: [{ text: 'hello', start: 0, end: 1 }],
};
fetchSpy.mockResolvedValueOnce(sttResponse(upstream));
const result = await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
}),
);
expect(result).toEqual(upstream);
});
});
// ── Metering ────────────────────────────────────────────────────────
describe('XAISpeechToTextDriver metering', () => {
it('meters ceil(duration) seconds × per-second ucents using the API duration', async () => {
const { actor } = await makeUser();
// Upstream reports 3.2s → driver should ceil to 4s.
fetchSpy.mockResolvedValueOnce(
sttResponse({ text: 'ok', duration: 3.2 }),
);
await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
}),
);
const sttCalls = incrementUsageSpy.mock.calls.filter(
([, type]) => type === 'xai:stt:second',
);
expect(sttCalls).toHaveLength(1);
const [actorArg, , count, cost] = sttCalls[0]!;
expect((actorArg as Actor).user.id).toBe(actor.user.id);
expect(count).toBe(4);
expect(cost).toBe(UCENTS_PER_SECOND * 4);
});
it('falls back to the byte-based estimate when the API omits duration', async () => {
const { actor } = await makeUser();
// 32000 bytes → estimated 2s; upstream omits `duration`.
fetchSpy.mockResolvedValueOnce(sttResponse({ text: 'ok' }));
await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.alloc(32000, 0), 'audio/mp3'),
}),
);
const sttCalls = incrementUsageSpy.mock.calls.filter(
([, type]) => type === 'xai:stt:second',
);
expect(sttCalls).toHaveLength(1);
const [, , count, cost] = sttCalls[0]!;
expect(count).toBe(2);
expect(cost).toBe(UCENTS_PER_SECOND * 2);
});
});
// ── Error paths ─────────────────────────────────────────────────────
describe('XAISpeechToTextDriver error paths', () => {
it('maps upstream 4xx to HttpError 400 upstream_bad_request and skips metering', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
new Response('bad request', { status: 400 }),
);
await expect(
withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
}),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('maps upstream 5xx to HttpError 400 upstream_provider_unavailable', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(new Response('oops', { status: 503 }));
await expect(
withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
}),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_provider_unavailable',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('maps upstream 401/403 to HttpError 500 upstream_auth_failed', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
new Response('forbidden', { status: 403 }),
);
await expect(
withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
}),
),
).rejects.toMatchObject({
statusCode: 500,
legacyCode: 'upstream_auth_failed',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('maps upstream 429 to HttpError 429 upstream_rate_limited', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
new Response('slow down', { status: 429 }),
);
await expect(
withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
}),
),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'upstream_rate_limited',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('tags upstream errors with provider=xai and the original status', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
new Response('boom', { status: 502 }),
);
await expect(
withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
}),
),
).rejects.toMatchObject({
fields: { provider: 'xai', upstreamStatus: 502 },
});
});
it('lets fetch network errors bubble so the driver boundary can decide', async () => {
const { actor } = await makeUser();
fetchSpy.mockRejectedValueOnce(new Error('connection reset'));
await expect(
withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
}),
),
).rejects.toThrow('connection reset');
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
@@ -215,32 +215,45 @@ export class XAISpeechToTextDriver extends PuterDriver {
formData.append('file', blob, filename);
}
let response: Response;
try {
response = await fetch(`${API_BASE}/stt`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.#apiKey}`,
},
body: formData,
});
} catch (e: unknown) {
const msg = (e as Error).message ?? String(e);
console.error('[XAISpeechToTextDriver] API error:', msg);
throw new HttpError(502, `xAI STT API error: ${msg}`, {
legacyCode: 'internal_error',
});
}
const response = await fetch(`${API_BASE}/stt`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.#apiKey}`,
},
body: formData,
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
console.error(
`[XAISpeechToTextDriver] API returned ${response.status}: ${errText}`,
);
// Mirrors ElevenLabs / XAITTS — map upstream status to an
// `upstream_*` HttpError so the alarm gate skips it.
const legacyCode =
response.status >= 500
? 'upstream_provider_unavailable'
: response.status === 401 || response.status === 403
? 'upstream_auth_failed'
: response.status === 429
? 'upstream_rate_limited'
: 'upstream_bad_request';
const exposedStatus =
legacyCode === 'upstream_rate_limited'
? 429
: legacyCode === 'upstream_auth_failed'
? 500
: 400;
throw new HttpError(
502,
`xAI STT API error (${response.status}): ${errText}`,
{ legacyCode: 'internal_error' },
exposedStatus,
errText || `xAI STT request failed (status ${response.status})`,
{
legacyCode,
fields: {
provider: 'xai',
upstreamStatus: response.status,
},
},
);
}
+2 -2
View File
@@ -85,7 +85,7 @@ export class TTSDriver extends PuterDriver {
this.#registerProviders();
}
// ── Interface methods ───────────────────────────────────────────
// -- Interface methods -------------------------------------------
/**
* List all available voices across all configured providers.
@@ -194,7 +194,7 @@ export class TTSDriver extends PuterDriver {
>;
}
// ── Provider registration ───────────────────────────────────────
// -- Provider registration ---------------------------------------
#registerProviders() {
const providers = this.config.providers ?? {};
@@ -420,7 +420,7 @@ describe('ElevenLabsTTSProvider.synthesize metering', () => {
// ── Error paths ─────────────────────────────────────────────────────
describe('ElevenLabsTTSProvider.synthesize error paths', () => {
it('wraps non-OK upstream responses as HttpError 502', async () => {
it('wraps non-OK upstream 4xx responses as HttpError 400 with upstream_bad_request', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response('{"error":"bad voice"}', {
@@ -431,7 +431,10 @@ describe('ElevenLabsTTSProvider.synthesize error paths', () => {
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 502 });
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
@@ -100,12 +100,44 @@ export class ElevenLabsTTSProvider extends TTSProvider {
status: response.status,
detail,
});
// Map upstream status to an `upstream_*` HttpError so the alarm
// gate skips it. Anything 4xx from ElevenLabs (voice_not_found,
// invalid model, bad payload, auth) is a user-caused error from
// our perspective — expose as 400. 5xx is an outage on their
// side — also expose as 400 (`upstream_provider_unavailable`)
// since the user can't act on it but it's not our bug.
const upstreamCode =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(detail as any)?.detail?.code ?? (detail as any)?.code;
const upstreamMessage =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(detail as any)?.detail?.message ?? (detail as any)?.message;
const legacyCode =
response.status >= 500
? 'upstream_provider_unavailable'
: response.status === 401 || response.status === 403
? 'upstream_auth_failed'
: response.status === 429
? 'upstream_rate_limited'
: 'upstream_bad_request';
const exposedStatus =
legacyCode === 'upstream_rate_limited'
? 429
: legacyCode === 'upstream_auth_failed'
? 500
: 400;
throw new HttpError(
502,
`ElevenLabs request failed (status ${response.status})`,
exposedStatus,
upstreamMessage ??
`ElevenLabs request failed (status ${response.status})`,
{
legacyCode: 'internal_error',
fields: { provider: 'elevenlabs', status: response.status },
legacyCode,
fields: {
provider: 'elevenlabs',
upstreamStatus: response.status,
upstreamCode,
},
},
);
}
@@ -476,17 +476,21 @@ describe('GeminiTTSProvider.synthesize error paths', () => {
}
});
it('wraps SDK errors as HttpError 502', async () => {
it('lets SDK errors bubble untouched so the driver boundary can classify them', async () => {
const provider = makeProvider();
generateContentMock.mockRejectedValueOnce(new Error('upstream blew up'));
// Google GenAI `ApiError`s carry `.status` — surfacing them
// through the driver-boundary translator yields a proper
// `upstream_*` HttpError instead of a 500/502 page.
const apiError = Object.assign(new Error('bad voice'), { status: 400 });
generateContentMock.mockRejectedValueOnce(apiError);
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 502 });
).rejects.toMatchObject({ status: 400, message: 'bad voice' });
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
});
it('throws 502 when Gemini response has no inline audio data', async () => {
it('throws 400 upstream_bad_request when Gemini response has no inline audio data', async () => {
const provider = makeProvider();
generateContentMock.mockResolvedValueOnce({
candidates: [{ content: { parts: [{ text: 'no audio here' }] } }],
@@ -495,7 +499,10 @@ describe('GeminiTTSProvider.synthesize error paths', () => {
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 502 });
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
});
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
});
});
@@ -229,35 +229,29 @@ export class GeminiTTSProvider extends TTSProvider {
? `${instructions}\n\nSay the following text aloud:\n${text}`
: `Say the following text aloud:\n${text}`;
// Let Google GenAI `ApiError`s bubble — they carry `.status` and
// are mapped to `upstream_*` HttpErrors by the driver-boundary
// translator. Catching here and wrapping as 502 hid the upstream
// status and caused 4xx validation errors to page.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let response: any;
try {
response = await this.#client.models.generateContent({
model,
contents: [{ parts: [{ text: inputText }] }],
config: {
responseModalities: ['AUDIO'],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName: voice },
},
const response: any = await this.#client.models.generateContent({
model,
contents: [{ parts: [{ text: inputText }] }],
config: {
responseModalities: ['AUDIO'],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: { voiceName: voice },
},
},
});
} catch (e: unknown) {
const msg = (e as Error).message ?? String(e);
console.error('[GeminiTTSProvider] API error:', msg);
throw new HttpError(502, `Gemini TTS API error: ${msg}`, {
legacyCode: 'internal_error',
fields: { provider: 'gemini' },
});
}
},
});
// Extract audio data from response
const part = response?.candidates?.[0]?.content?.parts?.[0];
if (!part?.inlineData?.data) {
throw new HttpError(502, 'Gemini TTS did not return audio data', {
legacyCode: 'internal_error',
throw new HttpError(400, 'Gemini TTS did not return audio data', {
legacyCode: 'upstream_bad_request',
fields: { provider: 'gemini' },
});
}
@@ -327,7 +327,7 @@ describe('XAITTSProvider.synthesize metering', () => {
// ── Error paths ─────────────────────────────────────────────────────
describe('XAITTSProvider.synthesize error paths', () => {
it('wraps non-OK upstream responses as HttpError 502', async () => {
it('maps upstream 4xx to HttpError 400 upstream_bad_request', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response('bad request', { status: 400 }),
@@ -335,17 +335,55 @@ describe('XAITTSProvider.synthesize error paths', () => {
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 502 });
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('wraps fetch network errors as HttpError 502 (not a thrown ENOTFOUND)', async () => {
it('maps upstream 5xx to HttpError 400 upstream_provider_unavailable', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response('oops', { status: 503 }),
);
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_provider_unavailable',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('maps upstream 429 to HttpError 429 upstream_rate_limited', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response('slow down', { status: 429 }),
);
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'upstream_rate_limited',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('lets fetch network errors bubble so the driver boundary can decide', async () => {
const provider = makeProvider();
// Raw network errors don't carry an upstream status — we
// intentionally don't wrap them here. The driver-boundary
// catch-all will surface them as a generic 500 (which we
// *do* want to alert on, since "we can't even reach the
// provider" usually means something on our side is wrong).
fetchSpy.mockRejectedValueOnce(new Error('connection reset'));
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 502 });
).rejects.toThrow('connection reset');
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
@@ -154,34 +154,48 @@ export class XAITTSProvider extends TTSProvider {
body.output_format = { codec };
}
let response: Response;
try {
response = await fetch(`${API_BASE}/tts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.#apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
} catch (e: unknown) {
const msg = (e as Error).message ?? String(e);
console.error('[XAITTSProvider] API error:', msg);
throw new HttpError(502, `xAI TTS API error: ${msg}`, {
legacyCode: 'internal_error',
fields: { provider: 'xai' },
});
}
const response = await fetch(`${API_BASE}/tts`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.#apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
console.error(
`[XAITTSProvider] API returned ${response.status}: ${errText}`,
);
// Map upstream status to an `upstream_*` HttpError so the
// alarm gate skips it. Mirrors ElevenLabs' translator —
// 4xx and 5xx both surface as 400 to the client (with the
// appropriate legacyCode), 429 stays 429, auth stays 500.
const legacyCode =
response.status >= 500
? 'upstream_provider_unavailable'
: response.status === 401 || response.status === 403
? 'upstream_auth_failed'
: response.status === 429
? 'upstream_rate_limited'
: 'upstream_bad_request';
const exposedStatus =
legacyCode === 'upstream_rate_limited'
? 429
: legacyCode === 'upstream_auth_failed'
? 500
: 400;
throw new HttpError(
502,
`xAI TTS API error (${response.status}): ${errText}`,
{ legacyCode: 'internal_error', fields: { provider: 'xai' } },
exposedStatus,
errText || `xAI TTS request failed (status ${response.status})`,
{
legacyCode,
fields: {
provider: 'xai',
upstreamStatus: response.status,
},
},
);
}
@@ -362,7 +362,7 @@ describe('OpenAIVideoProvider.generate polling', () => {
}
});
it('throws when the polled job ends in failed state', async () => {
it('surfaces failed jobs as HttpError 400 upstream_failed (not a 500 page)', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce({
id: 'job-fail',
@@ -376,7 +376,11 @@ describe('OpenAIVideoProvider.generate polling', () => {
withTestActor(() =>
provider.generate({ prompt: 'hi', model: 'sora-2' }),
),
).rejects.toThrow('content policy violation');
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_failed',
message: 'content policy violation',
});
expect(videosDownloadContentMock).not.toHaveBeenCalled();
});
});
@@ -134,7 +134,14 @@ export class OpenAIVideoProvider extends VideoProvider {
if (finalJob.status === 'failed') {
const errorMessage =
finalJob.error?.message ?? 'Video generation failed';
throw new Error(errorMessage);
// Same reasoning as TogetherVideoProvider — Sora's `failed`
// status covers both user input issues (content policy) and
// their own outages; expose as `upstream_failed` 400 so the
// alarm gate skips it instead of paging on 500.
throw new HttpError(400, errorMessage, {
legacyCode: 'upstream_failed',
fields: { provider: 'openai' },
});
}
const finalResolution =
@@ -409,7 +409,7 @@ describe('TogetherVideoProvider.generate polling', () => {
}
});
it('throws HttpError 500 on failed jobs with the surfaced error message', async () => {
it('surfaces failed jobs as HttpError 400 upstream_failed (not 500) so they do not page', async () => {
const provider = makeProvider();
videosCreateMock.mockResolvedValueOnce({ id: 'job-3' });
videosRetrieveMock.mockResolvedValueOnce({
@@ -421,7 +421,8 @@ describe('TogetherVideoProvider.generate polling', () => {
await expect(
withTestActor(() => provider.generate({ prompt: 'hi' })),
).rejects.toMatchObject({
statusCode: 500,
statusCode: 400,
legacyCode: 'upstream_failed',
message: 'content policy violation',
});
});
@@ -195,8 +195,14 @@ export class TogetherVideoProvider extends VideoProvider {
finalJob?.info?.errors?.message ??
finalJob?.info?.errors ??
'Video generation failed';
throw new HttpError(500, errorMessage, {
legacyCode: 'internal_error',
// Together returns `failed` for both user-input issues
// (content policy / unsupported params) and their own
// outages — we can't reliably tell from the payload, so
// expose as 4xx with `upstream_failed`. The alarm gate
// skips `upstream_*` legacy codes so this no longer pages.
throw new HttpError(400, errorMessage, {
legacyCode: 'upstream_failed',
fields: { provider: 'together' },
});
}
+15 -12
View File
@@ -118,7 +118,7 @@ export class AppDriver extends PuterDriver {
return this.services.permission;
}
// ── Driver methods ───────────────────────────────────────────────
// -- Driver methods -----------------------------------------------
async create({ object, options } = {}) {
if (!object || typeof object !== 'object') {
@@ -157,13 +157,16 @@ export class AppDriver extends PuterDriver {
if (await this.appStore.existsByName(fields.name)) {
if (options?.dedupe_name) {
let candidate;
let n = 1;
let i = 0;
do {
candidate = `${fields.name}-${++n}`;
if (n > 50)
const randString = Math.random().toString(36).slice(2, 6);
candidate = `${fields.name}-${randString}`;
if (i >= 3)
throw new HttpError(400, 'Failed to dedupe app name', {
legacyCode: 'bad_request',
legacyCode: 'app_name_already_in_use',
});
i++;
} while (await this.appStore.existsByName(candidate));
fields.name = candidate;
} else {
@@ -411,7 +414,7 @@ export class AppDriver extends PuterDriver {
return { success: true, uid: app.uid };
}
// ── Event emission ───────────────────────────────────────────────
// -- Event emission -----------------------------------------------
//
// Consumers (AppIconService, future cf-file-cache port, billing
// event handlers) key off `app_uid`; the full `app` / `old_app`
@@ -448,7 +451,7 @@ export class AppDriver extends PuterDriver {
}
}
// ── Public helpers (used by AppController) ──────────────────────
// -- Public helpers (used by AppController) ----------------------
/** Check if an app name is available. Mirrors the REST endpoint behaviour. */
async isNameAvailable(name) {
@@ -460,7 +463,7 @@ export class AppDriver extends PuterDriver {
return !(await this.appStore.existsByName(name));
}
// ── Validation ───────────────────────────────────────────────────
// -- Validation ---------------------------------------------------
async #validateInput(object, { isCreate }) {
const out = {};
@@ -578,7 +581,7 @@ export class AppDriver extends PuterDriver {
return out;
}
// ── Permission checks ────────────────────────────────────────────
// -- Permission checks --------------------------------------------
#requireActor() {
const actor = Context.get('actor');
@@ -676,7 +679,7 @@ export class AppDriver extends PuterDriver {
}
}
// ── Serialization ────────────────────────────────────────────────
// -- Serialization ------------------------------------------------
/**
* Resolve the canonical app row that backs `app.index_url`.
@@ -840,7 +843,7 @@ export class AppDriver extends PuterDriver {
return result;
}
// ── Puter-hosted index_url merge logic ───────────────────────────
// -- Puter-hosted index_url merge logic ---------------------------
//
// Ported from v1 AppES (`#maybeJoinOwnedHostedIndexUrlAppOnCreate`,
// `#ensure_puter_site_subdomain_is_owned`, `#ensureIndexUrlNotAlreadyInUse`,
@@ -1075,7 +1078,7 @@ export class AppDriver extends PuterDriver {
return app.description.startsWith('App created from origin ');
}
// ── Canonical-uid alias kvstore pair ─────────────────────────────
// -- Canonical-uid alias kvstore pair -----------------------------
//
// After a merge, the source app uid → canonical uid mapping is
// kept in `stores.kv` (system namespace) so any client that still
+4 -4
View File
@@ -20,7 +20,7 @@
import type { Readable } from 'node:stream';
import type { WithLifecycle } from '../types';
// ── Stream result convention ────────────────────────────────────────
// -- Stream result convention ----------------------------------------
//
// Driver methods that return a stream instead of JSON wrap the readable
// in this shape. The `/drivers/call` handler detects it and pipes to the
@@ -46,7 +46,7 @@ export function isDriverStreamResult(v: unknown): v is DriverStreamResult {
);
}
// ── Driver metadata keys ────────────────────────────────────────────
// -- Driver metadata keys --------------------------------------------
//
// Metadata keys stored on driver prototypes by the `@Driver` decorator.
// Imperative drivers set these as instance properties instead.
@@ -58,7 +58,7 @@ export const DRIVER_ALIASES_KEY = '__driverAliases' as const;
export const DRIVER_RATE_LIMIT_KEY = '__driverRateLimit' as const;
export const DRIVER_CONCURRENT_KEY = '__driverConcurrent' as const;
// ── Driver rate-limit config ────────────────────────────────────────
// -- Driver rate-limit config ----------------------------------------
//
// A driver declares its rate-limit policy alongside its other metadata
// (`@Driver({ rateLimit: ... })` or an imperative `readonly rateLimit`
@@ -194,7 +194,7 @@ export function resolveDriverMethodRateLimit(
return cfg.methods?.[method] ?? cfg.default;
}
// ── Driver concurrent-limit config ──────────────────────────────────
// -- Driver concurrent-limit config ----------------------------------
//
// Twin to `DriverRateLimitConfig` but for in-flight concurrency. Same
// shape minus `window`, plus `bySubscription` to vary the cap by the
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more