Compare commits

...
16 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
118 changed files with 7837 additions and 966 deletions

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
# AGENTS.md
Guidance for AI coding agents working in this repository. Read this before making changes. FOLLOW GUIDANCE AS CLOSELY AS POSSIBLE. If you think the guidance is wrong, raise an issue or flag a maintainer — don't just do what you think is right. This is the source of truth for how we write code, tests, and docs in this repo.
## Documentation Index
Use these as the source of truth before exploring further:
- [README.md](README.md) — project overview and quickstart.
- [doc/architecture.md](doc/architecture.md) — backend layered stack (controllers → drivers → services → stores → clients), `PuterServer` wiring, `Context` (ALS), and extensions.
- [doc/self-hosting.md](doc/self-hosting.md) — running Puter outside hosted infra.
- [CONTRIBUTING.md](CONTRIBUTING.md) — testing, security, AI-assisted code, PR conventions, Boy Scout Rule.
- [SECURITY.md](SECURITY.md) — how to report vulnerabilities (do not file them publicly).
- [BUG-BOUNTY.md](BUG-BOUNTY.md) — bounty program scope.
- [TRADEMARK.md](TRADEMARK.md) — trademark usage.
---
## Backend
The backend is organized as a layered stack inspired by Controller–Service–Repository with dependency injection. Every layer only depends on the layers beneath it, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together.
### Layers (top → bottom)
| Layer | Lives in | Responsibility |
| --------------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Controllers** | [src/backend/controllers/](src/backend/controllers/) | Route handlers. Parse + validate input, apply per-route gates (auth, subdomain, rate limit, body parsers via `RouteOptions`), call services, format responses. |
| **Drivers** | [src/backend/drivers/](src/backend/drivers/) | Optional. RPC-style handlers exposed over `/drivers/*`. Thin shells that validate RPC inputs and call into services/stores. |
| **Services** | [src/backend/services/](src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized — services do not run auth gates themselves. |
| **Stores** | [src/backend/stores/](src/backend/stores/) | Persistence. Wraps clients with the domain shape services consume (rows, entities, KV namespaces). |
| **Clients** | [src/backend/clients/](src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, dynamo, email, event bus). Knows protocols, not domain concepts. |
| **Config** | `config.*.json` → `IConfig` | Flat, typed config object every layer receives at construction. |
Each layer receives the layers beneath it through its constructor. Dependencies are explicit and traceable from `PuterServer`.
### Cross-layer rules
- **Don't reach across layers.** Controllers do not poke clients directly; services do not register routes. If you want to, the abstraction is wrong — fix the abstraction.
- **Don't call sideways within a layer for code reuse.** If two services need the same logic, lift it into a util/helper. Services should not depend on other services for shared code.
- **Prefer explicit arguments over `Context` (ALS).** Reach for [Context](src/backend/core/context.ts) only when the value is genuinely request-scoped and would otherwise thread through many layers. Today it's used sparingly — mostly for `actor` and `req`.
### Extensions
Extensions live in [extensions/](extensions/) and parallel the layered stack. They are for **non-crucial parts of the system** — things Puter still works without if removed. If a feature is load-bearing for clients, it belongs in core, not in an extension (see [whoami](extensions/whoami.ts) as the cautionary example).
The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts)) exposes:
- `extension.registerClient/Store/Service/Driver/Controller(name, Class)` for first-class additions.
- `extension.on(event, handler)` and `extension.get/post/put/delete/patch/use(path, opts?, handler)` for the lightweight common case. `opts` is the same `RouteOptions` controllers use.
- `extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` returns a lazy proxy to instantiated objects. `extension.config` exposes live config.
### Language & file conventions
- **Modules:** We transpile and build as needed — write ES modules, not CommonJS.
- **TypeScript preferred for new files.** Existing JS is fine; convert opportunistically when you're already touching a file.
- **Reuse types before inventing them.** Search for an existing type first; extend it if close. Only define a new type when nothing fits.
- **Make new types findable.** Co-locate them with the layer/module that owns them, export from the obvious entry point, and use a descriptive `PascalCase` name. Don't hide types in random files where future readers won't grep them.
- **Naming:** `camelCase` for variables/functions, `PascalCase` for classes and for files containing a class (`AuthService.ts`, `KVStoreDriver.ts`).
### Comments
Keep comments light. Prefer self-documenting code — clear names, small functions, obvious flow. Add a comment **only** when:
- The _why_ is non-obvious (a hidden constraint, a workaround, a subtle invariant).
- A non-trivial usage detail would otherwise trip the next reader.
Don't restate what the code already says. Don't write comments that reference the current task or PR — those rot.
**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.
- Use Vitest for unit and integration tests. Test files go next to the code they test, named `*.test.ts` or `*.test.js`.
- **Mock data, not methods.** Stub the inputs (fixtures, fake rows, sample payloads) rather than mocking the function under test or the layer beneath it. Over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service), not within the same layer you're testing.
- Prefer test server over mocking deps. We have existing test utilities for running full test server to boot deps based on config. (see [src/backend/testUtil.ts](src/backend/testUtil.ts)).
- **Hit a real database / real client where reasonable.** Integration shapes catch the things unit tests with mocks miss.
- **Regression tests for bug fixes.** A test that fails before your fix and passes after is the cheapest insurance against the bug coming back.
- If something is genuinely hard to test (UI animation, third-party glue), skip it but say so in the PR.
### Security & privacy
Before opening a PR, scan the diff for:
- Logs, error messages, or responses leaking internal paths, secrets, tokens, env vars, or other users' data.
- Debug routes, test credentials, commented-out auth checks.
- Endpoints returning more than the caller actually needs.
When in doubt, return less. Auth-, permission-, or data-export-related changes deserve an explicit callout in the PR description.
### Working rules of thumb
- **Run it, don't just compile it.** "It type-checks" is not "it works." Exercise the code path end-to-end at least once.
- **Read the neighbors before writing.** Match the shape of similar things already in the tree. If you genuinely think the existing pattern is wrong, raise it — don't quietly diverge.
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo, dead import, or missing typehint in files you're already touching. Don't ride a refactor along with a bug fix — that just makes review harder.
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't defend in review is not.
+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(
+70 -44
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": {
@@ -7436,6 +7435,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/memcached": {
"version": "2.2.10",
"resolved": "https://registry.npmjs.org/@types/memcached/-/memcached-2.2.10.tgz",
@@ -7459,6 +7469,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mysql": {
"version": "2.15.26",
"resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz",
@@ -7570,6 +7587,13 @@
"@types/node": "*"
}
},
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -8934,9 +8958,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10156,9 +10180,9 @@
}
},
"node_modules/engine.io": {
"version": "6.6.7",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz",
"integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==",
"version": "6.6.8",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz",
"integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
@@ -10170,7 +10194,7 @@
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3"
"ws": "~8.20.1"
},
"engines": {
"node": ">=10.2.0"
@@ -10225,9 +10249,9 @@
}
},
"node_modules/engine.io/node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -15400,24 +15424,24 @@
}
},
"node_modules/protobufjs": {
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz",
"integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==",
"version": "7.6.1",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz",
"integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.1",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
@@ -15481,9 +15505,9 @@
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -16610,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": {
@@ -16637,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"
@@ -17220,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"
@@ -18182,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"
@@ -18401,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",
+6 -6
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;
@@ -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,7 +228,7 @@ 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
@@ -309,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}`,
@@ -81,6 +81,10 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[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;
@@ -15,7 +15,7 @@
-- 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/>.
-- AUTH-2 (PUT-1014) — composite-key lookups + audit columns. Mirrors
-- 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
@@ -15,14 +15,14 @@
-- 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/>.
-- AUTH-2 (PUT-1014) — composite-key lookups + audit columns.
-- 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
-- (PUT-1010); lets manage-sessions group by identity.
-- - `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;
@@ -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]';
@@ -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 });
});
});
+424 -67
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',
@@ -1999,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',
@@ -2032,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') {
@@ -2052,7 +2256,7 @@ export class AuthController extends PuterController {
res.json({ ok: true });
}
// ── 2FA: configure ──────────────────────────────────────────────
// -- 2FA: configure ----------------------------------------------
@Post('/auth/configure-2fa/:action', {
subdomain: 'api',
@@ -2155,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!, {
@@ -2185,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> {
@@ -2215,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> {
@@ -2321,7 +2525,7 @@ export class AuthController extends PuterController {
});
}
// ── Session helpers ─────────────────────────────────────────────
// -- Session helpers ---------------------------------------------
@Get('/get-gui-token', {
requireUserActor: true,
@@ -2362,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
@@ -2378,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]`.
@@ -2427,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,
@@ -2525,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 {
@@ -2612,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,
@@ -2636,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`
@@ -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.
@@ -236,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 {
@@ -260,7 +260,7 @@ export class DriverController extends PuterController {
return this.#defaults.get(interfaceName);
}
// ── Route registration ──────────────────────────────────────────
// -- Route registration ------------------------------------------
registerRoutes(router: PuterRouter): void {
router.post(
@@ -280,7 +280,7 @@ export class DriverController extends PuterController {
);
}
// ── Handlers ────────────────────────────────────────────────────
// -- Handlers ----------------------------------------------------
#handleCall = async (req: Request, res: Response): Promise<void> => {
const {
@@ -494,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>;
+3 -3
View File
@@ -817,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) {
@@ -1015,7 +1015,7 @@ export class FSController extends PuterController {
}
}
// ── Mutation routes ────────────────────────────────────────────────
// -- Mutation routes ------------------------------------------------
@Post('/mkdir', { subdomain: 'api', requireVerified: true })
async mkdirEntry(req: Request, res: Response) {
@@ -1213,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;
@@ -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();
@@ -241,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
@@ -915,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
@@ -1573,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
@@ -1703,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`,
@@ -2012,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 {
@@ -2039,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:
@@ -2100,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
@@ -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(
@@ -139,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.
@@ -237,7 +237,7 @@ export class WebDAVController extends PuterController {
};
}
// ── OPTIONS ──────────────────────────────────────────────────────
// -- OPTIONS ------------------------------------------------------
#options(res: Response): void {
res.status(200)
@@ -251,7 +251,7 @@ export class WebDAVController extends PuterController {
.send('');
}
// ── GET / HEAD ──────────────────────────────────────────────────
// -- GET / HEAD --------------------------------------------------
async #get(
req: Request,
@@ -301,7 +301,7 @@ export class WebDAVController extends PuterController {
result.body.pipe(res);
}
// ── PROPFIND ────────────────────────────────────────────────────
// -- PROPFIND ----------------------------------------------------
async #propfind(
req: Request,
@@ -348,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,
@@ -372,7 +372,7 @@ export class WebDAVController extends PuterController {
);
}
// ── MKCOL ───────────────────────────────────────────────────────
// -- MKCOL -------------------------------------------------------
async #mkcol(
req: Request,
@@ -422,7 +422,7 @@ export class WebDAVController extends PuterController {
.end();
}
// ── PUT ─────────────────────────────────────────────────────────
// -- PUT ---------------------------------------------------------
async #put(
req: Request,
@@ -498,7 +498,7 @@ export class WebDAVController extends PuterController {
.end();
}
// ── DELETE ───────────────────────────────────────────────────────
// -- DELETE -------------------------------------------------------
async #delete(
res: Response,
@@ -528,7 +528,7 @@ export class WebDAVController extends PuterController {
res.status(204).end();
}
// ── COPY ────────────────────────────────────────────────────────
// -- COPY --------------------------------------------------------
async #copy(
req: Request,
@@ -586,7 +586,7 @@ export class WebDAVController extends PuterController {
res.status(destExists ? 204 : 201).end();
}
// ── MOVE ────────────────────────────────────────────────────────
// -- MOVE --------------------------------------------------------
async #move(
req: Request,
@@ -642,7 +642,7 @@ export class WebDAVController extends PuterController {
res.status(destExists ? 204 : 201).end();
}
// ── LOCK ────────────────────────────────────────────────────────
// -- LOCK --------------------------------------------------------
async #lock(
req: Request,
@@ -709,7 +709,7 @@ export class WebDAVController extends PuterController {
.send(lockResponseXml(token, davPath, lockScope));
}
// ── UNLOCK ──────────────────────────────────────────────────────
// -- UNLOCK ------------------------------------------------------
async #unlock(
req: Request,
@@ -740,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 = {
@@ -766,7 +766,7 @@ export class WebDAVController extends PuterController {
});
}
// ── Event emission ──────────────────────────────────────────────
// -- Event emission ----------------------------------------------
#emitGuiEvent<T extends keyof EventMap>(
eventName: T,
@@ -795,7 +795,7 @@ export class WebDAVController extends PuterController {
});
}
// ── Misc helpers ────────────────────────────────────────────────
// -- Misc helpers ------------------------------------------------
#parseDestination(req: Request): string {
const dest = req.headers.destination as string | undefined;
@@ -812,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,
+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.
@@ -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',
@@ -891,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,
@@ -69,6 +69,7 @@ export interface PrivateHostingConfig {
export interface PrivateIdentity {
source:
| 'private-cookie'
| 'public-cookie'
| 'session-cookie'
| 'bootstrap-token'
| 'authorization'
@@ -77,7 +78,7 @@ 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;
}
@@ -95,7 +96,7 @@ interface AppLike {
index_url?: string | null;
}
// ── Host helpers ────────────────────────────────────────────────────
// -- Host helpers ----------------------------------------------------
export function normalizeHost(value: string | undefined | null): string | null {
if (typeof value !== 'string') return null;
@@ -149,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,
@@ -169,7 +170,7 @@ export function subdomainFromHost(
return host.split('.')[0] || '';
}
// ── Hosted-site → owned-app resolution ─────────────────────────────
// -- Hosted-site → owned-app resolution -----------------------------
/**
* Resolve "what app does the subdomain owner run on this host?" by matching
@@ -258,7 +259,7 @@ export async function resolveOwnedAppForHostedSite(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;
@@ -345,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(
@@ -361,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 */
}
@@ -477,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,
@@ -492,12 +508,15 @@ 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 */
}
@@ -561,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(
@@ -642,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.
@@ -332,7 +332,7 @@ export const createPuterSiteMiddleware = (
privateHost: host,
});
res.cookie(
layers.services.auth.getPrivateAssetCookieName(),
layers.services.auth.getPrivateAssetCookieNameV2(),
token,
layers.services.auth.getPrivateAssetCookieOptions({
requestHostname: host,
@@ -401,7 +401,7 @@ export const createPuterSiteMiddleware = (
},
);
res.cookie(
layers.services.auth.getPublicHostedActorCookieName(),
layers.services.auth.getPublicHostedActorCookieNameV2(),
token,
layers.services.auth.getPublicHostedActorCookieOptions({
requestHostname: host,
@@ -300,7 +300,7 @@ export function resolveErrorTarget(
return { absPath, status: rule.status };
}
// ── Parsers ─────────────────────────────────────────────────────────
// -- Parsers ---------------------------------------------------------
function parsePuterSiteConfig(text: string): SiteConfig | null {
let raw: unknown;
+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
@@ -217,7 +217,7 @@ export class ChatCompletionDriver extends PuterDriver {
this.#buildModelMap();
}
// ── Interface methods ───────────────────────────────────────────
// -- Interface methods -------------------------------------------
async models() {
const seen = new Set<string>();
@@ -342,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.
@@ -555,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.
@@ -772,7 +772,7 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
// ── Provider registration ───────────────────────────────────────
// -- Provider registration ---------------------------------------
#registerProviders() {
const providers = this.config.providers ?? {};
@@ -928,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',
@@ -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',
+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,
@@ -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
@@ -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();
});
});
+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 ?? {};
+8 -8
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') {
@@ -414,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`
@@ -451,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) {
@@ -463,7 +463,7 @@ export class AppDriver extends PuterDriver {
return !(await this.appStore.existsByName(name));
}
// ── Validation ───────────────────────────────────────────────────
// -- Validation ---------------------------------------------------
async #validateInput(object, { isCreate }) {
const out = {};
@@ -581,7 +581,7 @@ export class AppDriver extends PuterDriver {
return out;
}
// ── Permission checks ────────────────────────────────────────────
// -- Permission checks --------------------------------------------
#requireActor() {
const actor = Context.get('actor');
@@ -679,7 +679,7 @@ export class AppDriver extends PuterDriver {
}
}
// ── Serialization ────────────────────────────────────────────────
// -- Serialization ------------------------------------------------
/**
* Resolve the canonical app row that backs `app.index_url`.
@@ -843,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`,
@@ -1078,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
@@ -69,7 +69,7 @@ export class NotificationDriver extends PuterDriver {
},
};
// ── Driver methods ──────────────────────────────────────────────
// -- Driver methods ----------------------------------------------
async create(args: Record<string, unknown>): Promise<unknown> {
const object = args.object as Record<string, unknown> | undefined;
@@ -188,7 +188,7 @@ export class NotificationDriver extends PuterDriver {
return { success: ok };
}
// ── Permissions ─────────────────────────────────────────────────
// -- Permissions -------------------------------------------------
#requireUserActor(): Actor & {
user: { id: number; uuid: string; username: string };
@@ -212,7 +212,7 @@ export class NotificationDriver extends PuterDriver {
};
}
// ── Serialization ───────────────────────────────────────────────
// -- Serialization -----------------------------------------------
#toClient(
row: Record<string, unknown> | null,
@@ -90,7 +90,7 @@ export class SubdomainDriver extends PuterDriver {
},
};
// ── Driver methods ──────────────────────────────────────────────
// -- Driver methods ----------------------------------------------
async create(args: Record<string, unknown>): Promise<unknown> {
const object = args.object as Record<string, unknown> | undefined;
@@ -368,7 +368,7 @@ export class SubdomainDriver extends PuterDriver {
return { success: true, uid: row.uuid };
}
// ── Resolve ─────────────────────────────────────────────────────
// -- Resolve -----------------------------------------------------
async #resolve(
args: Record<string, unknown>,
@@ -386,7 +386,7 @@ export class SubdomainDriver extends PuterDriver {
return null;
}
// ── Validation ──────────────────────────────────────────────────
// -- Validation --------------------------------------------------
#validateSubdomain(raw: unknown): string {
if (typeof raw !== 'string' || raw.trim().length === 0) {
@@ -418,7 +418,7 @@ export class SubdomainDriver extends PuterDriver {
return s;
}
// ── Permissions ─────────────────────────────────────────────────
// -- Permissions -------------------------------------------------
#requireActor(): Actor & {
user: { id: number; uuid: string; username: string };
@@ -500,7 +500,7 @@ export class SubdomainDriver extends PuterDriver {
}
}
// ── Config ──────────────────────────────────────────────────────
// -- Config ------------------------------------------------------
#configMaxSubdomains(): number {
const n = Number(
@@ -509,7 +509,7 @@ export class SubdomainDriver extends PuterDriver {
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_SUBDOMAINS;
}
// ── Serialization ───────────────────────────────────────────────
// -- Serialization -----------------------------------------------
//
// v1's `puter-subdomains` shape (canonical, see SubdomainES + the
// mapping at om/mappings/subdomain.js):
@@ -798,7 +798,7 @@ export class SubdomainDriver extends PuterDriver {
}
}
// ── Embed shape helpers (module-level, sync, no DB) ─────────────────
// -- Embed shape helpers (module-level, sync, no DB) -----------------
//
// `root_dir` mirrors v1's `safe_entry` from FSNodeContext, minus the
// fields v1 deletes before sending to clients (`user_id`, `bucket`,
+1 -1
View File
@@ -23,7 +23,7 @@ import {
} from '../../services/metering/consts.js';
import type { DriverConcurrentConfig, DriverRateLimitConfig } from '../meta.js';
// ── Shared AI driver limits ─────────────────────────────────────────
// -- Shared AI driver limits -----------------------------------------
//
// Every AI driver — chat, image, video, TTS, speech↔speech, speech→text,
// OCR — shares this policy envelope. Tuning the numbers in one place
+41 -24
View File
@@ -18,15 +18,15 @@
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import type { EventMetadata } from '../../clients/event/types.js';
import type { Actor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import { HttpError, type LegacyErrorCodes } from '../../core/http/HttpError.js';
import { assertVerifiedEmail } from '../../core/http/verifiedEmail.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import { PuterDriver } from '../types.js';
import { loadFileInput } from '../util/fileInput.js';
import type { Actor } from '../../core/actor.js';
import path from 'node:path';
import type { EventMetadata } from '../../clients/event/types.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
const CF_BASE_URL = 'https://api.cloudflare.com/client/v4/accounts';
const WORKER_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
@@ -34,7 +34,7 @@ const MAX_WORKERS_PER_USER = 100;
const MAX_SOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
const WORKER_SUBDOMAIN_PREFIX = 'workers.puter.';
// ── Preamble ────────────────────────────────────────────────────────
// -- Preamble --------------------------------------------------------
//
// The preamble is a webpack-built JS bundle that provides puter.js to
// worker code. It's baked into the source sent to Cloudflare Workers.
@@ -104,7 +104,7 @@ export class WorkerDriver extends PuterDriver {
this.#subscribeHotReload();
}
// ── Driver methods ──────────────────────────────────────────────
// -- Driver methods ----------------------------------------------
async create(args: {
appId: string;
@@ -150,8 +150,12 @@ export class WorkerDriver extends PuterDriver {
);
}
// If tied to an app, verify ownership and get app-scoped token
let authorization = String(args.authorization ?? '');
// If tied to an app, verify ownership and get an app-scoped
// worker token. Worker tokens use `kind='worker'` so they don't
// collide with any interactive `kind='app'` session for the
// same (user, app); the long expiry (WORKER_WINDOW_SECONDS)
// means the worker doesn't have to re-mint on a clock cadence.
let authorization = undefined;
let appOwnerId = actor.app?.id ?? undefined;
if (appId) {
if (actor.app && actor.app.uid !== appId) {
@@ -162,15 +166,17 @@ export class WorkerDriver extends PuterDriver {
);
}
appOwnerId = actor.app?.id;
authorization = await this.services.auth.getUserAppToken(
authorization = await this.services.auth.createWorkerAppToken(
actor,
appId,
workerName,
);
}
if (!authorization && actor.app?.uid) {
authorization = await this.services.auth.getUserAppToken(
authorization = await this.services.auth.createWorkerAppToken(
actor,
actor.app.uid,
workerName,
);
}
@@ -186,14 +192,18 @@ export class WorkerDriver extends PuterDriver {
);
}
if (!authorization) {
// Fall back to a session token for the current user
// Fall back to a user-scoped worker token (no app binding).
// Same kind='worker' row + long expiry as the app-scoped
// branch above; (user, worker_name) is the unique key.
const userRow = await this.stores.user.getById(actor.user.id!);
if (!userRow)
throw new HttpError(500, 'User not found', {
legacyCode: 'internal_error',
});
const session =
await this.services.auth.createSessionToken(userRow);
const session = await this.services.auth.createWorkerSessionToken(
userRow,
workerName,
);
authorization = session.token;
}
@@ -340,7 +350,7 @@ export class WorkerDriver extends PuterDriver {
return this.#workerConfig().loggingUrl ?? null;
}
// ── Cloudflare API ──────────────────────────────────────────────
// -- Cloudflare API ----------------------------------------------
async #cfDeploy(
workerName: string,
@@ -420,7 +430,7 @@ export class WorkerDriver extends PuterDriver {
return (await res.json()) as Record<string, unknown>;
}
// ── Helpers ──────────────────────────────────────────────────────
// -- Helpers ------------------------------------------------------
#requireActor(): Actor & {
user: { id: number; uuid: string; username: string };
@@ -498,7 +508,7 @@ export class WorkerDriver extends PuterDriver {
);
}
// ── Hot-reload: auto-redeploy on source file write ──────────────
// -- Hot-reload: auto-redeploy on source file write --------------
//
// When a user saves a JS file that's tied to a worker subdomain,
// we redeploy it to Cloudflare automatically. This is what makes
@@ -581,21 +591,28 @@ export class WorkerDriver extends PuterDriver {
);
const sourceCode = loaded.buffer.toString('utf-8');
// Get an auth token for the deploy
// Mint a worker token for the redeploy. Idempotent on
// (user, app_uid, worker_name) so a hot-reload reuses
// the same row across reloads and the long-lived token
// stays stable for the worker's whole lifetime.
const appOwnerId = row.app_owner as number | null;
let authorization: string;
if (appOwnerId) {
// App-scoped: get the app's uid, then mint an app-under-user token
const app = await this.stores.app.getById(appOwnerId);
if (!app) continue; // app gone
authorization = await this.services.auth.getUserAppToken(
ownerActor,
app.uid,
);
authorization =
await this.services.auth.createWorkerAppToken(
ownerActor,
app.uid,
workerName,
);
} else {
// User-scoped: mint a session token
const session =
await this.services.auth.createSessionToken(ownerUser);
await this.services.auth.createWorkerSessionToken(
ownerUser,
workerName,
);
authorization = session.token;
}
+5 -5
View File
@@ -202,7 +202,7 @@ const makeUseFn = (): ExtensionUseFn => {
* proxy to the registered instance (thrown on use-before-init).
*/
export const extension = {
// ── Config access ───────────────────────────────────────────────
// -- Config access -----------------------------------------------
//
// Lazy proxy to the server config. Populated by PuterServer during
// boot, so extensions can read it at request time (not import time).
@@ -211,7 +211,7 @@ export const extension = {
return configContainer;
},
// ── Event subscription ───────────────────────────────────────────
// -- Event subscription -------------------------------------------
on: <P extends ListenKey>(
key: P,
@@ -228,7 +228,7 @@ export const extension = {
extensionStore.events[key].push(callback as any);
},
// ── Registry writers ─────────────────────────────────────────────
// -- Registry writers ---------------------------------------------
registerClient: (
name: string,
@@ -264,7 +264,7 @@ export const extension = {
extensionStore.globalMiddlewares.push(middleware);
},
// ── Route registration ───────────────────────────────────────────
// -- Route registration -------------------------------------------
//
// Supports two call shapes per verb:
// extension.get('/path', handler)
@@ -285,7 +285,7 @@ export const extension = {
all: makeRouteFn('all'),
use: makeUseFn(),
// ── Import proxy ─────────────────────────────────────────────────
// -- Import proxy -------------------------------------------------
import: <S extends string>(
name: S,
+2
View File
@@ -75,7 +75,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",
+16 -15
View File
@@ -389,7 +389,7 @@ export class PuterServer {
next();
});
// ── Query param sanitization ────────────────────────────────
// -- Query param sanitization --------------------------------
// Strip non-primitive query values. Express 5's default simple
// parser mostly avoids these, but when `extended` qs is enabled
// (or a client tricks the parser) arrays/objects can sneak in.
@@ -406,7 +406,7 @@ export class PuterServer {
next();
});
// ── UA parsing ──────────────────────────────────────────────
// -- UA parsing ----------------------------------------------
this.#app.use((req, _res, next) => {
const header = req.headers['user-agent'];
if (header) {
@@ -415,34 +415,34 @@ export class PuterServer {
next();
});
// ── Host header validation ──────────────────────────────────
// -- Host header validation ----------------------------------
this.#installHostValidation();
// ── Host redirects (www → root, user subdomain → static hosting)
// -- Host redirects (www → root, user subdomain → static hosting)
// Installed after host validation so we know the host is allowed,
// and before CORS/body-parsing so we short-circuit on redirects
// without burning work.
this.#app.use(createWwwRedirect(this.#config));
this.#app.use(createUserSubdomainRedirect(this.#config));
// ── Native app static serving (editor.*, docs.*, …) ─────────
// -- Native app static serving (editor.*, docs.*, …) ---------
// No-op when `native_apps_root` is unset.
this.#app.use(createNativeAppStatic(this.#config));
// ── CORS headers ────────────────────────────────────────────
// -- CORS headers --------------------------------------------
this.#installCors();
// ── IP validation ───────────────────────────────────────────
// -- IP validation -------------------------------------------
if (this.#config.enable_ip_validation) {
this.#installIpValidation();
}
// ── OPTIONS preflight ───────────────────────────────────────
// -- OPTIONS preflight ---------------------------------------
this.#app.options('/*splat', (_req, res) => {
res.sendStatus(200);
});
// ── Body parsing (JSON + text-as-json shim) ─────────────────
// -- Body parsing (JSON + text-as-json shim) -----------------
const captureRawBody: NonNullable<
Parameters<typeof express.json>[0]
>['verify'] = (req, _res, buf) => {
@@ -464,23 +464,24 @@ export class PuterServer {
// cover the auth_token / anti_csrf field shape, not file uploads.
this.#app.use(express.urlencoded({ extended: true, limit: '100kb' }));
// ── Auth probe ──────────────────────────────────────────────
// -- Auth probe ----------------------------------------------
const authService = this.services.auth as AuthService | undefined;
if (authService) {
this.#app.use(
createAuthProbe({
authService,
cookieName: this.#config.cookie_name,
kvStore: this.stores.kv,
}),
);
}
// ── Per-request ALS context ─────────────────────────────────
// -- Per-request ALS context ---------------------------------
// Runs AFTER auth probe so `req.actor` is already populated when
// we snapshot it into the context.
this.#app.use(createRequestContextMiddleware());
// ── User-hosted sites (*.puter.site, *.puter.app) ───────────
// -- User-hosted sites (*.puter.site, *.puter.app) -----------
// Short-circuits hosting-domain hosts before any API/GUI
// controller route has a chance to match. Needs DI layers for
// subdomain lookup, private-app gate, and file streaming.
@@ -497,7 +498,7 @@ export class PuterServer {
});
}
// ── Host header validation ──────────────────────────────────────
// -- Host header validation --------------------------------------
#installHostValidation() {
const config = this.#config;
@@ -588,7 +589,7 @@ export class PuterServer {
return hostname === domain || hostname.endsWith(`.${domain}`);
}
// ── CORS headers ─────────────────────────────────────────────────
// -- CORS headers -------------------------------------------------
#installCors() {
const config = this.#config;
@@ -642,7 +643,7 @@ export class PuterServer {
});
}
// ── IP validation ───────────────────────────────────────────────
// -- IP validation -----------------------------------------------
#installIpValidation() {
this.#app.use(async (req, res, next) => {
+3 -3
View File
@@ -26,7 +26,7 @@ import { PermissionUtil } from '../permission/permissionUtil';
import { MANAGE_PERM_PREFIX } from '../permission/consts';
import { HttpError } from '../../core/http/HttpError.js';
// ── Types ────────────────────────────────────────────────────────────
// -- Types ------------------------------------------------------------
/**
* Thin, filesystem-agnostic view of a resource for ACL checks.
@@ -77,7 +77,7 @@ const PUBLIC_READ_MODES: ReadonlyArray<AclMode> = Object.freeze([
'see',
]);
// ── ACLService ───────────────────────────────────────────────────────
// -- ACLService -------------------------------------------------------
/**
* ACLService enforces filesystem access-control semantics for Puter.
@@ -97,7 +97,7 @@ const PUBLIC_READ_MODES: ReadonlyArray<AclMode> = Object.freeze([
export class ACLService extends PuterService {
declare protected services: LayerInstances<typeof puterServices>;
// ── Public API ───────────────────────────────────────────────────
// -- Public API ---------------------------------------------------
/**
* Returns true iff `actor` is allowed to perform `mode` access on `resource`.
@@ -152,7 +152,7 @@ export class AppIconService extends PuterService {
return `${protocol}://${APP_ICONS_SUBDOMAIN}.${host}${portSuffix}`;
}
// ── Bootstrap ───────────────────────────────────────────────────
// -- Bootstrap ---------------------------------------------------
/**
* Public so `DefaultUserService` can call it immediately after it
@@ -210,7 +210,7 @@ export class AppIconService extends PuterService {
}
}
// ── Icon pipeline ───────────────────────────────────────────────
// -- Icon pipeline -----------------------------------------------
async #processIcon(data: Record<string, unknown>): Promise<void> {
if (this.#dirReady) await this.#dirReady;
@@ -45,7 +45,7 @@ export class AppPermissionService extends PuterService {
const permissions = this.services.permission;
const appStore = this.stores.app;
// ── app:<name>:mode → app:uid#<uid>:mode ───────────────────────
// -- app:<name>:mode → app:uid#<uid>:mode -----------------------
// Names change (via app rename); uids are stable. Store/scan uid
// form so renames don't invalidate existing grants. AppStore caches
// `getByName` in Redis (5m), invalidated on rename/update.
@@ -65,7 +65,7 @@ export class AppPermissionService extends PuterService {
},
});
// ── app-is-owner implicator ───────────────────────────────────
// -- app-is-owner implicator -----------------------------------
// User actors implicitly hold `app:uid#X:*` (and manage form) on
// apps they own. Mirrors the fs is-owner pattern.
permissions.registerImplicator({
@@ -97,7 +97,7 @@ export class AppPermissionService extends PuterService {
},
});
// ── apps-of-user:<own_uuid>:* / subdomains-of-user:… ──────────
// -- apps-of-user:<own_uuid>:* / subdomains-of-user:… ----------
// A user implicitly holds read/write over *their own* apps and
// subdomains. `puter.perms` expresses these as
// `apps-of-user:<own_uuid>:<mode>` etc.
@@ -118,7 +118,7 @@ export class AppPermissionService extends PuterService {
},
});
// ── app-root-dir:<app_uid>:<mode> → fs:<root_uid>:<mode> ───────
// -- app-root-dir:<app_uid>:<mode> → fs:<root_uid>:<mode> -------
// Only rewrites during an explicit `grantUserAppPermission` (see
// PermissionService for the context flag). During scans we return
// PERMISSION_FOR_NOTHING_IN_PARTICULAR so `check(actor, 'app-root-dir:…')`
@@ -21,7 +21,7 @@ import { posix as pathPosix } from 'node:path';
import { getAppIconUrl } from '../../util/appIcon.js';
import { PuterService } from '../types.js';
// ── Extension → suggested app names mapping ─────────────────────────
// -- Extension → suggested app names mapping -------------------------
//
// Each extension maps to an ordered list of built-in app names that
// can open files of that type.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+8 -8
View File
@@ -81,7 +81,7 @@ export class OIDCService extends PuterService {
>;
}
// ── Provider config ─────────────────────────────────────────────
// -- Provider config ---------------------------------------------
async getProviderConfig(
providerId: string,
@@ -180,7 +180,7 @@ export class OIDCService extends PuterService {
return ids;
}
// ── Auth URL ────────────────────────────────────────────────────
// -- Auth URL ----------------------------------------------------
getCallbackUrl(flow: string): string | null {
if (!(VALID_OIDC_FLOWS as readonly string[]).includes(flow))
@@ -212,7 +212,7 @@ export class OIDCService extends PuterService {
return `${config.authorization_endpoint}?${params.toString()}`;
}
// ── State tokens (CSRF) ─────────────────────────────────────────
// -- State tokens (CSRF) -----------------------------------------
signState(payload: Record<string, unknown>): string {
return this.services.token.sign('oidc-state', payload, {
@@ -231,7 +231,7 @@ export class OIDCService extends PuterService {
}
}
// ── Revalidation tokens ─────────────────────────────────────────
// -- Revalidation tokens -----------------------------------------
signRevalidation(userUuid: string): string {
return this.services.token.sign(
@@ -244,7 +244,7 @@ export class OIDCService extends PuterService {
);
}
// ── Token exchange ──────────────────────────────────────────────
// -- Token exchange ----------------------------------------------
async exchangeCodeForTokens(
providerId: string,
@@ -279,7 +279,7 @@ export class OIDCService extends PuterService {
return (await res.json()) as { access_token: string };
}
// ── User info ───────────────────────────────────────────────────
// -- User info ---------------------------------------------------
async getUserInfo(
providerId: string,
@@ -304,7 +304,7 @@ export class OIDCService extends PuterService {
return null;
}
// ── User lookup / creation ──────────────────────────────────────
// -- User lookup / creation --------------------------------------
async findUserByProviderSub(
provider: string,
@@ -589,7 +589,7 @@ export class OIDCService extends PuterService {
return { success: true, user: resolved };
}
// ── Internals ───────────────────────────────────────────────────
// -- Internals ---------------------------------------------------
async #fetchDiscovery(
url: string,
+58 -15
View File
@@ -25,7 +25,7 @@ import { PuterService } from '../types';
// without papering over a genuinely-expired token.
const CLOCK_TOLERANCE_SECONDS = 30;
// ── Compression tables ──────────────────────────────────────────────
// -- Compression tables ----------------------------------------------
//
// Token payloads are compressed on the wire: full field names become
// short aliases, enum values become single-letter codes, and UUIDs get
@@ -129,7 +129,7 @@ const AUTH_COMPRESSION = def({
app_uid: { short: 'au', ...uuidCompression('app-') },
// v2 unified session-row binding — present on every v2 token kind.
session_uid: { short: 'su', ...uuidCompression() },
// v2 stable per-user identity that survives re-login (PUT-1010).
// v2 stable per-user identity that survives re-login
auth_id: { short: 'ai', ...uuidCompression() },
});
@@ -150,6 +150,10 @@ const HOSTED_ASSET_COMPRESSION = def({
user_uid: { short: 'uu', ...uuidCompression() },
app_uid: { short: 'au', ...uuidCompression('app-') },
session_uuid: { short: 's', ...uuidCompression() },
// Mirrors the `auth_id` claim on `auth`-scope tokens — stable
// per-user identity that survives re-login. Lets a reauth flow
// re-mint an asset cookie tied to the same identity.
auth_id: { short: 'ai', ...uuidCompression() },
subdomain: 'sd',
host: 'h',
});
@@ -159,19 +163,22 @@ const COMPRESSION: Record<string, CompressionContext> = {
'hosted-asset': HOSTED_ASSET_COMPRESSION,
};
// ── TokenService ────────────────────────────────────────────────────
/**
* Signs and verifies JWTs.
*
* Two secrets coexist for the v1→v2 migration:
* - `jwt_secret_v2` signs every new token (`kid: 'v2'` header).
* - `jwt_secret` is verify-only for tokens minted before this rolled out;
* verified-legacy results carry `legacy: true` so AuthService can
* drive lazy-backfill + the re-auth migration flow (AUTH-4).
*
* `allow_v1_tokens=false` (ROLLOUT-1) hard-rejects v1 tokens at verify.
* Thrown by `verify()` when a v1 token is presented while
* `auth.allow_v1_tokens=false`. Carries the **unverified** payload so
* the auth probe can mint a `reauth_required` response with an
* `auth_id` hint — the hint is advisory only (never trusted as
* identity), so reading it from an unsigned payload is safe.
*/
export class V1TokensDisabledError extends Error {
constructor(public readonly payload: Record<string, unknown>) {
super('v1 tokens are disabled');
this.name = 'V1TokensDisabledError';
}
}
// -- TokenService ----------------------------------------------------
export class TokenService extends PuterService {
#secretV2: string = '';
#secretLegacy: string = '';
@@ -240,7 +247,20 @@ export class TokenService extends PuterService {
// Legacy / unsigned-kid path.
if (!this.#allowV1Tokens) {
throw new Error('v1 tokens are disabled');
// Surface a structured error so the auth probe can route to a
// `reauth_required` response (with an `auth_id` hint) instead
// of a bare 401 that strands the user. Decompress the
// *unverified* payload — the hint is advisory, never trusted
// as identity.
const rawPayload =
decoded &&
typeof decoded === 'object' &&
decoded.payload &&
typeof decoded.payload === 'object'
? (decoded.payload as Record<string, unknown>)
: {};
const hint = this.#decompressPayload(context, rawPayload);
throw new V1TokensDisabledError(hint);
}
if (!this.#secretLegacy) {
throw new Error(
@@ -260,7 +280,30 @@ export class TokenService extends PuterService {
return decompressed as unknown as T;
}
// ── Internals ───────────────────────────────────────────────────
/**
* Decode + decompress *without* verifying the signature. Returns
* `null` for malformed tokens. Use **only** for paths that need to
* recover advisory hints from an expired / unsignable token (e.g.,
* the logout path that wants to revoke a session row even if the
* JWT has expired since the user opened the tab). The result is
* never trusted as identity — only as a pointer for cleanup
* operations the caller would otherwise authorize via a different
* channel.
*/
decodeWithoutVerify<T = Record<string, unknown>>(
scope: string,
token: string,
): T | null {
const context = COMPRESSION[scope];
const decoded = jwt.decode(token);
if (!decoded || typeof decoded !== 'object') return null;
return this.#decompressPayload(
context,
decoded as Record<string, unknown>,
) as unknown as T;
}
// -- Internals ---------------------------------------------------
#compressPayload(
context: CompressionContext | undefined,
+3 -5
View File
@@ -21,7 +21,7 @@
// — auth-related fields (`actor`, `token`) are declared there alongside the
// other request-level fields populated by global middleware.
// ── Token payload shapes (after `TokenService.verify` decompression) ──
// -- Token payload shapes (after `TokenService.verify` decompression) --
/**
* Base fields every auth token carries.
@@ -36,7 +36,7 @@ interface TokenPayloadBase {
type: TokenType;
/** v2: unified session-row binding (uuid of the `sessions` row). */
session_uid?: string;
/** v2: stable per-user identity that survives re-login (PUT-1010). */
/** v2: stable per-user identity that survives re-login */
auth_id?: string;
/** Set by TokenService when the token verified via the legacy secret. */
legacy?: boolean;
@@ -94,7 +94,7 @@ export type AnyTokenPayload =
| AppUnderUserTokenPayload
| AccessTokenPayload;
// ── Session row (from `sessions` table) ────────────────────────────
// -- Session row (from `sessions` table) ----------------------------
export interface SessionRow {
id: number;
@@ -103,12 +103,10 @@ export interface SessionRow {
meta?: Record<string, unknown> | string | null;
created_at?: number | null;
last_activity?: number | null;
/** PUT-1013: 'web' | 'app' | 'access_token' | 'asset'. */
kind?: string | null;
parent_session_id?: string | null;
revoked_at?: number | null;
expires_at?: number | null;
/** PUT-1014: composite-key columns. */
app_uid?: string | null;
legacy_token_uid?: string | null;
created_via?: string | null;
@@ -23,7 +23,7 @@ import { Agent as HttpsAgent } from 'node:https';
import { IBroadcastPeerConfig } from '../../types.js';
import { PuterService } from '../types.js';
// ── Wire types ──────────────────────────────────────────────────────
// -- Wire types ------------------------------------------------------
interface BroadcastEvent {
key: string;
@@ -55,7 +55,7 @@ interface IncomingHeaders {
signature: string | undefined;
}
// ── Service ─────────────────────────────────────────────────────────
// -- Service ---------------------------------------------------------
/**
* Cross-node event replication via signed HTTP webhooks.
@@ -104,7 +104,7 @@ export class BroadcastService extends PuterService {
/** Self-signed certs are common between Puter nodes — accept them. */
#webhookHttpsAgent = new HttpsAgent({ rejectUnauthorized: false });
// ── Lifecycle ───────────────────────────────────────────────────
// -- Lifecycle ---------------------------------------------------
override onServerStart(): void {
this.#loadConfig();
@@ -125,7 +125,7 @@ export class BroadcastService extends PuterService {
}
}
// ── Public API used by BroadcastController ──────────────────────
// -- Public API used by BroadcastController ----------------------
/**
* Verify an incoming webhook POST and, if valid, fan its events
@@ -231,7 +231,7 @@ export class BroadcastService extends PuterService {
return { ok: true };
}
// ── Outbound: subscribe + queue + flush ────────────────────────
// -- Outbound: subscribe + queue + flush ------------------------
#subscribeOutbound(): void {
// Wildcard: every `outer.*` event gets considered for broadcast.
@@ -368,7 +368,7 @@ export class BroadcastService extends PuterService {
}
}
// ── Inbound helpers ─────────────────────────────────────────────
// -- Inbound helpers ---------------------------------------------
#normalizeIncomingPayload(
payload: IncomingPayload,
@@ -511,7 +511,7 @@ export class BroadcastService extends PuterService {
return { ok: true, nonce: n };
}
// ── Misc ────────────────────────────────────────────────────────
// -- Misc --------------------------------------------------------
#normalizeMeta(meta: unknown): Record<string, unknown> {
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {};
+6 -6
View File
@@ -136,7 +136,7 @@ export class FSService extends PuterService {
const permissions = this.services.permission;
const fsEntryStore = this.stores.fsEntry;
// ── fs:/path:mode → fs:<uuid>:mode ─────────────────────────────
// -- fs:/path:mode → fs:<uuid>:mode -----------------------------
// Clients (puter.perms, requestPermission) emit path-based strings;
// stored as-is they'd never match anything, so resolve to uuid up
// front.
@@ -169,7 +169,7 @@ export class FSService extends PuterService {
},
});
// ── is-owner ──────────────────────────────────────────────────
// -- is-owner --------------------------------------------------
// For user actors, `fs:<uuid>:*` resolves iff the actor owns the
// underlying entry. Without this, `check(user, fs:UUID:*)` can't
// find a terminal and the `has_terminal` probe that #scanUserApp
@@ -206,7 +206,7 @@ export class FSService extends PuterService {
},
});
// ── fs-access-levels exploder ──────────────────────────────────
// -- fs-access-levels exploder ----------------------------------
// `fs:UUID:see` implies `[list, read, write, manage:fs:UUID]`.
// ACLService.check already expands the same-family chain
// (see→list→read→write) via MODES_ABOVE, but the `manage:fs:UUID`
@@ -2551,7 +2551,7 @@ export class FSService extends PuterService {
return this.stores.fsEntry.getUserStorageAllowance(numericUserId);
}
// ── Reads ───────────────────────────────────────────────────────────
// -- Reads -----------------------------------------------------------
/**
* List direct children of a directory. Caller is responsible for any ACL
@@ -2708,7 +2708,7 @@ export class FSService extends PuterService {
return entry.uuid;
}
// ── Mutation: mkdir / touch / rename / mkshortcut ─────────
// -- Mutation: mkdir / touch / rename / mkshortcut ---------
/**
* Resolve a free child name under `parentEntry` by appending ` (N)` when
@@ -2988,7 +2988,7 @@ export class FSService extends PuterService {
return created;
}
// ── Mutation: remove / move / copy ─────────────────────────────────
// -- Mutation: remove / move / copy ---------------------------------
/**
* Remove an entry. For directories, descendants are walked and removed
@@ -147,7 +147,7 @@ export class PuterHomepageService extends PuterService {
res.send(html);
}
// ── Internals ────────────────────────────────────────────────────
// -- Internals ----------------------------------------------------
async #renderShell(
ctx: { req: Request; actor: Actor | null },
@@ -36,7 +36,7 @@ import { toMicroCents } from './utils';
import { SUB_POLICIES } from '../../data/subPolicies/index.js';
// ── Types ────────────────────────────────────────────────────────────
// -- Types ------------------------------------------------------------
type SubscriptionPolicy = (typeof SUB_POLICIES)[number];
@@ -50,7 +50,7 @@ interface UsageInput {
costOverride?: number;
}
// ── MeteringService ──────────────────────────────────────────────────
// -- MeteringService --------------------------------------------------
/**
* Tracks per-actor and global usage, and exposes subscription/addon lookup.
@@ -70,7 +70,7 @@ export class MeteringService extends PuterService {
private subscriptionResolvers: SubscriptionResolver[] = [];
private defaultSubscriptionResolvers: SubscriptionResolver[] = [];
// ── Lifecycle ────────────────────────────────────────────────────
// -- Lifecycle ----------------------------------------------------
override onServerStart(): void {
this.rateCheckTimer = setInterval(
@@ -91,7 +91,7 @@ export class MeteringService extends PuterService {
}
}
// ── Extension hooks ──────────────────────────────────────────────
// -- Extension hooks ----------------------------------------------
/** Register a policy that should be available to actors. */
registerPolicy(policy: SubscriptionPolicy): void {
@@ -114,7 +114,7 @@ export class MeteringService extends PuterService {
this.defaultSubscriptionResolvers.push(fn);
}
// ── Public API: increment usage ──────────────────────────────────
// -- Public API: increment usage ----------------------------------
utilRecordUsageObject<T extends Record<string, number>>(
trackedUsageObject: T,
@@ -458,7 +458,7 @@ export class MeteringService extends PuterService {
}
}
// ── Public API: read usage ───────────────────────────────────────
// -- Public API: read usage ---------------------------------------
async getActorCurrentMonthUsageDetails(actor: Actor): Promise<{
usage: UsageByType;
@@ -788,7 +788,7 @@ export class MeteringService extends PuterService {
});
}
// ── Internals ────────────────────────────────────────────────────
// -- Internals ----------------------------------------------------
private monthYearString(): string {
const now = new Date();
@@ -77,7 +77,7 @@ export class NotificationService extends PuterService {
);
}
// ── Public API ──────────────────────────────────────────────────
// -- Public API --------------------------------------------------
/**
* Push a notification to one or more users. The notification is
@@ -172,7 +172,7 @@ export class NotificationService extends PuterService {
);
}
// ── Internals ───────────────────────────────────────────────────
// -- Internals ---------------------------------------------------
async #sendUnreads(userId: number): Promise<void> {
// Fetch all unseen + unacknowledged notifications
@@ -44,7 +44,7 @@ import {
} from '../../data/hardcoded-permissions.js';
import { UserRow } from '../../stores/user/UserStore';
// ── Types ────────────────────────────────────────────────────────────
// -- Types ------------------------------------------------------------
export interface ScanOptions {
noCache?: boolean;
@@ -81,7 +81,7 @@ export class PermissionService extends PuterService {
Record<string, unknown>
> = {};
// ── Extension hooks ──────────────────────────────────────────────
// -- Extension hooks ----------------------------------------------
//
// Other services contribute domain semantics via these. A controller or
// driver *could* register too, but typically the owning service for a
@@ -131,7 +131,7 @@ export class PermissionService extends PuterService {
this.systemGrantsByGroupUid[groupUid][permission] = data;
}
// ── Rewrite / explode (pure-ish helpers) ────────────────────────
// -- Rewrite / explode (pure-ish helpers) ------------------------
async rewritePermission(permission: string): Promise<string> {
for (const rewriter of this.rewriters) {
@@ -167,7 +167,7 @@ export class PermissionService extends PuterService {
return parents;
}
// ── Public check / scan API ──────────────────────────────────────
// -- Public check / scan API --------------------------------------
async check(
actor: Actor,
@@ -213,7 +213,7 @@ export class PermissionService extends PuterService {
return out;
}
// ── Cache pass: one pipelined MGET ──
// -- Cache pass: one pipelined MGET --
const aUid = actorUid(actor);
const cached = await this.stores.permission.getMultiCheckCache(
aUid,
@@ -285,7 +285,7 @@ export class PermissionService extends PuterService {
const reading: ReadingNode[] = [];
const workingState: ScanState = state ?? { antiCycleActors: [actor] };
// ── Redis scan cache ──
// -- Redis scan cache --
const cacheKey = this.stores.permission.buildScanCacheKey(
actorUid(actor),
options,
@@ -297,7 +297,7 @@ export class PermissionService extends PuterService {
const startTs = Date.now();
// ── grant_if_system short-circuit ──
// -- grant_if_system short-circuit --
if (isSystemActor(actor)) {
reading.push({
$: 'option',
@@ -312,7 +312,7 @@ export class PermissionService extends PuterService {
return reading;
}
// ── rewrite ──
// -- rewrite --
for (let i = 0; i < options.length; i++) {
const old = options[i];
const rewritten = await this.rewritePermission(old);
@@ -321,7 +321,7 @@ export class PermissionService extends PuterService {
reading.push({ $: 'rewrite', from: old, to: rewritten });
}
// ── explode (parents + exploders) ──
// -- explode (parents + exploders) --
const exploded: string[][] = [];
for (let i = 0; i < options.length; i++) {
const perm = options[i];
@@ -333,7 +333,7 @@ export class PermissionService extends PuterService {
}
options = exploded.flat();
// ── shortcut implicators ──
// -- shortcut implicators --
let shortCircuit = false;
for (const permission of options) {
for (const implicator of this.implicators) {
@@ -358,7 +358,7 @@ export class PermissionService extends PuterService {
}
if (!shortCircuit) {
// ── scanners (formerly PERMISSION_SCANNERS) ──
// -- scanners (formerly PERMISSION_SCANNERS) --
// Run in parallel — matches v1's `Promise.all(ps)` in the
// scan-permission Sequence. Each scanner has a cheap actor-shape
// guard at the top (e.g. `if (!actor.app) return` for app-only
@@ -397,7 +397,7 @@ export class PermissionService extends PuterService {
}
}
// ── Scanners (inlined, no Sequence) ──────────────────────────────
// -- Scanners (inlined, no Sequence) ------------------------------
async #scanNonShortcutImplicators(
actor: Actor,
@@ -692,7 +692,7 @@ export class PermissionService extends PuterService {
});
}
// ── validateUserPerms (flat + linked reads) ──────────────────────
// -- validateUserPerms (flat + linked reads) ----------------------
/**
* Resolves user-to-user permissions for an actor across the given
@@ -832,7 +832,7 @@ export class PermissionService extends PuterService {
return out;
}
// ── Grant / revoke orchestration ─────────────────────────────────
// -- Grant / revoke orchestration ---------------------------------
async grantUserUserPermission(
actor: Actor,
@@ -1224,7 +1224,7 @@ export class PermissionService extends PuterService {
.catch(() => {});
}
// ── Issuer queries (share discovery et al) ───────────────────────
// -- Issuer queries (share discovery et al) -----------------------
async listUserPermissionIssuers(user: {
id: number;
@@ -1310,7 +1310,7 @@ export class PermissionService extends PuterService {
);
}
// ── Cache invalidation ───────────────────────────────────────────
// -- Cache invalidation -------------------------------------------
async invalidatePermissionScanCacheForAppUnderUser(
userUuid: string,
@@ -1324,7 +1324,7 @@ export class PermissionService extends PuterService {
await this.stores.permission.invalidateScanCache(cacheKey);
}
// ── Internals ────────────────────────────────────────────────────
// -- Internals ----------------------------------------------------
#userToActor(user: {
id: number;
@@ -163,7 +163,7 @@ export const readingHasTerminal = (reading: ReadingNode[]): boolean => {
return false;
};
// ── Rules ────────────────────────────────────────────────────────────
// -- Rules ------------------------------------------------------------
//
// Rewriters, Implicators, and Exploders are the extension points other
// services use to contribute domain semantics. These are plain objects —
@@ -0,0 +1,129 @@
/**
* 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 type { Actor } from '../../core/actor.js';
import type { AuthResult } from '../auth/AuthService.js';
import {
buildSocketReauthError,
decideSocketAuth,
type SocketReauthError,
} from './SocketService.js';
// ── buildSocketReauthError ──────────────────────────────────────────
describe('buildSocketReauthError', () => {
it('packs reason + auth_id into error.data matching the HTTP shape', () => {
const err = buildSocketReauthError({
reason: 'token_v1',
auth_id: 'u-1',
});
expect(err.message).toBe('reauth_required');
expect(err.data).toEqual({
code: 'reauth_required',
reason: 'token_v1',
auth_id: 'u-1',
});
});
it('omits auth_id when none was supplied', () => {
const err = buildSocketReauthError({ reason: 'session_expired' });
expect(err.data).toEqual({
code: 'reauth_required',
reason: 'session_expired',
});
expect(err.data.auth_id).toBeUndefined();
});
});
// ── decideSocketAuth ────────────────────────────────────────────────
describe('decideSocketAuth', () => {
const userActor: Actor = {
user: { id: 1, uuid: 'u-1', username: 'u' },
};
const appActor: Actor = {
user: { id: 1, uuid: 'u-1', username: 'u' },
app: { uid: 'app-1', id: 2 },
};
const accessTokenActor: Actor = {
user: { id: 1, uuid: 'u-1', username: 'u' },
accessToken: {
uid: 'tok-1',
issuer: { user: { id: 1, uuid: 'u-1', username: 'u' } },
authorized: null,
},
};
it('accepts a plain user actor', () => {
const decision = decideSocketAuth({ actor: userActor } as AuthResult);
expect(decision).toEqual({ accept: userActor });
});
it('rejects with a structured reauth error when result carries reauth', () => {
const decision = decideSocketAuth({
reauth: { reason: 'session_revoked', auth_id: 'u-1' },
} as AuthResult);
if (!('reject' in decision)) throw new Error('expected reject');
expect(decision.reject.message).toBe('reauth_required');
expect((decision.reject as SocketReauthError).data).toEqual({
code: 'reauth_required',
reason: 'session_revoked',
auth_id: 'u-1',
});
});
it('rejects an app-under-user actor with a specific message', () => {
const decision = decideSocketAuth({ actor: appActor } as AuthResult);
if (!('reject' in decision)) throw new Error('expected reject');
expect(decision.reject.message).toMatch(/only user tokens/);
// Plain Error — no structured `data` payload.
expect(
(decision.reject as { data?: unknown }).data,
).toBeUndefined();
});
it('rejects an access-token actor with a specific message', () => {
const decision = decideSocketAuth({
actor: accessTokenActor,
} as AuthResult);
if (!('reject' in decision)) throw new Error('expected reject');
expect(decision.reject.message).toMatch(/only user tokens/);
});
it('rejects when AuthService returned no actor at all', () => {
const decision = decideSocketAuth({ invalid: true } as AuthResult);
if (!('reject' in decision)) throw new Error('expected reject');
expect(decision.reject.message).toBe('socket auth failed');
});
it('reauth wins over a usable actor (legacy v1 path)', () => {
// Legacy v1 tokens may lazy-backfill a valid actor AND emit a
// reauth signal — the socket must still reject so the client
// migrates. Mirrors the HTTP gate's priority.
const decision = decideSocketAuth({
actor: userActor,
reauth: { reason: 'token_v1', auth_id: 'u-1' },
} as AuthResult);
if (!('reject' in decision)) throw new Error('expected reject');
expect((decision.reject as SocketReauthError).data.reason).toBe(
'token_v1',
);
});
});
+79 -16
View File
@@ -22,9 +22,58 @@ import type { Server as HttpServer } from 'node:http';
import { Server as SocketIOServer, type Socket } from 'socket.io';
import type { Actor } from '../../core/actor.js';
import { isAccessTokenActor, isAppActor } from '../../core/actor.js';
import type { AuthService } from '../auth/AuthService.js';
import type { AuthResult, AuthService } from '../auth/AuthService.js';
import { PuterService } from '../types.js';
export type SocketReauthError = Error & { data: Record<string, unknown> };
/**
* Build a `reauth_required` error for the socket auth middleware to
* pass to `next()`. Exported for unit testing.
*/
export const buildSocketReauthError = (reauth: {
reason: string;
auth_id?: string;
}): SocketReauthError => {
const err = new Error('reauth_required') as SocketReauthError;
err.data = {
code: 'reauth_required',
reason: reauth.reason,
...(reauth.auth_id ? { auth_id: reauth.auth_id } : {}),
};
return err;
};
/** Pure decision from an `AuthResult` to a socket-side accept/reject. */
export type SocketAuthDecision = { accept: Actor } | { reject: Error };
/**
* Map an `AuthService.authenticate()` result onto the socket-handshake
* verdict. Order matters:
*
* 1. `reauth` → structured `reauth_required` error so the client can
* drive the same migration / re-login flow it does for HTTP.
* 2. Missing actor → generic `socket auth failed`.
* 3. App-under-user / access-token actor → rejected with a specific
* message; sockets only accept plain user actors.
* 4. Otherwise → accept the actor.
*
* Pure / no side effects — the middleware logs the reauth event.
*/
export const decideSocketAuth = (result: AuthResult): SocketAuthDecision => {
if (result.reauth) {
return { reject: buildSocketReauthError(result.reauth) };
}
const actor = result.actor;
if (!actor || !actor.user) {
return { reject: new Error('socket auth failed') };
}
if (isAppActor(actor) || isAccessTokenActor(actor)) {
return { reject: new Error('socket auth: only user tokens accepted') };
}
return { accept: actor };
};
/**
* Socket push target. A `room` fans to every socket in that room; a
* `socket` targets one specific socket by id. Multiple specifiers may
@@ -35,7 +84,7 @@ export interface SocketSpecifier {
socket?: string;
}
// ── Redis key format for cross-node FS-cache invalidation ──────────
// -- Redis key format for cross-node FS-cache invalidation ----------
//
// puter-js (browser) polls `GET /cache/last-change-timestamp` and purges
// its in-memory FS cache when the server's timestamp is ≥ ~2s ahead of
@@ -99,7 +148,7 @@ interface AuthenticatedSocket extends Socket {
export class SocketService extends PuterService {
#io: SocketIOServer | null = null;
// ── Lifecycle ───────────────────────────────────────────────────
// -- Lifecycle ---------------------------------------------------
/**
* Called by `PuterServer` after the http server is created but
@@ -163,7 +212,7 @@ export class SocketService extends PuterService {
});
}
// ── Public API (used by other services / controllers) ──────────
// -- Public API (used by other services / controllers) ----------
/**
* Push an event to one or more specifiers. `room` targets every
@@ -228,7 +277,7 @@ export class SocketService extends PuterService {
}
}
// ── Auth + connection wiring ───────────────────────────────────
// -- Auth + connection wiring -----------------------------------
#installAuthMiddleware(): void {
if (!this.#io) return;
@@ -266,21 +315,35 @@ export class SocketService extends PuterService {
}
try {
const actor = await authService.authenticateFromToken(token);
if (!actor || !actor.user) {
next(new Error('socket auth failed'));
return;
const handshakeHeaders =
(socket.handshake.headers as
| Record<string, string | string[] | undefined>
| undefined) ?? {};
const uaHeader = handshakeHeaders['user-agent'];
const userAgent = Array.isArray(uaHeader)
? uaHeader[0]
: uaHeader;
const result = await authService.authenticate(token, {
ip: socket.handshake.address,
userAgent: userAgent ?? undefined,
});
if (result.reauth) {
console.info(
`[auth-v2] reauth reason=${result.reauth.reason} auth_id=${result.reauth.auth_id ?? '-'} (ws)`,
);
}
// Only user tokens accepted — no app-under-user, no access-token.
if (isAppActor(actor) || isAccessTokenActor(actor)) {
next(new Error('socket auth: only user tokens accepted'));
const decision = decideSocketAuth(result);
if ('reject' in decision) {
next(decision.reject);
return;
}
socket.actor = actor;
socket.actor = decision.accept;
// user.id is numeric in the DB; stringify for room name
// so adapter lookups key on a stable type.
socket.join(String(actor.user.id));
socket.join(String(decision.accept.user!.id));
next();
} catch (err) {
console.warn('[socket] auth error', err);
@@ -333,7 +396,7 @@ export class SocketService extends PuterService {
});
}
// ── Event bus → socket fan-out ──────────────────────────────────
// -- Event bus → socket fan-out ----------------------------------
#subscribeEventBus(): void {
// One wildcard subscriber covers every `outer.gui.*` mutation +
@@ -379,7 +442,7 @@ export class SocketService extends PuterService {
this.clients.event.emit(
`sent-to-user.${wireName}`,
{
user_id: userId,
user_id: userId as number,
response: data.response,
},
{},
+7 -7
View File
@@ -109,7 +109,7 @@ const READ_ONLY_COLUMNS = new Set([
export class AppStore extends PuterStore {
#appStatsInterval;
// ── Reads ────────────────────────────────────────────────────────
// -- Reads --------------------------------------------------------
async getByUid(uid) {
return this.#getByProperty('uid', uid);
@@ -304,7 +304,7 @@ export class AppStore extends PuterStore {
return apps;
}
// ── Writes ───────────────────────────────────────────────────────
// -- Writes -------------------------------------------------------
/**
* Create a new app row.
@@ -497,7 +497,7 @@ export class AppStore extends PuterStore {
return true;
}
// ── Filetype associations ────────────────────────────────────────
// -- Filetype associations ----------------------------------------
async getFiletypeAssociations(appId) {
const rows = await this.clients.db.read(
@@ -621,7 +621,7 @@ export class AppStore extends PuterStore {
await this.publishCacheKeys({ keys, broadcast: true });
}
// ── Cache invalidation ───────────────────────────────────────────
// -- Cache invalidation -------------------------------------------
async invalidate(app) {
const keys = this.#cacheKeysForApp(app);
@@ -651,7 +651,7 @@ export class AppStore extends PuterStore {
);
}
// ── Internals ────────────────────────────────────────────────────
// -- Internals ----------------------------------------------------
async #getByProperty(prop, value) {
if (value === undefined || value === null) return null;
@@ -997,7 +997,7 @@ export class AppStore extends PuterStore {
return this.#querySingleStats(appUid, timeRange, clickhouse);
}
// ── Stats internals ──────────────────────────────────────────────
// -- Stats internals ----------------------------------------------
async #queryStatsForUids(uids) {
const out = new Map();
@@ -1335,7 +1335,7 @@ export class AppStore extends PuterStore {
return 1 + Math.ceil((firstThursday - target) / 604800000);
}
// ── Refresh loop ─────────────────────────────────────────────────
// -- Refresh loop -------------------------------------------------
async #refreshAppStats() {
// Cross-instance lock: if another node refreshed within the window,
+3 -3
View File
@@ -2239,7 +2239,7 @@ export class FSEntryStore extends PuterStore {
return completedEntries;
}
// ── Non-file entry creation (dirs, shortcuts, symlinks, touch) ──────
// -- Non-file entry creation (dirs, shortcuts, symlinks, touch) ------
/**
* Create a single non-file entry: directory, shortcut, or symlink.
@@ -2389,7 +2389,7 @@ export class FSEntryStore extends PuterStore {
return entry;
}
// ── Listing / descendants / search ──────────────────────────────────
// -- Listing / descendants / search ----------------------------------
// Children of a directory (direct children only). Paginated + sortable.
async listChildren(
@@ -2538,7 +2538,7 @@ export class FSEntryStore extends PuterStore {
return entries;
}
// ── Mutation ───────────────────────────────────────────────────────
// -- Mutation -------------------------------------------------------
// Generic single-entry update. Only a narrow set of columns are patchable
// through this method; the caller provides the JS-shaped patch and we map.
+6 -6
View File
@@ -21,7 +21,7 @@ import { v4 as uuidv4 } from 'uuid';
import { PuterStore } from '../types';
import { HttpError } from '../../core/http/HttpError.js';
// ── Types ────────────────────────────────────────────────────────────
// -- Types ------------------------------------------------------------
export interface GroupRow {
id: number;
@@ -32,12 +32,12 @@ export interface GroupRow {
[k: string]: unknown;
}
// ── Constants ────────────────────────────────────────────────────────
// -- Constants --------------------------------------------------------
const CREATE_RATE_LIMIT_PER_HOUR = 20;
const PUBLIC_GROUPS_CACHE_TTL_SECONDS = 10 * 60;
// ── GroupStore ───────────────────────────────────────────────────────
// -- GroupStore -------------------------------------------------------
/**
* Persistence layer for persistent user groups.
@@ -60,7 +60,7 @@ export class GroupStore extends PuterStore {
this.redisNamespace = uuidv4();
}
// ── Reads ────────────────────────────────────────────────────────
// -- Reads --------------------------------------------------------
async getByUid(uid: string): Promise<GroupRow | null> {
const rows = await this.clients.db.read(
@@ -142,7 +142,7 @@ export class GroupStore extends PuterStore {
return rows.map((r) => String(r.username));
}
// ── Writes ───────────────────────────────────────────────────────
// -- Writes -------------------------------------------------------
/**
* Creates a new group owned by `ownerUserId`. Enforces a 20/hour per-owner
@@ -210,7 +210,7 @@ export class GroupStore extends PuterStore {
);
}
// ── Internals ────────────────────────────────────────────────────
// -- Internals ----------------------------------------------------
#publicGroupsCacheKey(): string {
return `${this.redisNamespace}:group:public-groups`;
@@ -26,7 +26,7 @@ const UNACK_CACHE_KEY_PREFIX = 'notifications:unack';
const UNACK_CACHE_TTL_SECONDS = 5 * 60;
export class NotificationStore extends PuterStore {
// ── Reads ────────────────────────────────────────────────────────
// -- Reads --------------------------------------------------------
async getByUid(uid, { userId } = {}) {
const where =
@@ -90,7 +90,7 @@ export class NotificationStore extends PuterStore {
return count;
}
// ── Writes ───────────────────────────────────────────────────────
// -- Writes -------------------------------------------------------
async create({ userId, value }) {
if (!userId) throw new Error('create: userId is required');
@@ -135,7 +135,7 @@ export class NotificationStore extends PuterStore {
return changed;
}
// ── Internals ────────────────────────────────────────────────────
// -- Internals ----------------------------------------------------
#unackCacheKey(userId) {
return `${UNACK_CACHE_KEY_PREFIX}:${userId}`;
+2 -2
View File
@@ -27,7 +27,7 @@ import { HttpError } from '../../core/http/HttpError.js';
* UNIQUE(provider, provider_sub).
*/
export class OIDCStore extends PuterStore {
// ── Reads ────────────────────────────────────────────────────────
// -- Reads --------------------------------------------------------
async getByProviderSub(provider, providerSub) {
const rows = await this.clients.db.read(
@@ -44,7 +44,7 @@ export class OIDCStore extends PuterStore {
);
}
// ── Writes ───────────────────────────────────────────────────────
// -- Writes -------------------------------------------------------
async link(userId, provider, providerSub, refreshToken = null) {
try {
@@ -37,7 +37,7 @@ const TOKEN_CACHE_TTL_SECONDS = 10 * 60;
// The canonical definition lives in `UserStore`, which owns the user table.
export type { UserRow };
// ── Types ────────────────────────────────────────────────────────────
// -- Types ------------------------------------------------------------
export interface FlatPermValue {
permission?: string;
@@ -94,7 +94,7 @@ export interface AuditEntry {
export class PermissionStore extends PuterStore {
declare protected stores: LayerInstances<typeof puterStores>;
// ── Flat view (KV under system namespace) ────────────────────────
// -- Flat view (KV under system namespace) ------------------------
/**
* Read the flat user-to-user permissions for a holder across a set of
@@ -151,7 +151,7 @@ export class PermissionStore extends PuterStore {
await this.stores.kv.del({ key });
}
// ── SQL: user-to-user permissions ───────────────────────────────
// -- SQL: user-to-user permissions -------------------------------
async readLinkedUserUserPerms(
holderUserId: number,
@@ -234,7 +234,7 @@ export class PermissionStore extends PuterStore {
return rows.map((r) => Number(r.issuer_user_id));
}
// ── SQL: user-to-app permissions ────────────────────────────────
// -- SQL: user-to-app permissions --------------------------------
async readUserAppPerms(
userId: number,
@@ -330,7 +330,7 @@ export class PermissionStore extends PuterStore {
);
}
// ── SQL: dev-to-app permissions ─────────────────────────────────
// -- SQL: dev-to-app permissions ---------------------------------
async readDevAppPerms(
appId: number,
@@ -412,7 +412,7 @@ export class PermissionStore extends PuterStore {
);
}
// ── SQL: user-to-group permissions ──────────────────────────────
// -- SQL: user-to-group permissions ------------------------------
/**
* Reads group permissions granted to groups the user is a member of, for
@@ -495,7 +495,7 @@ export class PermissionStore extends PuterStore {
);
}
// ── SQL: access token permissions ───────────────────────────────
// -- SQL: access token permissions -------------------------------
async hasAccessTokenPerm(
tokenUid: string,
@@ -512,7 +512,7 @@ export class PermissionStore extends PuterStore {
});
}
// ── SQL: issuer-prefix queries (share discovery, etc.) ──────────
// -- SQL: issuer-prefix queries (share discovery, etc.) ----------
async queryIssuerUserPermsByPrefix(
issuerUserId: number,
@@ -557,7 +557,7 @@ export class PermissionStore extends PuterStore {
return rows.map((r) => String(r.permission));
}
// ── Scan cache (redis) ──────────────────────────────────────────
// -- Scan cache (redis) ------------------------------------------
buildScanCacheKey(actorUid: string, permissionOptions: string[]): string {
return PermissionUtil.join(
@@ -595,7 +595,7 @@ export class PermissionStore extends PuterStore {
await this.publishCacheKeys({ keys: [cacheKey] });
}
// ── Per-permission check cache (for `checkMany`) ─────────────────
// -- Per-permission check cache (for `checkMany`) -----------------
//
// Cached as `1`/`0` per (actor, permission) pair so a batch lookup
// reduces to a single MGET. Same TTL as the scan cache — the
@@ -656,7 +656,7 @@ export class PermissionStore extends PuterStore {
}
}
// ── Internals ───────────────────────────────────────────────────
// -- Internals ---------------------------------------------------
#u2uCacheKey(holderUserId: number): string {
return `perms:u2u:holder:${holderUserId}`;
+396 -70
View File
@@ -45,8 +45,29 @@ const TOUCH_THROTTLE_MAX_ENTRIES = 10000;
// session never expires. `access_token` rows are *not* slid — their
// `expires_at` is hard-set at mint to the caller-specified value.
// Exported so AuthService can use the same values when seeding new rows.
export const WEB_WINDOW_SECONDS = 30 * 24 * 60 * 60; // 30 days
export const APP_WINDOW_SECONDS = 90 * 24 * 60 * 60; // 90 days
export const WEB_WINDOW_SECONDS = 365 * 24 * 60 * 60; // 1y
export const APP_WINDOW_SECONDS = 365 * 24 * 60 * 60; // 1y
export const WORKER_WINDOW_SECONDS = 99 * 365 * 24 * 60 * 60; // 99y (virtually infinite);
// Duplicate-key error codes used by the `getOrCreate*` paths to detect
// "another caller won the partial-unique-index race" — the only error
// category they're prepared to silently no-op through. CHECK / NOT NULL /
// FK / type violations must bubble up; otherwise the caller caches a
// row that was never inserted.
// better-sqlite3 surfaces SqliteError.code; mysql2 surfaces .code and
// .errno (1062 = ER_DUP_ENTRY).
function isUniqueViolation(err) {
if (!err) return false;
const code = err.code;
if (
code === 'SQLITE_CONSTRAINT_UNIQUE' ||
code === 'SQLITE_CONSTRAINT_PRIMARYKEY' ||
code === 'ER_DUP_ENTRY'
) {
return true;
}
return err.errno === 1062;
}
export const ASSET_WINDOW_SECONDS = 7 * 24 * 60 * 60; // 7 days
const sqlTimestamp = (ms) =>
@@ -69,26 +90,27 @@ export class SessionStore extends PuterStore {
* expired row in cache doesn't grant access.
*/
async getByUuid(uuid) {
const row = await this.getByUuidAny(uuid);
if (!row) return null;
if (row.revoked_at != null) return null;
if (isExpired(row, nowSeconds())) return null;
return row;
}
async getByUuidAny(uuid) {
if (!uuid) return null;
const now = nowSeconds();
const cached = await this.#readCache(uuid);
if (cached) {
if (cached.revoked_at != null) return null;
if (isExpired(cached, now)) return null;
return cached;
}
if (cached) return cached;
const rows = await this.clients.db.read(
'SELECT * FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) LIMIT 1',
[uuid, now],
'SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1',
[uuid],
);
const normalized = this.#normalizeRow(rows[0]);
if (!normalized) return null;
this.#writeCache(normalized).catch(() => {
// Best-effort backfill — local only.
});
this.#writeCache(normalized).catch(() => {});
return normalized;
}
@@ -123,6 +145,10 @@ export class SessionStore extends PuterStore {
* index.
* @param opts.legacy_token_uid - v1 token_uid this row backfills.
* Only set for `created_via='legacy_backfill'`.
* @param opts.access_token_uid - For `kind='access_token'` v2 rows: the
* `token_uid` claim that lives in `access_token_permissions`. Lets
* raw-uuid revoke reverse-find the session row when no JWT was
* presented.
* @param opts.created_via - Audit sentinel (e.g. 'legacy_backfill').
* @param opts.auth_id - Stable per-user identity (survives re-login);
* carried on every v2 JWT so manage-sessions can group by identity.
@@ -134,10 +160,10 @@ export class SessionStore extends PuterStore {
/**
* Shared INSERT implementation for `create()` and the idempotent
* `getOrCreate*` paths. `ignoreConflict: true` switches to engine-
* specific INSERT-IGNORE so partial-unique-index collisions silently
* no-op rather than throw — the idempotent callers handle the "row
* already existed" path via a re-SELECT.
* `getOrCreate*` paths. With `ignoreConflict: true`, only a duplicate-
* key error from the partial unique indexes is swallowed (concurrent
* caller won the race); every other failure — CHECK, NOT NULL, FK,
* type — throws. The caller then re-SELECTs to find the winning row.
*/
async #insertSession(
userId,
@@ -151,6 +177,7 @@ export class SessionStore extends PuterStore {
expires_at = null,
app_uid = null,
legacy_token_uid = null,
access_token_uid = null,
created_via = null,
auth_id = null,
} = {},
@@ -162,33 +189,45 @@ export class SessionStore extends PuterStore {
meta.created = new Date().toISOString();
meta.created_unix = now;
const insertVerb = ignoreConflict
? this.clients.db.case({
sqlite: 'INSERT OR IGNORE INTO',
otherwise: 'INSERT IGNORE INTO',
})
: 'INSERT INTO';
await this.clients.db.write(
`${insertVerb} \`sessions\` (\`uuid\`, \`user_id\`, \`meta\`, \`last_activity\`, \`created_at\`, \`kind\`, \`label\`, \`parent_session_id\`, \`last_ip\`, \`last_user_agent\`, \`expires_at\`, \`app_uid\`, \`legacy_token_uid\`, \`created_via\`, \`auth_id\`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
uuid,
userId,
JSON.stringify(meta),
now,
now,
kind,
label,
parent_session_id,
last_ip,
last_user_agent,
expires_at,
app_uid,
legacy_token_uid,
created_via,
auth_id,
],
);
// Always issue a plain INSERT. The `getOrCreate*` paths set
// ignoreConflict=true so the partial-unique-index race against a
// concurrent caller can no-op, but only that specific error class
// is swallowed — every other failure (CHECK, NOT NULL, FK, type)
// bubbles up. Engine-specific INSERT-IGNORE swallowed all
// constraint violations, which masked schema bugs as "row didn't
// appear in the DB but the caller cached a synthetic row anyway".
try {
await this.clients.db.write(
`INSERT INTO \`sessions\` (\`uuid\`, \`user_id\`, \`meta\`, \`last_activity\`, \`created_at\`, \`kind\`, \`label\`, \`parent_session_id\`, \`last_ip\`, \`last_user_agent\`, \`expires_at\`, \`app_uid\`, \`legacy_token_uid\`, \`access_token_uid\`, \`created_via\`, \`auth_id\`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
uuid,
userId,
JSON.stringify(meta),
now,
now,
kind,
label,
parent_session_id,
last_ip,
last_user_agent,
expires_at,
app_uid,
legacy_token_uid,
access_token_uid,
created_via,
auth_id,
],
);
} catch (err) {
if (!ignoreConflict || !isUniqueViolation(err)) {
throw err;
}
// Concurrent caller won the partial-unique-index race. The
// caller's re-SELECT will return that winning row; the local
// `row` object below is a placeholder for the caller's
// `winner ?? created` fallback shape and is never cached on
// the ignoreConflict path.
}
const row = {
uuid,
@@ -205,6 +244,7 @@ export class SessionStore extends PuterStore {
expires_at,
app_uid,
legacy_token_uid,
access_token_uid,
created_via,
auth_id,
};
@@ -221,6 +261,39 @@ export class SessionStore extends PuterStore {
return row;
}
/**
* Rename a session's label. Ownership is enforced via the user_id
* filter — a label edit by user A can't touch a row owned by user B
* even if A guesses B's session uuid.
*
* Returns `true` when a row was updated, `false` when no row matched
* the (uuid, user_id) pair (either the uuid doesn't exist, the row
* belongs to another user, or it's already soft-revoked).
*/
async setLabel(uuid, userId, label) {
if (!uuid || !userId) return false;
const result = await this.clients.db.write(
'UPDATE `sessions` SET `label` = ? WHERE `uuid` = ? AND `user_id` = ? AND `revoked_at` IS NULL',
[label, uuid, userId],
);
const affected = result?.affectedRows ?? 0;
if (affected > 0) {
// Invalidate every cached view onto the row so the next read
// (manage-sessions reload, /whoami, etc.) sees the new label.
const rows = await this.clients.db.read(
'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid`, `meta`, `created_via`, `last_ip`, `last_user_agent` FROM `sessions` WHERE `uuid` = ? LIMIT 1',
[uuid],
);
if (rows[0]) {
await this.publishCacheKeys({
keys: this.#allCacheKeysForRow(rows[0]),
broadcast: true,
});
}
}
return affected > 0;
}
/**
* Soft-revoke a session by uuid. The row remains in the table
* with `revoked_at` set; subsequent `getByUuid` calls treat it
@@ -235,21 +308,36 @@ export class SessionStore extends PuterStore {
// SELECT first so we know which composite cache keys point at
// this row. A single UPDATE ... RETURNING would be cleaner but
// isn't portable between sqlite/mysql.
// `meta` included so #allCacheKeysForRow can read
// meta.worker_name for the worker cache key; without it the
// composite worker cache entry would survive revocation and
// getOrCreateWorker would serve the stale (revoked) row for
// up to CACHE_TTL_SECONDS. `created_via` + `last_ip` +
// `last_user_agent` ride along for the symmetric legacy-web
// key derivation.
const rows = await this.clients.db.read(
'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid` FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1',
'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid`, `meta`, `created_via`, `last_ip`, `last_user_agent` FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1',
[uuid],
);
if (rows.length === 0) return;
// Double-delete pattern: invalidate the cache BEFORE the SQL
// UPDATE, then again after. The pre-DEL drops any cached
// active-row view so a concurrent reader between the DEL and the
// UPDATE goes to the DB (seeing the still-active row is fine —
// that's the truth at that instant). The post-DEL clears any
// entry a racer might have re-cached during the window. Pays
// one extra pipelined DEL per revoke; revokes are rare so the
// cost is negligible.
const keys = this.#allCacheKeysForRow(rows[0]);
await this.publishCacheKeys({ keys, broadcast: true });
const now = nowSeconds();
await this.clients.db.write(
'UPDATE `sessions` SET `revoked_at` = ? WHERE `uuid` = ? AND `revoked_at` IS NULL',
[now, uuid],
);
await this.publishCacheKeys({
keys: this.#allCacheKeysForRow(rows[0]),
broadcast: true,
});
await this.publishCacheKeys({ keys, broadcast: true });
}
/**
@@ -261,26 +349,47 @@ export class SessionStore extends PuterStore {
if (!rootUuid) return;
// Read each affected row's identity columns up-front — every
// composite cache mapping (app, legacy-token) must be invalidated
// alongside the uuid key, otherwise a follow-up `getOrCreateApp`
// would short-circuit to the freshly-revoked row.
// composite cache mapping (app, legacy-token, legacy-web) must
// be invalidated alongside the uuid key, otherwise a follow-up
// `getOrCreateApp` / `findOrCreateLegacyWeb` would short-circuit
// to the freshly-revoked row.
const rows = await this.clients.db.read(
'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL',
'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid`, `meta`, `created_via`, `last_ip`, `last_user_agent` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL',
[rootUuid, rootUuid],
);
if (rows.length === 0) return;
// Double-delete: see `removeByUuid` for rationale.
const keys = [];
for (const r of rows) keys.push(...this.#allCacheKeysForRow(r));
await this.publishCacheKeys({ keys, broadcast: true });
const now = nowSeconds();
await this.clients.db.write(
'UPDATE `sessions` SET `revoked_at` = ? WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL',
[now, rootUuid, rootUuid],
);
const keys = [];
for (const r of rows) keys.push(...this.#allCacheKeysForRow(r));
await this.publishCacheKeys({ keys, broadcast: true });
}
/**
* Active session row whose access-token identity matches `tokenUid`.
* Covers both v2 (`access_token_uid` set at mint) and v1 lazy-backfill
* (`legacy_token_uid` set on first verify) rows so raw-uuid revoke can
* find the row regardless of whether the token was originally v1 or v2.
* Returns `null` if no active row matches.
*/
async findActiveByAccessTokenUid(tokenUid) {
if (!tokenUid) return null;
const now = nowSeconds();
const rows = await this.clients.db.read(
"SELECT * FROM `sessions` WHERE `kind` = 'access_token' AND (`access_token_uid` = ? OR `legacy_token_uid` = ?) AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) ORDER BY `id` DESC LIMIT 1",
[tokenUid, tokenUid, now],
);
return this.#normalizeRow(rows[0]);
}
/**
* Idempotent "give me the app session for this (user, app)" lookup.
* Returns the existing active app session if one exists, or creates
@@ -298,7 +407,8 @@ export class SessionStore extends PuterStore {
* @param appUid - App UID (string).
* @param opts.last_ip / opts.last_user_agent - Request context for
* first-time creation. Ignored when a row already exists.
* @param opts.auth_id - Stable per-user identity (PUT-1010).
* @param opts.auth_id - Stable per-user identity (survives re-login);
* carried on every v2 JWT so manage-sessions can group by identity.
*/
async getOrCreateApp(userId, appUid, opts = {}) {
if (!userId || !appUid) return null;
@@ -344,6 +454,79 @@ export class SessionStore extends PuterStore {
return row;
}
/**
* Idempotent "give me the worker session for this (user, app,
* worker_name)" lookup. Same shape as `getOrCreateApp` but keyed on
* a triple so multiple workers can sit under the same app (each
* with its own `worker_name`) and each gets its own session row.
*
* `appUid` is allowed null for user-scoped workers — the partial
* unique index treats those distinctly (SQLite via NULL-distinct
* semantics, MySQL via IFNULL in the generated key).
*
* @param userId - User row id (numeric).
* @param opts.appUid - App UID or null for user-scoped workers.
* @param opts.workerName - Per-worker discriminator. Required.
* @param opts.meta - Additional metadata merged into the row's
* `meta` blob alongside the canonical `worker: true` and
* `worker_name` markers.
* @param opts.last_ip / opts.last_user_agent - Request context for
* first-time creation. Ignored when a row already exists.
* @param opts.auth_id - Stable per-user identity (survives re-login).
*/
async getOrCreateWorker(userId, opts = {}) {
if (!userId || !opts.workerName) return null;
const appUid = opts.appUid ?? null;
const workerName = String(opts.workerName);
const cacheKey = this.#cacheKeyWorker(userId, appUid, workerName);
const now = nowSeconds();
const cached = await this.#readCacheKey(cacheKey);
if (cached && cached.revoked_at == null && !isExpired(cached, now)) {
return cached;
}
const existing = await this.#selectWorkerRow(
userId,
appUid,
workerName,
);
if (existing) {
await this.#writeCacheKey(cacheKey, existing);
this.#writeCache(existing).catch(() => {});
return existing;
}
const created = await this.#insertSession(
userId,
{
kind: 'worker',
app_uid: appUid,
parent_session_id: null,
last_ip: opts.last_ip ?? null,
last_user_agent: opts.last_user_agent ?? null,
expires_at: now + WORKER_WINDOW_SECONDS,
auth_id: opts.auth_id ?? null,
meta: {
...(opts.meta ?? {}),
worker: true,
worker_name: workerName,
},
},
{ ignoreConflict: true },
);
// INSERT-IGNORE may have lost the race against another caller;
// re-SELECT under the partial unique index to find whichever
// row actually won.
const winner = await this.#selectWorkerRow(userId, appUid, workerName);
const row = winner ?? created;
await this.#writeCacheKey(cacheKey, row);
this.#writeCache(row).catch(() => {});
return row;
}
/**
* Idempotent "give me the lazy-backfill row for this v1 token_uid"
* lookup. Mirrors `getOrCreateApp` but keys on `legacy_token_uid`.
@@ -390,9 +573,17 @@ export class SessionStore extends PuterStore {
/**
* Best-effort lazy-backfill row for a v1 web session. The keying tuple
* is `(user_id, last_ip, last_user_agent)` — a UA/IP shift on a roaming
* client produces a fresh row, which is the spec's accepted trade-off.
* No partial unique index here; collisions are tolerated.
* is `(user_id, last_ip, last_user_agent)` — a UA/IP shift on a
* roaming client produces a fresh row, which is the spec's accepted
* trade-off.
*
* No partial unique index exists for this tuple (a UA string is too
* variable to index), so concurrent racers can both INSERT. We
* resolve via an optimistic-lock pass: after our INSERT we re-SELECT
* the oldest matching row; if we lost the race, soft-revoke our own
* row and return the winner so every caller converges on a single
* `session_uuid`. Cheap (one extra SELECT per legacy-backfill mint,
* which only runs on the first contact from a stale v1 client).
*/
async findOrCreateLegacyWeb(opts = {}) {
if (!opts.userId) return null;
@@ -407,10 +598,13 @@ export class SessionStore extends PuterStore {
return cached;
}
const rows = await this.clients.db.read(
"SELECT * FROM `sessions` WHERE `kind` = 'web' AND `user_id` = ? AND `created_via` = 'legacy_backfill' AND IFNULL(`last_ip`, '') = IFNULL(?, '') AND IFNULL(`last_user_agent`, '') = IFNULL(?, '') AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) ORDER BY `id` ASC LIMIT 1",
[opts.userId, ip, ua, now],
);
const selectOldest = () =>
this.clients.db.read(
"SELECT * FROM `sessions` WHERE `kind` = 'web' AND `user_id` = ? AND `created_via` = 'legacy_backfill' AND IFNULL(`last_ip`, '') = IFNULL(?, '') AND IFNULL(`last_user_agent`, '') = IFNULL(?, '') AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) ORDER BY `id` ASC LIMIT 1",
[opts.userId, ip, ua, now],
);
const rows = await selectOldest();
const existing = this.#normalizeRow(rows[0]);
if (existing) {
await this.#writeCacheKey(cacheKey, existing);
@@ -426,6 +620,21 @@ export class SessionStore extends PuterStore {
created_via: 'legacy_backfill',
auth_id: opts.auth_id ?? null,
});
// Optimistic conflict resolution: re-SELECT the oldest row that
// matches the same tuple. If a concurrent racer beat us to the
// INSERT, fold to their row and revoke ours so the (rare) pair
// doesn't both linger for a year. `removeByUuid` is a no-op when
// the row was already revoked by a third party.
const winnerRows = await selectOldest();
const winner = this.#normalizeRow(winnerRows[0]);
if (winner && winner.uuid !== created.uuid) {
await this.removeByUuid(created.uuid);
await this.#writeCacheKey(cacheKey, winner);
this.#writeCache(winner).catch(() => {});
return winner;
}
await this.#writeCacheKey(cacheKey, created);
// uuid cache already warmed by create()
return created;
@@ -438,8 +647,19 @@ export class SessionStore extends PuterStore {
* their existing `expires_at` (hard expiry). The `last_activity < ?`
* guard makes the UPDATE idempotent across nodes so concurrent touches
* don't fight.
*
* When `ip` / `userAgent` are provided and differ from the stored
* values, they are written into `last_ip` / `last_user_agent` in the
* same UPDATE — and the uuid cache is invalidated so the next read
* doesn't serve the pre-roam values. Unchanged values are no-ops at
* the SQL level (the CASE guards keep the column write conditional)
* and skip the cache invalidate.
*/
async updateActivity(uuid, lastActivity) {
async updateActivity(
uuid,
lastActivity,
{ ip = null, userAgent = null } = {},
) {
const webExpires = lastActivity + WEB_WINDOW_SECONDS;
const appExpires = lastActivity + APP_WINDOW_SECONDS;
const assetExpires = lastActivity + ASSET_WINDOW_SECONDS;
@@ -449,17 +669,45 @@ export class SessionStore extends PuterStore {
"WHEN 'app' THEN ? " +
"WHEN 'asset' THEN ? " +
'ELSE `expires_at` ' +
'END ' +
'END, ' +
'`last_ip` = CASE WHEN ? IS NOT NULL AND (`last_ip` IS NULL OR `last_ip` <> ?) THEN ? ELSE `last_ip` END, ' +
'`last_user_agent` = CASE WHEN ? IS NOT NULL AND (`last_user_agent` IS NULL OR `last_user_agent` <> ?) THEN ? ELSE `last_user_agent` END ' +
'WHERE `uuid` = ? AND (`last_activity` IS NULL OR `last_activity` < ?)',
[
lastActivity,
webExpires,
appExpires,
assetExpires,
ip,
ip,
ip,
userAgent,
userAgent,
userAgent,
uuid,
lastActivity,
],
);
// When IP or UA actually changed, the cached row at
// `sessions:v2:uuid:<uuid>` is now stale (it carries the old
// `last_ip` / `last_user_agent`). Manage-sessions reads off the
// cached row, so without this invalidate the UI keeps showing the
// pre-roam values until the 15-minute TTL expires.
if (ip != null || userAgent != null) {
const cached = await this.#readCache(uuid);
if (cached) {
const ipChanged = ip != null && cached.last_ip !== ip;
const uaChanged =
userAgent != null && cached.last_user_agent !== userAgent;
if (ipChanged || uaChanged) {
await this.publishCacheKeys({
keys: [this.#cacheKey(uuid)],
broadcast: true,
});
}
}
}
}
/** Update user-level last activity timestamp. */
@@ -475,9 +723,16 @@ export class SessionStore extends PuterStore {
* `last_activity` column and the owning user's `user.last_activity_ts`
* if either hasn't been touched within `TOUCH_THROTTLE_MS`.
*
* When `ip` / `userAgent` are passed, they ride along into
* `updateActivity` so a roaming session also refreshes its
* `last_ip` / `last_user_agent`. Throttle still applies — the IP/UA
* fields only get a chance to update once per `TOUCH_THROTTLE_MS`.
*
* Callers fire-and-forget — failures are swallowed.
*
* @param {{uuid?: string, userId?: number, ip?: string|null, userAgent?: string|null}} [args]
*/
async touch({ uuid, userId } = {}) {
async touch({ uuid, userId, ip = null, userAgent = null } = {}) {
const nowMs = Date.now();
const sessionDue =
@@ -509,9 +764,10 @@ export class SessionStore extends PuterStore {
const tasks = [];
if (sessionDue) {
tasks.push(
this.updateActivity(uuid, Math.floor(nowMs / 1000)).catch(
() => {},
),
this.updateActivity(uuid, Math.floor(nowMs / 1000), {
ip,
userAgent,
}).catch(() => {}),
);
}
if (userDue) {
@@ -524,7 +780,7 @@ export class SessionStore extends PuterStore {
await Promise.all(tasks);
}
// ── Internals ───────────────────────────────────────────────────
// -- Internals ---------------------------------------------------
#cacheKey(uuid) {
return `${CACHE_KEY_PREFIX}:uuid:${uuid}`;
@@ -538,6 +794,15 @@ export class SessionStore extends PuterStore {
return `${CACHE_KEY_PREFIX}:legacy-at:${tokenUid}`;
}
/**
* Cache key for the (user, app, worker_name) worker-session triple.
* `app_uid` is encoded as empty-string for user-scoped workers so
* the namespace doesn't fracture on NULL.
*/
#cacheKeyWorker(userId, appUid, workerName) {
return `${CACHE_KEY_PREFIX}:worker:${userId}:${appUid ?? ''}:${workerName}`;
}
/**
* Cache key for the (legacy-web) backfill lookup. IP and UA are
* percent-encoded into a single key segment so a UA containing `:`
@@ -559,6 +824,42 @@ export class SessionStore extends PuterStore {
if (row.kind === 'app' && row.user_id && row.app_uid) {
keys.push(this.#cacheKeyApp(row.user_id, row.app_uid));
}
if (row.kind === 'worker' && row.user_id) {
const meta =
typeof row.meta === 'string'
? (() => {
try {
return JSON.parse(row.meta);
} catch {
return null;
}
})()
: row.meta;
const workerName = meta?.worker_name;
if (typeof workerName === 'string' && workerName) {
keys.push(
this.#cacheKeyWorker(
row.user_id,
row.app_uid ?? null,
workerName,
),
);
}
}
// Legacy-web backfill rows are cached by (user_id, last_ip,
// last_user_agent) inside `findOrCreateLegacyWeb`. Without this
// branch, revoke would clear the uuid key but leave the composite
// key serving the stale revoked row for up to CACHE_TTL_SECONDS,
// letting a same-IP/UA replay re-authenticate.
if (row.kind === 'web' && row.created_via === 'legacy_backfill') {
keys.push(
this.#cacheKeyLegacyWeb(
row.user_id,
row.last_ip ?? null,
row.last_user_agent ?? null,
),
);
}
if (row.legacy_token_uid) {
keys.push(this.#cacheKeyLegacyAt(row.legacy_token_uid));
}
@@ -611,6 +912,31 @@ export class SessionStore extends PuterStore {
return this.#normalizeRow(rows[0]);
}
/**
* Active worker session for (userId, appUid, workerName). Matches
* the partial unique index `idx_sessions_user_worker_active`.
* `appUid` is allowed null for user-scoped workers; IFNULL keeps
* the comparison correct since SQL `= NULL` doesn't match.
*
* MySQL's `JSON_EXTRACT` returns a JSON-typed value with embedded
* quoting (`"name"` rather than `name`), which never equals a
* plain string bind. Use `JSON_UNQUOTE(JSON_EXTRACT(...))` on
* MySQL to strip that. SQLite's `json_extract` already returns the
* unwrapped scalar so the literal form works there.
*/
async #selectWorkerRow(userId, appUid, workerName) {
const now = nowSeconds();
const workerNameExpr = this.clients.db.case({
sqlite: "json_extract(`meta`, '$.worker_name')",
otherwise: "JSON_UNQUOTE(JSON_EXTRACT(`meta`, '$.worker_name'))",
});
const rows = await this.clients.db.read(
`SELECT * FROM \`sessions\` WHERE \`kind\` = 'worker' AND \`user_id\` = ? AND IFNULL(\`app_uid\`, '') = IFNULL(?, '') AND ${workerNameExpr} = ? AND \`revoked_at\` IS NULL AND (\`expires_at\` IS NULL OR \`expires_at\` > ?) LIMIT 1`,
[userId, appUid ?? null, workerName, now],
);
return this.#normalizeRow(rows[0]);
}
async #selectLegacyAccessTokenRow(tokenUid) {
const now = nowSeconds();
const rows = await this.clients.db.read(
+352 -10
View File
@@ -21,6 +21,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { setupTestServer } from '../../testUtil.ts';
import { PuterServer } from '../../server.ts';
import {
APP_WINDOW_SECONDS,
WEB_WINDOW_SECONDS,
WORKER_WINDOW_SECONDS,
} from './SessionStore.js';
describe('SessionStore', () => {
let server: PuterServer;
@@ -131,9 +136,9 @@ describe('SessionStore', () => {
});
it('returns null when expires_at is in the past', async () => {
// PUT-1014 moved expires_at enforcement into getByUuid so the
// row is the single source of truth — no AUTH-4 re-mint pass
// needed (we run long-lived JWTs in v2).
// expires_at enforcement lives in getByUuid so the row is
// the single source of truth — no re-mint pass needed (we
// run long-lived JWTs in v2).
const user = await makeUser();
const past = Math.floor(Date.now() / 1000) - 60;
const session = await target.create(user.id, { expires_at: past });
@@ -268,7 +273,7 @@ describe('SessionStore', () => {
});
});
// ── PUT-1014 composite-key lookups ──────────────────────────────
// ── Composite-key lookups ──────────────────────────────────────
describe('getOrCreateApp', () => {
it('creates a kind="app" row on first call with the right shape', async () => {
@@ -282,11 +287,13 @@ describe('SessionStore', () => {
expect(row.app_uid).toBe(appUid);
expect(row.parent_session_id).toBeNull();
expect(row.user_id).toBe(user.id);
// Sliding window seeded — 90 days for app.
// Sliding window seeded for app — bound matches the live
// APP_WINDOW_SECONDS constant so a future bump to the window
// doesn't silently fail this assertion.
const now = Math.floor(Date.now() / 1000);
expect(row.expires_at).toBeGreaterThan(now);
expect(row.expires_at).toBeLessThanOrEqual(
now + 90 * 24 * 60 * 60 + 5,
now + APP_WINDOW_SECONDS + 5,
);
});
@@ -353,6 +360,228 @@ describe('SessionStore', () => {
});
});
describe('getOrCreateWorker', () => {
it('creates a kind="worker" row tagged meta.worker / meta.worker_name', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const row = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
auth_id: user.uuid,
});
expect(row).toBeTruthy();
expect(row.kind).toBe('worker');
expect(row.app_uid).toBe(appUid);
expect(row.user_id).toBe(user.id);
expect(row.parent_session_id).toBeNull();
expect(row.meta.worker).toBe(true);
expect(row.meta.worker_name).toBe(workerName);
// Sliding window seeded for worker — bound matches the live
// WORKER_WINDOW_SECONDS constant so a future bump to the window
// doesn't silently fail this assertion.
const now = Math.floor(Date.now() / 1000);
expect(row.expires_at).toBeGreaterThan(now);
expect(row.expires_at).toBeLessThanOrEqual(
now + WORKER_WINDOW_SECONDS + 5,
);
const raw = await rawRow(row.uuid);
expect(raw.kind).toBe('worker');
expect(raw.app_uid).toBe(appUid);
});
it('is idempotent on (user, app, worker_name)', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const a = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
const b = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
expect(a.uuid).toBe(b.uuid);
});
it('is idempotent on (user, worker_name) when app_uid is null (user-scoped)', async () => {
// The partial unique index uses IFNULL(app_uid, '') so two
// user-scoped (app_uid=null) workers with the same worker_name
// still dedupe. Without IFNULL, SQLite would treat the NULLs as
// distinct per the SQL standard and let duplicates through.
const user = await makeUser();
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const a = await target.getOrCreateWorker(user.id, {
appUid: null,
workerName,
});
const b = await target.getOrCreateWorker(user.id, {
appUid: null,
workerName,
});
expect(a.uuid).toBe(b.uuid);
expect(a.app_uid).toBeNull();
});
it('mints distinct rows for different worker_names under the same (user, app)', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const a = await target.getOrCreateWorker(user.id, {
appUid,
workerName: `wk-${Math.random().toString(36).slice(2, 8)}-a`,
});
const b = await target.getOrCreateWorker(user.id, {
appUid,
workerName: `wk-${Math.random().toString(36).slice(2, 8)}-b`,
});
expect(a.uuid).not.toBe(b.uuid);
});
it('does NOT collide with an interactive kind="app" row for the same (user, app)', async () => {
// Worker rows are intentionally carved out of the
// idx_sessions_user_app_active index (which is WHERE kind='app').
// An app session and a worker session for the same (user, app)
// must coexist.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const appRow = await target.getOrCreateApp(user.id, appUid);
const workerRow = await target.getOrCreateWorker(user.id, {
appUid,
workerName: `wk-${Math.random().toString(36).slice(2, 8)}`,
});
expect(appRow.uuid).not.toBe(workerRow.uuid);
expect(appRow.kind).toBe('app');
expect(workerRow.kind).toBe('worker');
});
it('converges to a single row under concurrent racers', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const results = await Promise.all(
Array.from({ length: 10 }, () =>
target.getOrCreateWorker(user.id, { appUid, workerName }),
),
);
const uuids = new Set(results.map((r: { uuid: string }) => r.uuid));
expect(uuids.size).toBe(1);
// And only one row ever made it into the table.
const rows = await server.clients.db.read(
"SELECT `uuid` FROM `sessions` WHERE `user_id` = ? AND `kind` = 'worker' AND `app_uid` = ?",
[user.id, appUid],
);
expect(rows).toHaveLength(1);
});
it('returns null when called with falsy inputs', async () => {
expect(
await target.getOrCreateWorker(null, { workerName: 'wk' }),
).toBeNull();
expect(await target.getOrCreateWorker(1, {})).toBeNull();
expect(
await target.getOrCreateWorker(1, { workerName: '' }),
).toBeNull();
});
it('mints a new row after the previous one was revoked', async () => {
// After revoke, the partial-unique index has no active row
// for (user_id, app_uid, worker_name), so the next call mints
// a fresh uuid instead of being short-circuited by the
// composite cache or by re-SELECTing the revoked row.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const first = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
await target.removeByUuid(first.uuid);
const second = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
expect(second.uuid).not.toBe(first.uuid);
expect(second.kind).toBe('worker');
});
it('revokeCascade invalidates the worker composite cache', async () => {
// Mirrors the app cascade-invalidation test. First call primes
// the worker composite cache; cascading revoke must drop it so
// the next call doesn't serve the revoked row.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const first = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
await target.revokeCascade(first.uuid);
const second = await target.getOrCreateWorker(user.id, {
appUid,
workerName,
});
expect(second.uuid).not.toBe(first.uuid);
});
});
describe('error propagation (no silent swallow)', () => {
// INSERT-IGNORE used to mask every constraint violation, not just
// the partial-unique-index conflict the `getOrCreate*` paths rely
// on for idempotency. These tests pin the post-fix behavior: real
// schema errors throw, the unique-key conflict path still no-ops.
it('create() with an unsupported kind throws (CHECK constraint surfaces)', async () => {
// The `sessions.kind` column carries a CHECK constraint
// restricting it to the known set. A bogus kind must throw
// rather than be silently coerced or swallowed.
const user = await makeUser();
await expect(
target.create(user.id, { kind: 'not-a-real-kind' }),
).rejects.toThrow();
});
it('create() accepts kind="worker" (regression: post-migration the CHECK allows it)', async () => {
// Sanity check that migration 0056 actually relaxed the CHECK.
// Pre-migration this threw `CHECK constraint failed`.
const user = await makeUser();
const session = await target.create(user.id, {
kind: 'worker',
meta: { worker: true, worker_name: 'direct' },
});
expect(session.kind).toBe('worker');
});
it('create() with a duplicate (user_id, app_uid) on kind="app" throws (UNIQUE surfaces)', async () => {
// `create()` runs with ignoreConflict=false, so the partial
// unique index `idx_sessions_user_app_active` fires loudly.
// This is the path that pre-fix `INSERT IGNORE` was silently
// collapsing — verify it now throws as expected.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
await target.create(user.id, { kind: 'app', app_uid: appUid });
await expect(
target.create(user.id, { kind: 'app', app_uid: appUid }),
).rejects.toThrow();
});
it('getOrCreateApp swallows the partial-unique conflict (idempotent get-or-create)', async () => {
// Counterpart to the previous test. The same INSERT path
// routed through getOrCreateApp (ignoreConflict=true) must
// still no-op the unique-key conflict and return the existing
// row instead of throwing.
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
const first = await target.getOrCreateApp(user.id, appUid);
await expect(
target.getOrCreateApp(user.id, appUid),
).resolves.toMatchObject({ uuid: first.uuid });
});
});
describe('touch slides expires_at per kind', () => {
it('extends expires_at on web sessions', async () => {
const user = await makeUser();
@@ -370,13 +599,15 @@ describe('SessionStore', () => {
await target.updateActivity(session.uuid, now);
const row = await rawRow(session.uuid);
// After slide, expires_at = now + 30d (within a tolerance
// for between-statement wall-clock drift).
// After slide, expires_at = now + WEB_WINDOW_SECONDS (within
// a tolerance for between-statement wall-clock drift). Use
// the constant directly so a future window bump doesn't
// need a parallel edit here.
expect(row.expires_at).toBeGreaterThanOrEqual(
now + 30 * 24 * 60 * 60 - 5,
now + WEB_WINDOW_SECONDS - 5,
);
expect(row.expires_at).toBeLessThanOrEqual(
now + 30 * 24 * 60 * 60 + 5,
now + WEB_WINDOW_SECONDS + 5,
);
});
@@ -396,6 +627,117 @@ describe('SessionStore', () => {
});
});
describe('updateActivity refreshes last_ip / last_user_agent', () => {
// Backdate so the SQL `last_activity < ?` guard fires deterministically.
const backdate = async (uuid: string) => {
const ancient = Math.floor(Date.now() / 1000) - 3600;
await server.clients.db.write(
'UPDATE `sessions` SET `last_activity` = ? WHERE `uuid` = ?',
[ancient, uuid],
);
};
it('writes new ip and user-agent when changed', async () => {
const user = await makeUser();
const session = await target.create(user.id, {
kind: 'web',
last_ip: '1.1.1.1',
last_user_agent: 'old-agent',
});
await backdate(session.uuid);
await target.updateActivity(
session.uuid,
Math.floor(Date.now() / 1000),
{ ip: '2.2.2.2', userAgent: 'new-agent' },
);
const row = await rawRow(session.uuid);
expect(row.last_ip).toBe('2.2.2.2');
expect(row.last_user_agent).toBe('new-agent');
});
it('leaves existing ip / ua untouched when args are null', async () => {
const user = await makeUser();
const session = await target.create(user.id, {
kind: 'web',
last_ip: '3.3.3.3',
last_user_agent: 'keep-me',
});
await backdate(session.uuid);
await target.updateActivity(
session.uuid,
Math.floor(Date.now() / 1000),
{},
);
const row = await rawRow(session.uuid);
expect(row.last_ip).toBe('3.3.3.3');
expect(row.last_user_agent).toBe('keep-me');
});
it('does not overwrite when values are unchanged', async () => {
const user = await makeUser();
const session = await target.create(user.id, {
kind: 'web',
last_ip: '4.4.4.4',
last_user_agent: 'same-agent',
});
await backdate(session.uuid);
await target.updateActivity(
session.uuid,
Math.floor(Date.now() / 1000),
{ ip: '4.4.4.4', userAgent: 'same-agent' },
);
const row = await rawRow(session.uuid);
expect(row.last_ip).toBe('4.4.4.4');
expect(row.last_user_agent).toBe('same-agent');
});
});
describe('setLabel', () => {
it('renames the label for the owning user', async () => {
const user = await makeUser();
const session = await target.create(user.id, { label: 'old' });
const ok = await target.setLabel(session.uuid, user.id, 'new');
expect(ok).toBe(true);
const row = await rawRow(session.uuid);
expect(row.label).toBe('new');
});
it('returns false when the uuid belongs to another user', async () => {
const owner = await makeUser();
const interloper = await makeUser();
const session = await target.create(owner.id, { label: 'mine' });
const ok = await target.setLabel(
session.uuid,
interloper.id,
'pwned',
);
expect(ok).toBe(false);
const row = await rawRow(session.uuid);
expect(row.label).toBe('mine');
});
it('returns false when the row is soft-revoked', async () => {
const user = await makeUser();
const session = await target.create(user.id, { label: 'live' });
await target.removeByUuid(session.uuid);
const ok = await target.setLabel(session.uuid, user.id, 'after');
expect(ok).toBe(false);
const row = await rawRow(session.uuid);
expect(row.label).toBe('live');
});
it('returns false on falsy uuid / userId', async () => {
expect(await target.setLabel('', 1, 'x')).toBe(false);
expect(await target.setLabel('some-uuid', 0, 'x')).toBe(false);
});
});
describe('revokeCascade invalidates composite caches', () => {
it('a revoked app row is not re-served by getOrCreateApp cache hit', async () => {
// First create primes the app composite cache. Revoke via
+3 -3
View File
@@ -31,7 +31,7 @@ import { PuterStore } from '../types';
* row is deleted.
*/
export class ShareStore extends PuterStore {
// ── Reads ────────────────────────────────────────────────────────
// -- Reads --------------------------------------------------------
async getByUid(uid) {
const rows = await this.clients.db.read(
@@ -57,7 +57,7 @@ export class ShareStore extends PuterStore {
return rows.map((r) => this.#normalizeRow(r));
}
// ── Writes ───────────────────────────────────────────────────────
// -- Writes -------------------------------------------------------
async create({ issuerUserId, recipientEmail, data }) {
if (!issuerUserId || !recipientEmail) {
@@ -91,7 +91,7 @@ export class ShareStore extends PuterStore {
return (result?.affectedRows ?? result?.changes ?? 0) > 0;
}
// ── Internals ────────────────────────────────────────────────────
// -- Internals ----------------------------------------------------
#normalizeRow(row) {
if (!row) return null;
@@ -60,7 +60,7 @@ const NEGATIVE_CACHE_MARKER = '__none__';
const NEGATIVE_CACHE_TTL_SECONDS = 10;
export class SubdomainStore extends PuterStore {
// ── Reads ────────────────────────────────────────────────────────
// -- Reads --------------------------------------------------------
async getByUuid(uuid, { userId, primary = false } = {}) {
const where =
@@ -180,7 +180,7 @@ export class SubdomainStore extends PuterStore {
return rows;
}
// ── Writes ───────────────────────────────────────────────────────
// -- Writes -------------------------------------------------------
/** @param {{ userId: number, subdomain: string, rootDirId?: number|null, associatedAppId?: number|null, appOwner?: number|null, preambleVersion?: string|null }} opts */
async create({
@@ -311,7 +311,7 @@ export class SubdomainStore extends PuterStore {
}
}
// ── Internals ────────────────────────────────────────────────────
// -- Internals ----------------------------------------------------
#cacheKey(subdomain) {
return `${CACHE_KEY_PREFIX}:name:${subdomain}`;
+5 -5
View File
@@ -27,7 +27,7 @@ import {
import { PUTER_KV_STORE_TABLE_DEFINITION } from './tableDefinition';
import { HttpError } from '../../core/http';
// ── Types ────────────────────────────────────────────────────────────
// -- Types ------------------------------------------------------------
/** DynamoDB consumed-capacity units split by operation kind. */
export interface KVUsage {
@@ -53,7 +53,7 @@ export interface RecursiveRecord<T> {
[k: string]: T | RecursiveRecord<T>;
}
// ── Helpers ──────────────────────────────────────────────────────────
// -- Helpers ----------------------------------------------------------
const GLOBAL_APP_KEY = 'os-global';
const SYSTEM_NAMESPACE = `v1:${SYSTEM_ACTOR_UUID}:${GLOBAL_APP_KEY}`;
@@ -184,7 +184,7 @@ const objectsEqual = (left: unknown, right: unknown): boolean => {
const cleanAttrName = (chunk: string): string =>
`#${chunk.replaceAll(PATH_CLEANER_REGEX, '')}`;
// ── SystemKVStore ────────────────────────────────────────────────────
// -- SystemKVStore ----------------------------------------------------
/**
* Underlying key-value store. Housed at the store layer so both services
@@ -217,7 +217,7 @@ export class SystemKVStore extends PuterStore {
await this.initialized;
}
// ── Public API ───────────────────────────────────────────────────
// -- Public API ---------------------------------------------------
async get(
{ key }: { key: string | string[] },
@@ -823,7 +823,7 @@ export class SystemKVStore extends PuterStore {
return { res: response.Attributes?.value, usage };
}
// ── Internals ────────────────────────────────────────────────────
// -- Internals ----------------------------------------------------
private async getBatches(
namespace: string,
+6 -6
View File
@@ -19,7 +19,7 @@
import { PuterStore } from '../types';
// ── Types ────────────────────────────────────────────────────────────
// -- Types ------------------------------------------------------------
/**
* Canonical user row. Typed fields cover everything auth/acl/quota code
@@ -55,7 +55,7 @@ export interface UserRow {
export const USER_ID_PROPERTIES = ['id', 'uuid', 'username', 'email'] as const;
export type UserIdProperty = (typeof USER_ID_PROPERTIES)[number];
// ── Constants ────────────────────────────────────────────────────────
// -- Constants --------------------------------------------------------
const CACHE_KEY_PREFIX = 'users';
const CACHE_TTL_SECONDS = 15 * 60;
@@ -99,7 +99,7 @@ const assertLatin1Writable = (fields: Record<string, unknown>): void => {
}
};
// ── UserStore ────────────────────────────────────────────────────────
// -- UserStore --------------------------------------------------------
/**
* Persistence + cache for the `user` table. Provides a multi-key Redis cache
@@ -113,7 +113,7 @@ const assertLatin1Writable = (fields: Record<string, unknown>): void => {
* add the property to that tuple.
*/
export class UserStore extends PuterStore {
// ── Reads ────────────────────────────────────────────────────────
// -- Reads --------------------------------------------------------
async getById(
id: number,
@@ -284,7 +284,7 @@ export class UserStore extends PuterStore {
return user;
}
// ── Writes ───────────────────────────────────────────────────────
// -- Writes -------------------------------------------------------
/**
* Merge-update a user's `metadata` JSON blob. Reads current value,
@@ -449,7 +449,7 @@ export class UserStore extends PuterStore {
if (cached) await this.invalidate(cached);
}
// ── Internals ────────────────────────────────────────────────────
// -- Internals ----------------------------------------------------
#cacheKey(prop: UserIdProperty, value: unknown): string {
return `${CACHE_KEY_PREFIX}:${prop}:${String(value)}`;
+29 -13
View File
@@ -395,7 +395,7 @@ export interface IDevWatcherConfig {
* need to migrate.
*/
interface IConfigOptional {
// ── Environment / identity ──────────────────────────────────────
// -- Environment / identity --------------------------------------
/** Environment marker. `dev` disables blocked-email checks, opens auto-browser, etc. */
env: 'dev' | 'prod';
@@ -406,7 +406,7 @@ interface IConfigOptional {
/** Stable identity for this server node. Enables pager alerts + graceful shutdown delay. */
serverId: string;
// ── Networking / URLs ───────────────────────────────────────────
// -- Networking / URLs -------------------------------------------
/** Protocol used for the externally-visible origin ('http' or 'https'). Default: 'http'. */
protocol: string;
@@ -476,12 +476,12 @@ interface IConfigOptional {
/** Optional dev-time frontend watcher overrides. */
devwatch: IDevWatcherConfig;
// ── Auth / session ──────────────────────────────────────────────
// -- Auth / session ----------------------------------------------
/**
* Legacy HMAC secret for v1 JWTs. New tokens are always signed with
* `jwt_secret_v2`; this value is verify-only and accepted as long as
* `allow_v1_tokens` is true (flipped off in ROLLOUT-1 to retire v1).
* `allow_v1_tokens` is true (flipped off to retire v1).
*/
jwt_secret: string;
/** HMAC secret used to sign and verify v2 auth JWTs (`kid: 'v2'`). */
@@ -491,6 +491,22 @@ interface IConfigOptional {
* Default true during the v1→v2 migration window.
*/
allow_v1_tokens: boolean;
/**
* When false, `POST /auth/migrate-token` returns 410 Gone for v1
* `app-under-user` tokens. App-token migration is retired ahead
* of access-token migration — keeping these on separate flags
* lets ops kill apps first and keep API-key migration on
* indefinitely. Default true.
*/
allow_v1_app_migration: boolean;
/**
* Optional explicit allowlist of `Origin` header values that may
* call `POST /auth/migrate-token` cross-origin. The main `origin`
* is always allowed. Used to thread the SDK migration call through
* app subdomains (e.g. `*.puter.site`) without opening the
* endpoint to arbitrary attacker pages.
*/
allow_migrate_token_origins?: string[];
/** HMAC secret for signed file URLs (/file, /writeFile, /sign). */
url_signature_secret: string;
/** Name of the session cookie the auth probe reads. */
@@ -506,7 +522,7 @@ interface IConfigOptional {
/** OIDC / OAuth2 providers (google + custom). */
oidc: IOIDCConfig;
// ── Groups / provisioning ───────────────────────────────────────
// -- Groups / provisioning ---------------------------------------
/** UID of the persistent group that non-temp users are enrolled in at signup. */
default_user_group: string;
@@ -515,7 +531,7 @@ interface IConfigOptional {
/** When true, ACL grants read/list/see on `/<user>/Public` to any actor. */
enable_public_folders: boolean;
// ── Storage / S3 ────────────────────────────────────────────────
// -- Storage / S3 ------------------------------------------------
/** S3 storage config (local fauxqs or remote). */
s3: IS3Config;
@@ -534,11 +550,11 @@ interface IConfigOptional {
/** Optional dedicated S3-compatible bucket used by the thumbnails extension. */
thumbnailStore: IThumbnailStoreConfig;
// ── Database ────────────────────────────────────────────────────
// -- Database ----------------------------------------------------
database: IDatabaseConfig;
// ── Clients / infra ─────────────────────────────────────────────
// -- Clients / infra ---------------------------------------------
dynamo: IDynamoConfig;
redis: IRedisConfig;
@@ -547,7 +563,7 @@ interface IConfigOptional {
clickhouse: IClickhouseConfig;
cf_file_cache: ICfFileCacheConfig;
// ── Rate limiting ───────────────────────────────────────────────
// -- Rate limiting -----------------------------------------------
rate_limit: {
/**
@@ -559,7 +575,7 @@ interface IConfigOptional {
backend?: 'memory' | 'redis' | 'kv';
};
// ── AI / integration providers ──────────────────────────────────
// -- AI / integration providers ----------------------------------
//
// All AI providers — chat, image, video, TTS, OCR, speech-to-text,
// speech-to-speech — are configured under `providers[<provider-id>]`.
@@ -569,7 +585,7 @@ interface IConfigOptional {
// shortcut.
providers: Record<string, IAIProviderConfig | undefined>;
// ── Cross-node / external integrations ──────────────────────────
// -- Cross-node / external integrations --------------------------
/** Cross-node event replication config. */
broadcast: IBroadcastConfig;
@@ -583,7 +599,7 @@ interface IConfigOptional {
secureCorsProxy: ISecureCorsProxyConfig;
/** Legacy Stripe billing extension. */
// ── GUI / static mounts ─────────────────────────────────────────
// -- GUI / static mounts -----------------------------------------
/** Absolute path to the GUI assets root. */
gui_assets_root: string;
@@ -613,7 +629,7 @@ interface IConfigOptional {
/** Path to the puter-js SDK root (serves `/sdk/*` and `/puter.js/v{1,2}`). */
puterjs_root: string;
// ── Extension-specific ──────────────────────────────────────────
// -- Extension-specific ------------------------------------------
/**
* Flat `{ flag_name: boolean }` bag of feature toggles. Non-boolean values
+2 -2
View File
@@ -1422,8 +1422,8 @@ const ipc_listener = async (event, handled) => {
'Authorization': `Bearer ${window.auth_token}`,
},
statusCode: {
401: function () {
window.logout();
401: function (xhr) {
window.handle401(xhr);
},
},
});
+19 -2
View File
@@ -125,6 +125,23 @@ async function UIDesktop (options) {
console.error('GUI Socket Error:', error);
});
// Pick up reauth_required signals delivered via the socket.io
// handshake. SocketService emits `Error { data: { code:
// 'reauth_required', reason, auth_id } }` for legacy/revoked/expired
// tokens. Without this branch the disconnect would look like a
// silent network failure to the user.
window.socket.on('connect_error', (err) => {
const signal = err?.data;
if ( signal?.code === 'reauth_required' ) {
window.handleReauthRequired({
reason: signal.reason,
auth_id: signal.auth_id,
});
} else {
console.error('GUI Socket connect_error:', err);
}
});
window.socket.on('connect', function () {
// console.log('GUI Socket: Connected', window.socket.id);
window.socket.emit('puter_is_actually_open');
@@ -1735,8 +1752,8 @@ async function UIDesktop (options) {
'Authorization': `Bearer ${window.auth_token}`,
},
statusCode: {
401: function () {
window.logout();
401: function (xhr) {
window.handle401(xhr);
},
},
});
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more