Compare commits

...
147 Commits
Author SHA1 Message Date
Filip Kujundžić 62bc8d6d6f Merge pull request #3955 from HeyPuter/fix/put-1992-deepseek-pricing 2026-09-28 21:22:39 +02:00
404oops 4700c2dc65 Fix DeepSeek routing and follow published peak pricing 2026-09-27 15:17:50 +02:00
Anuj Shrivastava e041fb6b9b Remove deprecated AI chat models flagged by live model check
OpenAI's o3-pro, Mistral's mistral-large-2512 and zai-glm-5-2, and
DeepSeek's deepseek-v4-flash / deepseek-v4-flash-vision-exp are no
longer served by their upstream APIs, so drop them from the hardcoded
provider model lists (closes HeyPuter/puter#3945).

DeepSeek's legacy chat/reasoner aliases and default model previously
pointed at deepseek-v4-flash; they now resolve to deepseek-v4-pro, the
remaining model in that catalog, so existing callers keep working.

Signed-off-by: Anuj Shrivastava <[email protected]>
Signed-off-by: Anuj Shrivastava <[email protected]>
2026-09-25 19:40:02 +05:30
Nariman Jelveh 5721bdeb1d Add link to AI Builder in README (#3946) 2026-09-24 21:00:04 -07:00
Daniel Salazar 132cae9f6a Bug bash fixes (PUT-1876, 1877, 1880, 1881, 1948, 1949, 1951) and KV event fan-out by plan (#3943)
* fix(kv): keep disableSharing entries out of other apps' events (PUT-1876)

Private kv keys and values are no longer delivered to cross-app subscriptions, including forwarded and durable deliveries.

* fix(fs): refuse usernames whose home path still holds another account's rows (PUT-1877)

Username claims (signup, save_account, change-username, OIDC, team provisioning) now also check for leftover rows under /<name>/, and the root listing only returns the actor's own home.

* fix(puter-js): reconnect the events channel after a server-side disconnect (PUT-1881)

A server hang-up is retried with a bounded backoff; persistent handlers survive it and get an optional onError when the connection can't be restored.

* fix(backend): let the graceful shutdown finish before telemetry exits (PUT-1948)

The telemetry preload no longer exits on signals; the entry point drains, tears down top-down, flushes telemetry last, then exits. DB pools stay open until teardown.

* fix(fs): reserve quota for signed uploads in progress (PUT-1880)

startWrite/startBatchWrite hold each upload's declared size in a per-owner cache reservation until it completes, aborts or expires, so a burst of starts can't all read the same committed usage. Caps in-flight uploads at 10,000 per account.

* fix(fs): refuse to complete a signed upload whose object never arrived (PUT-1951)

Completion now 400s when the object store reports the key missing (transient errors stay lenient), abort no longer deletes an object a live entry still uses, and a ghost entry is only removed after a second miss at its recorded location.

* fix(metering): move usage to v2 keys with a small totals item, per-model detail shards and a 3-month TTL (PUT-1949)

The billed item holds only totals; per-model detail lives in 100 hash-sharded items read in one batch and cached for 60s, and more than 5,000 distinct usage types in a month fold into other. Global and per-app aggregates store totals only, appTotals comes from a prefix listing, a refused path only skips the key that refused it, exact reads near the allowance are throttled per key, and every metering record expires after 3 months. September usage restarts at deploy; the monthly-charge claim stays on the v1 key through September so recurring charges don't fire twice.

* test(events): let the worker backoff test tolerate a loaded run

* feat(events): scale KV event fan-out with the key owner's plan

A KV change delivers to up to 512 matching subscriptions (2,048 filter evaluations) for a paid key owner and 128 (512) otherwise, inline values are dropped once more than 128 match, and paid accounts may hold up to 512 share handles per app. The owner's plan is only looked up when a change has more candidates than any cap.

* test(metering): seed the 150-app listing test directly so it fits under coverage
2026-09-24 16:00:52 -07:00
Neal Shah 4a9e5a2440 Ns/worker no socket (#3944)
* remove socket creation from puter worker startup

* Remove really extra comment
2026-09-24 18:55:10 -04:00
Juan Fernando Castro 600064dcee Merge pull request #3942 from HeyPuter/juancastro/put-1944-teams-hardening
fix: Teams lifecycle robustness — owner deletion, cap serialization, provisioned-account scope (PUT-1944)

fixes requested by Daniel pushed
2026-09-24 16:51:43 -04:00
Juan Castro 66ee50317c style: restore prettier formatting displaced by the main merge 2026-09-24 16:35:09 -04:00
Juan Castro 8f0a317a97 Merge remote-tracking branch 'origin/juancastro/put-1944-teams-hardening' into juancastro/put-1944-teams-hardening 2026-09-24 16:33:40 -04:00
Juan Castro 8c816060a3 fix: answer a cap-lock failure as 409, like contention, per review 2026-09-24 16:33:12 -04:00
Juan Castro 0131ebfe9b Merge branch 'main' into juancastro/put-1944-teams-hardening 2026-09-24 12:20:12 -04:00
Juan Fernando Castro 2cb7516141 Merge pull request #3936 from HeyPuter/juancastro/put-1883-alternate-host-bootstrap-app-row-shadows-a-private-app-and
fix(hosting): keep alternate-host bootstrap stubs from shadowing private apps
2026-09-24 12:19:31 -04:00
Juan Castro 0c5c1be777 fix: Teams lifecycle robustness ahead of wider enablement
Closing an account that owns a team left `owner_user_id` NULL while
`deleted_at` stayed NULL, so the team remained live while every team
route resolves by membership — which the deletion cascades away. The team
could then not be administered by anyone, and the accounts it provisioned
could not be closed. Declined now in both directions: live teams via
`countOwned`, and soft-deleted ones still holding accounts via a new
store read, since a deleted team's accounts are suspended rather than
removed.

The cap lock gave up after 200ms and proceeded unserialized, so
overlapping provisioning could each read the seat count before any insert
committed. It declines instead; a single create never contends. An
unreachable lock backend is a 503 rather than a silent pass.

An account a team provisioned can no longer create a team of its own
(PUT-1890) — that is not the intended lifecycle, since such accounts do
not go through the normal sign-up path. Keyed on `org_owned = 1`, so the
owner and joined members are unaffected, and not via `teamsAvailableTo`,
which is permissive by design.

The directory declines scoped access tokens, as `listMembers` already
bounds non-account callers.
2026-09-24 11:06:00 -04:00
Juan Castro 8ecaa77a0e Merge remote-tracking branch 'origin/main' into juancastro/put-1883-alternate-host-bootstrap-app-row-shadows-a-private-app-and
# Conflicts:
#	src/backend/controllers/auth/AuthController.test.ts
2026-09-24 10:04:00 -04:00
Neal Shah 07a11240ea PUT-1868 (#3940) 2026-09-24 00:30:47 -04:00
Daniel Salazar 7758aea78c fix: acl check (#3939) 2026-09-23 19:03:16 -07:00
Rasad RegmiandDaniel Salazar 83de67cca9 fix(tools): spawn npm.cmd through a shell on Windows (#3923)
* fix(tools): spawn npm.cmd through a shell on Windows

* fix(tools): join the npm command line on Windows to avoid DEP0190

Passing args alongside shell: true warns on Node 24. Test the spawn
tuple as data and run a real npm spawn instead of mocking child_process.

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-09-23 18:54:43 -07:00
Echa ApriliyantoandDaniel Salazar 9f69483593 feat: allow browser extension origins in auth requests (#3907)
* feat: allow browser extension origins in auth requests

Add chrome-extension://, moz-extension://, safari-extension://, safari-web-extension://, and extension:// to the protocol allow-list so browser extensions can obtain app tokens via /auth/get-user-app-token.

Extract WEB_AND_EXTENSION_PROTOCOLS constant in validation.js so the allow-list is defined once and shared by AuthService, AppStore, and AppDriver.

* fix: harden the extension-origin allow-list

Review follow-ups on the extension-origin change:

- Require a host in `validateUrl`. Only "special" schemes need an
  authority, so `chrome-extension:` parsed with an empty hostname and
  slipped past the reserved-system-host guard in AppDriver.
- Lowercase the host in `AuthService#normalizedOrigin`. `new URL()`
  lowercases http(s) hosts but leaves opaque ones alone, so one
  extension in two spellings resolved to two app uids — two AppData
  trees, two permission sets — and missed the origin blocklist.
- Drop `extension:`. No browser emits it, and it accepted
  `extension://evil.com` as an app origin.
- Freeze the allow-list and derive `validateUrl`'s http(s) default from
  `WEB_PROTOCOLS` so the two spellings can't drift apart.
- Pin the tests to the real uid derivation, use unique extension ids so
  a row left by another test can't mask the bootstrap path, and cover
  the host-less and near-miss schemes.
- Fix the prettier/eslint failure in AppStore.js.

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-09-23 18:24:31 -07:00
Daniel Salazar 606f5a27a3 chore: CODEOWNERS (#3938) 2026-09-23 18:24:09 -07:00
Daniel Salazar 58ed2f485f feat(auth): signup bonus codes
Signup links can carry a bonusCode. Core checks its shape and previews it through `puter.signup-bonus.check`, refuses a dead code before the abuse hook records the attempt, then hands a live one to `puter.signup-bonus.validate` after `puter.signup.validate`, which may raise the phone/card gates; the accepted code rides on `puter.signup.success`, and OIDC carries it in the signed state. `user.card-verified` is now awaited like `user.phone-verified`. The signup form shows the offer and sends the code.
2026-09-23 14:47:34 -07:00
Daniel Salazar 4c3c684cb4 fix: refuse app-issued scoped tokens on events handler and worker routes (#3935)
* fix: new cache costs for claude

* fix: refuse app-issued scoped tokens on events handler and worker routes

An access token an app mints (e.g. a getReadURL() token) resolves
effectiveApp to the issuing app, so #handlerApp and listEventsWorkers
treated it as the app: it could publish, remove and list handlers, and
list or destroy the app's worker, regardless of its permission manifest.
Refuse any scoped token there, as the kv-handle routes already do.

* test: freeze the clock before the first backoff read in workerInvoker

The first hold was read on real time, so a slow run measured the 2s
backoff as 1s.
2026-09-23 13:58:49 -07:00
Juan Castro 438a4584de fix(hosting): keep alternate-host bootstrap stubs from shadowing private apps
A public origin-bootstrap app row planted on an alternate hosting domain
could resolve ahead of the owner's private app and skip the private
access gate (PUT-1883).

- get-user-app-token canonicalizes hosted-subdomain origins before uid
  derivation and bootstrap, so every hosting variant shares one app row
- the app create/update conflict check crosses hosting-domain variants,
  so an existing alt-host stub is absorbed by the owner's create
- resolveOwnedAppForHostedSite and the canonical-uid lookup order
  matches private-first with a deterministic id tiebreak
2026-09-23 16:39:22 -04:00
Daniel Salazar b3fda186e7 fix: let apps read opted-in teams, scope worker listing, fix revokeReadURL (#3933)
- GET /teams and /teams/:uid/members admit apps for teams whose owner opened
  the directory; apps get active members' usernames only
- events.workers.list() from an app returns only its own worker
- hide account-session-only SDK methods from the public types
- unpublish the updateProfile docs page (redirects to getProfile)
- revokeReadURL() works through a new /auth/revoke-own-access-token route
2026-09-23 12:06:56 -07:00
Filip KujundžićandClaude Opus 5.5 bc0f4dc279 feat(ai): add GPT-6 Sol and Luna and sync Claude pricing (#3931)
* feat(ai): sync GPT-6 Sol, GPT-6 Luna, and Claude Opus 5.5

* chore: remove documentation changes from model sync

* fix(ai): bill OpenAI cache writes and long-context pricing

GPT-5.6 and later bill prompt-cache writes at 1.25x input and report them
in `cache_write_tokens`, inside the input total. Both OpenAI calculators now
split them out of the prompt count and meter them under their own key, and
the six GPT-5.6+ models carry the rate.

GPT-6, GPT-5.6, GPT-5.5 and GPT-5.4 (incl. Pro) bill a request with more
than 272K input tokens at 2x input (cached reads and cache writes included)
and 1.5x output for the whole request. Models declare this as
`long_context_pricing`, and the ledger overrides, the reported `usd_cents`
and the credit gate all apply the multipliers.

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

* fix(ai): bill GPT-5.6 Sol at OpenAI's promotional pricing

The catalog carried GPT-5.5's $5/$0.50/$30, but OpenAI bills GPT-5.6 Sol
at $4/$0.40/$20 per million input/cached/output tokens, promotional
through at least 2026-11-21. PUT-1943 tracks re-checking before then.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-23 01:59:10 -07:00
Juan Fernando Castro 17f5454265 Merge pull request #3915 from HeyPuter/juancastro/put-1871-authcheck-app-reports-every-app-as-already-authorized
fix: check-app reported every app as already authorized
2026-09-22 18:24:20 -04:00
Filip Kujundžić 5ddff2b3d6 Merge pull request #3927 from HeyPuter/404oops/put-1870-ai-upstream-credit-exhaustion-is-the-largest-error-class-in
fix(ai): classify upstream credit exhaustion, contain the route, redact vendor text (PUT-1870)
2026-09-22 22:29:58 +02:00
404oops 52f0176854 fix(ai): classify and sanitize upstream credit exhaustion 2026-09-22 21:51:44 +02:00
Daniel Salazar 300fb115a1 feat: opus 5.5 (#3930)
* feat: opus 5.5

* fix: bad typing
2026-09-22 12:31:44 -07:00
dependabot[bot] 6debd7df76 chore(deps-dev): bump prettier from 3.9.6 to 3.9.8 (#3925)
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.6 to 3.9.8.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 11:33:49 -07:00
dependabot[bot] 70404579f2 chore(deps-dev): bump @types/node from 24.13.4 to 24.13.6 (#3926)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.13.4 to 24.13.6.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.13.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 11:33:40 -07:00
Juan Fernando Castro 7462a8eda9 Merge pull request #3914 from HeyPuter/juancastro/put-1869-unhandled-rejection-on-the-hottest-permission-path-can-take
fix: stop leaking an unawaited permission traversal on every check
2026-09-22 14:28:44 -04:00
Daniel Salazar 718de8a9c6 fix: bump server timeouts for better error handling (#3929) 2026-09-22 10:13:46 -07:00
Juan Fernando Castro 8abdcf4f7f Merge pull request #3928 from HeyPuter/juancastro/put-1873-restrict-godmode-app-launches-to-godmode-callers
fix: restrict godmode app launches to godmode callers
2026-09-22 12:32:32 -04:00
Juan Fernando Castro 476f7e075d Merge pull request #3913 from HeyPuter/juancastro/put-1865-apps-repeatedly-ask-permission-when-it-wants-to-access-files
fix: settle a URL file-access prompt from a grant the app already holds
2026-09-22 12:32:06 -04:00
Juan Castro 1bc8cb964e fix: restrict godmode app launches to godmode callers
`ExecService.launchApp`, reachable from `puter.ui.launchApp`, launched any
named app with no check on the target. Gate it: a godmode target may only
be launched by a godmode caller. Desktop launches (tile, double-click,
URL) call `launch_app` directly and are unaffected; `connectToInstance`
already had its own allowlist.
2026-09-22 09:12:25 -04:00
Daniel Salazar 2690f4934c fix: tests (#3922) 2026-09-21 23:33:23 -07:00
Daniel Salazar d07ddeaaf5 fix(fs): keep a home directory on its account's username (#3920)
A root was free to be renamed or moved, so it could be parked on a
username no account held yet. The next account to take that name
provisioned a second root at the same path, and from then on either row
could answer a lookup of it — new entries inherit their owner from
whichever row resolved as the parent, so one account's files were
created owned by the other, and renaming the parked root dragged the
other account's subtree along with it.

Rename and move now refuse a root, every username claim checks the home
path before the user row is written, and renameUserHome refuses to heal
onto a path another account holds.
2026-09-21 23:29:09 -07:00
Daniel Salazar 1261976088 feat: user profiles in a system-owned store, public only for paid accounts (#3919)
- ProfileService keeps each profile as /system/profiles/<uuid>.profile,
  admin-owned and served by the protected puter-profiles subdomain
- GET/POST /profile; puter.auth.getProfile/updateProfile, with
  getProfilePicture reading through them
- another user's profile is served only while its owner is on a paid
  plan (profileGate.enabled kill switch); the hosted file is gated
  through the new site.access.check hook the hosting middleware asks
  before streaming any file
- SubdomainStore.create takes isProtected
- the GUI reads and writes the profile through the SDK
- docs: getProfile, updateProfile, UserProfile, limits

PUT-1859 PUT-1860 PUT-1861 PUT-1862
2026-09-21 23:28:56 -07:00
Daniel Salazar 2abd5533ac fix(puter-js): restore file pickers on third-party sites (#3917)
The clickjacking guard in #3912 pinned `event.source` to `messageTarget`,
which is only assigned when `env === 'app'`. On a third-party site it is
undefined, so the UI listener dropped every message and
showOpenFilePicker, showSaveFilePicker and showDirectoryPicker never
settled.

Split the two environments. `app` still pins the host frame, whose origin
is whatever the deployment is served from. `web` has no host frame — the
picker popups post back directly — so pin the GUI origin we opened them
on, plus the set of popups we opened. Origin pinning is safe there,
unlike the parent-frame case, because we chose the URL, so self-hosted
and local deployments keep working.

Falls back to the origin alone when `event.source` is null: the picker
calls window.close() right after posting and a discarded browsing
context can drop the source.

PUT-1867
2026-09-21 21:43:58 -07:00
922d203e18 fix(ai): send stable, non-sequential user identifiers to AI providers (#3856)
* fix(ai): send stable, non-sequential user identifiers to AI providers

A precedence bug in the AI providers' identifier expression made every
request send `user: ":undefined"` (the ternary bound the app-uid suffix to
the whole `actor.user.id + actor.app?.uid` sum instead of just the suffix),
or read `actor.user.id` on a missing user. The same expression also shipped
the sequential internal user id, letting AI vendors correlate a single
account across apps and sessions.

All eight OpenAI-, Azure-, xAI-, Meta- and ZAI-style providers now build
the identifier through one shared helper, `aiUserIdentifier()`:

- `puter-<user-uuid>[-<app-token>]`: the random user UUID is always
  preserved in full; `maxLength` constrains only the app-bearing form
- app attribution reads `effectiveApp`, so access-token requests name the
  issuing app instead of looking like direct user traffic
- the app token is truncated to fit the budget, and omitted entirely when
  the remaining budget is below 8 chars, where a truncation could collide
  with another app's uid
- nothing is sent for the system actor
- Meta and ZAI keep a caller-supplied `safety_identifier` / `user_id`
  override, applied before the helper result

`user` is deprecated by OpenAI; the SDK types direct callers to
`safety_identifier` (abuse detection) and `prompt_cache_key` (cache-hit
bucketing). The four OpenAI/Azure chat providers and MetaProvider now send
`prompt_cache_key` as well, defaulting it to the same per-user identifier
unless the caller supplies one; Azure's Grok branch drops both fields,
matching its rejection of unknown args. The cap comment cites only verified
limits: OpenAI's 64 for `safety_identifier` (from the SDK types) and Z.AI's
6-128 for `user_id` (from Z.AI's docs); Meta and xAI document none, so none
is claimed.

The xAI image `#edit` path now carries the identifier like generation, and
takes a named-options param so `user` cannot be transposed with the
adjacent same-typed `aspectRatio`.

Tests share a four-actor matrix (`user` / `user+app` / `access token` /
`system`) with `assertActorMatrixIdentifiers()` across the six
OpenAI-style suites; the helper has exact-string and boundary coverage
(size caps, zero-budget and sub-base cases, no dangling separator, UUID
never truncated, collision guard); the Azure Grok assertions run under a
real user actor so they cannot pass vacuously. 212 provider-suite tests
pass; typecheck and ESLint are clean.

* fix(ai): lock the vendor identifier down and keep vitest out of the test util

Meta and Z.AI no longer let `custom` override the abuse identifier; it is
Puter's attribution, not the caller's. The shared test util exposes pure
field pickers instead of importing vitest into a file the production
tsconfig compiles. The helper's length-cap comment now matches vendor docs
(Meta does cap `safety_identifier` at 64), the redundant budget branch and
the unused export are gone, and the per-user `prompt_cache_key` trade-off is
stated once in the helper instead of five times in providers.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: 404oops <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-21 16:21:47 -07:00
404oopsandClaude Fable 5.1 027e9a71f3 fix(ai-image): refresh model catalogs, unify txt2img requests, expand docs (#3889)
* fix(ai-image): sync image catalogs, unify request shape, document models

Model sync against every vendor listing and a live generation sweep:
Gemini stable ids replace the retired preview spellings (2.5 Flash Image
delisted ahead of its 2026-10-02 shutdown), OpenAI gpt-image-1/-1-mini/-1.5
are delisted but routable until their shutdown dates with gpt-image-2 as
the default, xAI gains grok-imagine-image-2.0 with its quality tiers,
BytePlus gains the 3K/4K tiers and per-model pixel bounds, Cloudflare
gains SDXL Lightning/Base and SD 1.5 Inpainting, Replicate gains a
schema-driven catalog of 88 additional models with version-pinned
community predictions, and every Together image route is excluded for
the third-party data-sharing requirement.

Request normalization: the driver collapses ratio/width/height/aspect_ratio
into one imageSize with aspect-versus-pixel intent, validates prompt,
quality and resolution once, resolves provider hints (short names or
full driver ids) and aliases with exact ids winning over resellers, and
hands each provider an immutable copy of the caller's args. Providers
share prompt validation, aspect snapping, and content-sniffed data URIs
so responses carry the right MIME type. Replicate predictions are created
once, cancelled on abort or deadline, and bounded in input fan-out.

SDK: txt2img copies caller options, rejects blank prompts with
prompt_required in every call form, and documents that normalize has no
effect because the image return shape is already uniform.

Docs: txt2img options per provider, provider defaults and discovery,
availability notes, a new image model catalog and pricing page, and the
image-generation limits in the quotas page.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(ai-image): sniff SVG outputs correctly, reject hex/exponent dimensions

imageDataUri only decoded the first 24 base64 chars (18 bytes) before
sniffImageMime, which cannot see an <svg> root behind an XML prolog -
SVG outputs from recraft-v*-svg models were being labeled image/png.
Decode enough of the payload to cover the 8 KB SVG sniff window.

dimension() accepted string number literals ('0x10', '1e3') as if they
were decimal dimensions; restrict to plain decimal notation.

* docs(ai): link to the model directory instead of a static catalog

The image model catalog and pricing page duplicated the always up to date
model directory at developer.puter.com/ai/models. Link to the directory
from txt2img-related docs and keep the Together data-sharing exclusion
note self-contained.

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-21 15:00:02 -07:00
Juan Castro 259cb69fde fix: check-app reported every app as already authorized
The token grant writes `flag:app-is-authenticated` on the user->app row,
but the check asked for `service:<app_uid>:ii:flag:app-is-authenticated`.
Those never match, and the parent walk reduces the question to a bare
`service` — a root every user holds by default — so the check answered
true for any app_uid and handed back an app-under-user token for an app
the user had never opened.

Read the row the grant writes, through one shared constant so the two
cannot drift again, and mint against the resolved uid.

A cancelled account picker no longer leaves `popup_signin_consent` set:
with no relationship there is no token to flip it.
2026-09-21 16:13:53 -04:00
404oopsandClaude Fable 5.1 081941a6f3 fix: null-prototype model maps in chat/video drivers; txt2vid prompt and option guards (#3887)
Chat and video drivers keyed their provider and model maps on plain objects,
so `model` or `provider` values such as `__proto__` reached Object.prototype
and surfaced as 500s. Both maps are now null-prototype objects and reject
those names as ordinary unknown models.

txt2vid now rejects blank or non-string prompts with `prompt_required` before
any request, tolerates `null` in either argument slot, and copies the caller's
options before resolving the `duration` alias and output path so frozen
option objects work and caller objects are never mutated.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-21 13:12:35 -07:00
404oops e2a5ab8cdc docs: document local Docker builds with Compose overrides (#3867) 2026-09-21 13:12:16 -07:00
Juan Castro 07e889f135 fix: stop leaking an unawaited permission traversal on every check
`validateUserPerms` started the linked SQL traversal beside the flat read
and returned on a flat hit without awaiting it. The hit is the common
case, so nearly every user-permission check left a floating promise: with
`keep_alive_on_uncaught` unset no `unhandledRejection` listener is
registered, so a DB wobble inside the traversal exits the process.

Run it only when the flat read misses. That also stops a SQL walk the
cache had already answered.
2026-09-21 16:07:26 -04:00
Juan Castro ab7a5f325a fix: refuse an empty app_uid and survive a failed grant check
A present-but-empty `app_uid` fell through to checking the user, where
every `fs:` scope on their own file answers `true` — a prompt settled by
a question nobody answered. It is a 400 now.

The launch-path check also sat outside any catch, so a rejecting read
would have taken the whole launch down instead of falling through to the
prompt. Renamed the seam it is injected through to match the function it
defaults to, since wiring the token-based sibling in its place would send
an app uid as a bearer token and silently always prompt.
2026-09-21 15:56:39 -04:00
Juan Castro b4b464554b fix: settle a URL file-access prompt from a grant the app already holds
Opening `/app/<name>?file=<path>` asked for consent on every launch: the
gate prompted unconditionally and never read the `fs:<uid>:write` grant
its own Allow had written.

`/auth/check-permissions` takes an optional `app_uid` so the account can
ask what one of its apps holds, and the launch gate skips the dialog when
the answer is yes. Sessions only — an app or a scoped token asking would
be a window onto its neighbours' grants.
2026-09-21 15:00:02 -04:00
Nariman Jelveh 4de1d1fb39 Redesign the Teams tab in the dashboard (#3901)
* Redesign the Teams tab in the dashboard

Hero card with a lettered tile, role pill, handle and headcount; the
directory setting becomes a settings row with the shared toggle switch;
the add-account form gets labels, Enter to submit, and a copy button on
the one-time password. The accounts and record tables drop the grid for
hairlines, avatar tiles and status pills, and stack into labelled rows
on phones so actions stay reachable. Loading, empty and error states
get a skeleton, a create call to action, and a retry.

Also fixes audit dates showing 1970 (the audit routes send unix
seconds), the actions cell breaking the row border by being a flex
table cell, and the "0 suspended" clause on a healthy team.

* Use the dashboard's overlay dialog in the Teams tab, not UIAlert

Adds UIDashboardDialog, a confirm / alert / prompt card built like the
share and properties modals: mounted in the dashboard window, revealed
after a frame, dismissed by Escape, the close button or a backdrop
press-and-release, with a focus trap and focus restored on close. On
phones it docks to the bottom as a sheet. The Teams tab's confirms,
error alerts, seat-limit warning and rename/create prompts all go
through it; UIAlert and UIPrompt are no longer imported there.
2026-09-21 09:30:03 -07:00
Daniel Salazar 4e9b03bf5c fix: harden clickjacking (#3912) 2026-09-21 09:19:37 -07:00
Juan Fernando Castro a63172e9ae Merge pull request #3892 from HeyPuter/juancastro/put-1806-invite-email-addresses-are-disclosed-to-apps-manage
fix: stop disclosing invite addresses to apps, tokens and delegates

approvals already in place
2026-09-21 12:05:08 -04:00
Reynaldi Chernando 29d805d793 show app icon when login from external app (#3905)
* show app icon when login from external app

* fix
2026-09-21 22:33:38 +07:00
Juan Fernando Castro 059fae3d15 Merge pull request #3872 from HeyPuter/juancastro/put-1813-team-share-blocklist-and-unshare-paging
fix: make the block list bite on team shares, and unshare every re-share (PUT-1813)

Approvals in place to move with the merge
2026-09-21 11:31:24 -04:00
Juan Castro 18ada4764a chore: drop a stray lockfile marker
A local `npm install` (needed to pick up main's new `postal-mime`) wrote
`"extraneous": true` onto a bundled `@clack/prompts` dependency. It was the
branch's only lockfile change and it does not belong to this PR.
2026-09-21 11:26:27 -04:00
Juan Castro 72203152d9 fix: bound a scoped token to what it issued, which is nothing
CI caught three HTTP-level tests the earlier merge left behind, and they
were right to fail: dropping this branch's `manage` gate in favour of
main's row filter lost a case main's filter does not cover.

Main bounds an app to the rows it issued. A *scoped* access token is not
an app, so it was falling through unbounded — `/fs/stat` with
`return_shares` handed a `fs:<uid>:list` token the owner's whole share
list. Addresses stayed withheld, but who else can reach a file is no
more a narrow token's business than the addresses are.

So the filter is generalised rather than the gate restored: a scoped
token is bounded to nothing, since it issues nothing under its own name.
Filtering it by a null app would have been worse than not filtering —
that matches the owner's own rows. Apps and sessions behave exactly as
they do on main, and a full-access token still holds the account's reach.

The three tests now assert the answer instead of a refusal, including
the one whose name had always promised a refusal its body never checked.
2026-09-21 11:12:37 -04:00
Juan Castro cec8382d4d Merge branch 'main' into juancastro/put-1806-invite-email-addresses-are-disclosed-to-apps-manage
Two textual conflicts, both additive on each side: `isAccountContext`
(here) and `isPlainUserActor` (main) are both imported and both used,
and the `stat()` note keeps both sentences — this branch's on who may
read an invite address, main's on the share-read limit it spends.

The rest is adapting to main, which grew its own answer to half of what
this branch was for. `listSharesOf` there bounds an app to the rows it
issued itself; this branch instead required the credential to hold
`manage` and refused it otherwise. Main's is the better mechanism — it
answers the app rather than turning it away, and it hides other
issuers' rows outright rather than redacting a field on them — so the
`manage` gate goes, and with it the two tests that asserted the
refusal. They are replaced by tests that hold main's line: an app sees
none of the invites it did not send, with or without `manage`.

What this branch still carries is the gap main does not close. Its row
filter only applies to apps, so a plain manage delegate still reads the
owner's invite addresses; `#maySeeInviteAddress` is what withholds
those, and its delegate tests pass unchanged. The `stat()` note is
corrected to describe main's behaviour rather than the removed gate.
2026-09-21 10:46:07 -04:00
Juan Castro 3f335939b3 fix: team shares are not subject to a recipient's per-sender block
Review call from the ticket's author: a team share is the team's, not
one colleague's to withhold from another, so a personal block should not
hide it. This drops the enforcement added earlier on the branch — the
listing, its total and the fs-event fan-out no longer filter team rows
by the recipient's block list, and the SQL fragment that did it goes
with them.

What a block still does for a team share is suppress the notification,
which was already true before this branch: it stops the interruption
without pretending to stop the access. Leaving the team is what ends
that. Said so in the block API docs, the settings copy and the code,
since the mismatch between the two was the original complaint.

The unshare sweep is untouched — that half of the ticket stands.
2026-09-21 10:28:50 -04:00
Juan Castro 4e821c128c Merge remote-tracking branch 'origin/main' into juancastro/put-1813-team-share-blocklist-and-unshare-paging
# Conflicts:
#	src/backend/services/share/ShareService.ts
#	src/backend/stores/share/ShareStore.js
2026-09-21 10:28:33 -04:00
Juan Fernando Castro c313a381b6 Merge pull request #3896 from HeyPuter/juancastro/put-1843-plan-card-unknown-not-free
fix: a team seat's plan card names its team, and an unreadable plan is not Free (PUT-1843)
2026-09-21 09:58:41 -04:00
Juan Fernando Castro 76e0d2ec93 Merge pull request #3871 from HeyPuter/juancastro/put-1798-sharing-email-notification-changes
feat: name the issuing app in share emails, and cut the digest window to 5s
2026-09-21 09:58:09 -04:00
Juan Fernando Castro 24fb679dd3 fix: charge stat's return_shares against the share-listing budget (#3870)
* fix: charge stat's return_shares against the share-listing budget

`return_shares` on /fs/stat and the legacy /stat runs the same work as
GET /share/shares, but was only metered under fs:stat's far more
generous limit — and the two scopes stacked instead of sharing one
counter.

Adds consumeRouteRateLimit(req, spec), an imperative charge that
resolves the key and per-subscription limit exactly as rateLimitGate
does, so a handler can conditionally spend a second scope when a
request flag makes the route expensive. Both stat handlers now charge
share:list before doing the listing work; SHARE_LIST_LIMIT moves to a
shared share/limits.ts so all callers pin the same spec.

Closes PUT-1597.

* feat: consumeRouteRateLimit takes the array spec form too

Review follow-up on #3870: a multi-window spec passed whole would have
read an undefined window and silently never pruned. Charge each window
in order instead, refusing on the first refusal, matching the gate.
2026-09-19 14:11:29 -07:00
Daniel Salazar eb9f03e984 fix: misc hardening + other fixes (#3906) 2026-09-19 12:57:57 -07:00
Daniel Salazar 9292771554 fix: hardening (#3904) 2026-09-18 11:22:39 -07:00
Reynaldi Chernando f4974581d0 Add auth message for external apps using puterjs (#3893) 2026-09-18 08:56:02 +07:00
Daniel Salazar b39bb771d9 feat: sortby for recursive readdir (#3900) 2026-09-17 17:29:55 -07:00
Nariman Jelveh 766379ae9f feat(puterjs): add defensive profile picture lookup (#3899) 2026-09-17 17:16:54 -07:00
Daniel Salazar 0be3bc55c2 feat(metering): AI cost multiplier hook for AI drivers (#3898)
Emits ai.cost.multiplier.<driver>.<provider>:<model> before recording AI
usage, so what a model costs to charge is policy an extension owns rather
than a number in core. Nothing listening records the provider cost.

MeteringService.withAiCostMultiplier(driver) returns a view of the service
whose recording paths scale costOverride by the hook's answer; every AI
driver hands that view to its providers, so all of them are covered without
touching provider code.
2026-09-17 15:18:38 -07:00
Nariman Jelveh d1484af754 One recipient field in the sharing dialogs, with suggestions (#3897)
* feat: one recipient field in the sharing dialogs, with suggestions

Both sharing dialogs asked twice: a text field for a person, and a
separate select, label, note and button for a team. Which control to use
was a fact about the API — a bare string is read as an email or a
username, so a team could not be typed — not something a user should
have to know.

Now there is one field. Clicking it offers who this account shared with
before, the teams it belongs to, and the people in them; typing narrows
the list. Choosing an offer locks the field to that recipient, so a team
picked by name still goes out by uid, and the note about a team grant
reaching everyone in it appears at the moment it applies. Anyone already
on the access list is left out of the offers, and a typed address still
works untouched.

Past recipients are kept in the account's key-value store, so they
follow the user between browsers; a typed address that turned out to
belong to an account is filed under the username the backend resolved.
Colleagues are read one page per team, cached for five minutes, and only
looked up once the field is actually used.

The picker is one helper shared by both dialogs so the two can't drift,
styled through `share-suggest-*` tokens each host restates in its own
palette. It opens in the flow rather than floating, which a scrolling
modal body and a mobile bottom sheet would otherwise clip.

* fix: float the recipient suggestions instead of resizing the dialog

The list opened in the flow, which pushed the Share button and everything
under it down and back up as it opened and closed — a dialog that resizes
under the cursor. It now hangs off the recipient row, out of the flow, and
floats over what follows it.

It goes inside the row rather than after it, so the row is its containing
block; as a sibling it resolved against whatever was positioned further up
and landed at the bottom of the scrolling body. Being out of flow it can
be cut off by that scroll container rather than scrolling itself, so on
open it measures the room between the row and the nearest clipping
ancestor, caps the list to it, and opens upwards where below is too tight.

* fix: keep the recipient field focused when the picker is wired onto it

Both sharing dialogs focus the recipient field as they open, and the
picker then moves that field into its wrapper. Moving an element takes
it out of the document for an instant, which drops its focus, so every
dialog opened with the caret in the field opened with the caret on
<body> instead.

* fix: keep the recipient suggestions inside the screen

The list sized itself against the nearest clipping ancestor alone. That
ancestor is the dialog's own scrolling body, which reaches past the
bottom of a short screen — or of any screen once the dialog has been
dragged low — so the list was placed below the fold and the user saw
nothing at all. The viewport bounds it too.

* fix: stop the suggestions flashing a loading box over the dialog

Clicking the recipient field put up a panel saying it was loading before
it knew there was anything to load. For the many accounts with no teams
and nobody shared with before, that panel covered the Share button for
as long as the round trip took and then vanished again. It now waits a
beat before saying anything, and leaves whatever is already listed in
place while a reload is in flight. A list still on its way also no
longer swallows the Escape that closes the dialog.

* fix: do not double-encode the recipient field's placeholder

`i18n()` encodes what it returns, which is right for a string dropped
into markup and wrong for one handed to `.attr()` — the entities show up
as themselves. Nothing is lost today because only English carries the
key, but the next translation with an apostrophe in it would read
`l&apos;équipe`.

* chore: drop the styles for the team picker the field replaced

The desktop dialog's separate team control went with the one recipient
field; its rules stayed behind. The dashboard's equivalents were already
removed with its own markup.

* fix: let Enter share from the desktop dialog's recipient field

Typing a name and pressing Enter did nothing there: the field is in no
form, so the only way to send was to reach the button. The Dashboard's
dialog has always submitted on Enter, and the field now takes Enter to
choose a suggestion, which makes a second press that does nothing read
as a dead key. The picker still gets the press first while it is
choosing a row.
2026-09-17 13:54:03 -07:00
Juan Castro dfd7925872 fix: say which team a seat's plan belongs to, paid or free
A provisioned account read as a plain "Free" plan with an "Upgrade for
more features" badge: nothing said the account belongs to a team, and
the prompt asked for an upgrade only its owner can buy. Its free tier is
not the free plan either — org_seat_free is half the allowance.

The card now names the team on both paths: a paid seat keeps its tier
and status and gains "Managed by <team>", and a seat with no tier reads
"Team account" instead of an upgrade pitch.
2026-09-17 14:15:01 -04:00
Juan Castro e50d210423 fix: an unreadable plan is not a free plan (PUT-1843)
The Home tab read /marketplace/subscriptions/current and folded every
failure into the free state: a non-OK response left `subscription` null,
and the catch said so explicitly. So a refusal rendered as a confident
"Free" with an Upgrade badge.

That is reachable: verification gates are default-on for authenticated
routes, and a team seat that still owes its password change is refused
there — it then reads Free while its team pays for a tier. A rate limit
or a blip does the same to anyone.

The plan card moves into its own method so a failed read can return
without touching it, leaving the card as it was rather than naming a
plan the account does not have. A 200 carrying no subscription still
reads Free, which is the one case that actually means it.
2026-09-17 13:52:03 -04:00
Daniel Salazar 51ce868d1e fix: shared kv handles also allow value opt in (#3895) 2026-09-17 10:34:35 -07:00
Juan Castro 461cb7d0a9 fix: close two gaps the review found in the invite-address fix
- A full-access token was denied the address while `shared-by-me` still
  handed it the same rows, so the clause bought no privacy and cost an
  API client the address `unshare()` takes. `isAccountContext` is the
  boundary the rest of the codebase already uses for 'acting as the
  account': plain session or full-access token, never a scoped one.
- The Dashboard share modal dropped a withheld invite entirely, since
  its aggregate keys a pending row on the address — so a delegate saw no
  sign of an outstanding invite and accessCount under-reported who could
  reach the item. It is now kept, keyed on the share uid, labelled, and
  without the controls that would need a recipient to address.
2026-09-17 13:21:13 -04:00
Daniel Salazar bb97660ad7 feat: kv events value opt in (#3894) 2026-09-17 00:17:12 -07:00
Juan Castro 3e3d02bafe fix: stop disclosing invite addresses to apps, tokens and delegates
getShares (and stat's return_shares, which runs the same listing) gated
on #assertCanManage's default 'see' mode, so any credential that could
see the node got every unclaimed invite's raw email — including an app
handed one file by the picker, a list-scoped token on the stat surface,
and a manage delegate reading the owner's invitees.

Two bounds, matching the invariant clientShare.ts already claimed:

- An invite's address goes only to the item's owner and to whoever sent
  it, and never to an app or token. A delegate can revoke only what they
  issued, so withholding costs them nothing they could act on.
- An app or token must hold manage reach of its own to read the listing
  at all; it answers for the ancestors too, which is not what being
  handed one file grants. tryListSharesOf turns that into an empty
  shares array, so stat itself keeps working.

The share dialog names an unattributable invite rather than rendering a
blank row with a dead revoke button.

Closes PUT-1806.
2026-09-16 18:59:10 -04:00
Neal Shah b2fef45fd6 email read api (#3869)
* email read api

* address docs changes

* remove playground examples
2026-09-16 18:38:58 -04:00
Juan Castro 728966bfb1 Merge branch 'main' into juancastro/put-1813-team-share-blocklist-and-unshare-paging 2026-09-16 18:05:58 -04:00
Juan Castro 3f7ee34706 Merge branch 'main' into juancastro/put-1798-sharing-email-notification-changes 2026-09-16 18:05:39 -04:00
Juan Fernando Castro ff333d05e8 Merge pull request #3890 from HeyPuter/juancastro/put-1819-keep-plan-console
feat: tell the plan picker when a seat's plan is on the way out
2026-09-16 18:05:12 -04:00
Daniel Salazar 194ca7c789 fix: list a link share only while the owner's plan covers it (#3891)
* fix: list a link share only while the owner's plan covers it

* chore: types + jsdoc cleanup
2026-09-16 14:42:52 -07:00
Juan Castro 0b85203edb feat: tell the plan picker when a seat's plan is on the way out
The billing view now names each seat's status; the console passes it
through to the picker so a cancel-pending plan can be kept, plus the
two strings that flow renders.
2026-09-16 15:55:52 -04:00
Juan Castro 23c33b2e06 Merge remote-tracking branch 'origin/main' into juancastro/put-1813-team-share-blocklist-and-unshare-paging
# Conflicts:
#	src/backend/stores/share/ShareStore.js
2026-09-16 15:27:09 -04:00
Daniel Salazar 1b6334c02c feat: a paid plan counts as a verified card for the sharing gate (#3886)
* fix: list a link share only while the owner's plan covers it

* fix: fs limits for signed urls

* feat: a paid plan counts as a verified card for the sharing gate
2026-09-16 10:18:47 -07:00
Juan Castro 0593844acf Merge branch 'main' into juancastro/put-1813-team-share-blocklist-and-unshare-paging 2026-09-16 13:05:03 -04:00
Juan Castro 67d048148c Merge branch 'main' into juancastro/put-1798-sharing-email-notification-changes 2026-09-16 13:04:43 -04:00
Juan Fernando Castro 05cbd33211 Merge pull request #3888 from HeyPuter/juancastro/bump-sqlite-schema-version-80
test: schema version is 80 since 0084
2026-09-16 13:04:15 -04:00
Juan Castro 5c8f02384c test: schema version is 80 since 0084
#3874 added 0084_share-anyone-with-link.sql, taking the sqlite chain's
target user_version to 80, but CURRENT_SCHEMA_VERSION stayed at 79 —
and the test workflow only runs on PRs, so main broke silently and every
open PR's 'test (base)' job now fails on it.
2026-09-16 12:48:07 -04:00
Juan Castro 5d20e054ac Merge branch 'main' into juancastro/put-1813-team-share-blocklist-and-unshare-paging 2026-09-16 10:33:03 -04:00
Juan Castro 3bd3955e0d Merge branch 'main' into juancastro/put-1798-sharing-email-notification-changes 2026-09-16 10:32:05 -04:00
Reynaldi Chernando 79c856386f document model variants (#3875) 2026-09-16 19:31:28 +07:00
Daniel Salazar 59f73528cb feat: share with anyone with the link (PUT-1580) (#3874)
* feat(email): inline cid attachments and Puter mailbox delivery for sendTransactional

EmailAttachment gains cid/contentDisposition so the transactional driver can send inline images. The SDK's EmailAttachment typedef now comes from types.js, which already carried cid. Docs describe delivery to <username>@puter.email recipients and the not_found code.

* feat: share with anyone with the link (PUT-1580); gate sharing on a verified phone or card
2026-09-15 21:06:12 -07:00
Juan Castro 27e858d760 fix: gate the group-share index delete on authority, qualify the live filter
Review follow-ups on #3872: deleteActiveGroup ran even when every
canManagePermission check was denied, deleting the index row while the
team's grant survived — unlisted live access, the class this PR exists
to remove. And memberIdsAmong's join left deleted_at unqualified, which
only works while jct_user_group has no such column; #live now takes an
alias.
2026-09-15 18:57:50 -04:00
Juan Castro 0bd6458441 test: pin the mixed-digest button link carrying no app
Review follow-up on #3871: the flush-level rule — shared_app on the
button only when every entry came through one app — had no direct
coverage. An app share and a plain share folded into one digest now
prove the line and item link keep their attribution while the button
stays clean.
2026-09-15 18:56:38 -04:00
Daniel Salazar 45aff36f0c fix: auto create folders for fs perms (#3873) 2026-09-15 15:11:13 -07:00
Nariman Jelveh a1f7577acb fix: warn before closing the tab while an upload is in flight
Uploads stream from the page, so closing the tab loses a nearly-finished
one with no warning. The beforeunload handler only existed behind
prompt_user_when_navigation_away_from_puter, which is off by default.

Install it unconditionally and have it consult window.active_uploads,
the registry the progress code already maintains. The feature flag keeps
its open-window behavior.

Uploads now register in active_uploads before the first await in the
init callback: an upload failing while the progress window was still
opening used to run its delete ahead of the insert, leaving a phantom
entry that would have made the tab unclosable.
2026-09-15 13:07:43 -07:00
Juan Castro af6547724d Merge branch 'main' into juancastro/put-1813-team-share-blocklist-and-unshare-paging 2026-09-15 15:23:22 -04:00
Juan Castro e9922aea26 Merge branch 'main' into juancastro/put-1798-sharing-email-notification-changes 2026-09-15 15:23:11 -04:00
Juan Fernando Castro b136c56cb5 Merge pull request #3846 from HeyPuter/juancastro/put-1792-optional-seat-email
feat: the team seat experience — no email required, forced password change, team label, and plan-based limits (PUT-1792)

Note: Bypassing the code owners rule, since there are couple approvals in place for this.
2026-09-15 14:53:06 -04:00
Juan Castro aadcda073d fix: keep blocks out of the authority graph, and sweep what unshare missed
Code review on the previous commit confirmed three ways the block filter
inside readUserGroupPerms turned a reversible block into permanent loss:
canManagePermission reads the same rows, so a block-suspended grant
looked revoked to #revokeDownstream's cascade, to unshare's authority
gate, and to invite claiming — deleting re-shares and invites that were
supposed to come back on unblock. The scan is now deliberately blind to
blocks; a block suspends delivery only (listing, count, fan-out,
telling), which the share store filters itself through one shared
notBlockedSql fragment owned by UserBlockStore.

The team-unshare sweep also now covers what it claimed to: a member's
re-share *to another team* is revoked with them (group rows swept in
 #revokeDownstream, user path included), and the sweep is skipped
entirely while another issuer's grant still backs the team — members'
re-shares rest on that authority and revoking them would orphan live
access. The blanket block-all note in settings now says team shares
still arrive, matching what the code deliberately does.
2026-09-15 14:49:54 -04:00
Juan Castro 9da03555ae fix: make the block list bite on team shares, and unshare every re-share (PUT-1813)
A sender a member blocked could still land items in that member's
shared-with-me — and grant them real access — by sharing with a common
team. The group grant is one row and cannot exclude a member, so the
block is now enforced where delivery is derived per member: the
permission scan, the inbound listing and its count, and the fs-event
fan-out all skip team rows whose issuer the member blocked. Nothing is
revoked, so unblocking restores everything. Blocking now also bumps the
blocker's permission cache so it bites immediately. Direct shares keep
their contract: existing ones stand.

#unshareTeam swept re-shares by listing members with a 200 cap and
never following the cursor, so members past the cap kept their orphaned
rows. It now walks the share rows in the subtree — the set that can
actually need sweeping — and filters those issuers to members, which
has no cap by construction.
2026-09-15 14:49:54 -04:00
Juan Castro f428797a79 feat: render the teams UI for allowlisted users without the global switch
`gui_params.teams_ui` stays the deployment-wide switch; with it off, the
tab now also renders for a signed-in user who passes the email-domain
allowlist (membership included, so seats see their roster). Anonymous
renders hide it, and the API keeps deciding real access either way.
2026-09-15 14:48:11 -04:00
Juan Castro ad3d15e7e2 feat: stage the teams rollout behind an email-domain allowlist
`teams_allowed_email_domains` limits who may enter the teams surface;
unset keeps today's behavior. Gated on the two routes that constitute
entry — creating a team, and the listing that shows the tab — with the
same 404 a teams-off deployment answers, so the GUI needs no change and
a staged rollout is indistinguishable from the feature being off.
Members of an existing team always pass, whatever their domain: an
allowed owner brought them in, and the surface follows the team.
2026-09-15 14:23:49 -04:00
dependabot[bot] 07bf0be9c7 chore(deps): bump peter-evans/create-pull-request from 6 to 8 (#3865)
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 6 to 8.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](https://github.com/peter-evans/create-pull-request/compare/v6...v8)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:39:50 -07:00
dependabot[bot] d116f49669 chore(deps): bump dorny/paths-filter from 3 to 4 (#3866)
Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 3 to 4.
- [Release notes](https://github.com/dorny/paths-filter/releases)
- [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md)
- [Commits](https://github.com/dorny/paths-filter/compare/v3...v4)

---
updated-dependencies:
- dependency-name: dorny/paths-filter
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:35:19 -07:00
dependabot[bot] a5f267c2d4 chore(deps): bump docker/setup-qemu-action from 3 to 4 (#3864)
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:35:07 -07:00
Daniel Salazar 2fdd67c72e fix: tighten up fs perm strings (#3860) 2026-09-15 10:33:47 -07:00
dependabot[bot] 92dfe4019e chore(deps): bump compression from 1.8.1 to 1.8.2 (#3861)
Bumps [compression](https://github.com/expressjs/compression) from 1.8.1 to 1.8.2.
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

---
updated-dependencies:
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:30:41 -07:00
dependabot[bot] 61a6820767 chore(deps-dev): bump @types/node from 24.13.3 to 24.13.4 (#3862)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.13.3 to 24.13.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.13.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:29:37 -07:00
dependabot[bot] 38b131e5e8 chore(deps): bump libphonenumber-js from 1.13.12 to 1.13.13 (#3863)
Bumps [libphonenumber-js](https://gitlab.com/catamphetamine/libphonenumber-js) from 1.13.12 to 1.13.13.
- [Changelog](https://gitlab.com/catamphetamine/libphonenumber-js/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/catamphetamine/libphonenumber-js/compare/v1.13.12...v1.13.13)

---
updated-dependencies:
- dependency-name: libphonenumber-js
  dependency-version: 1.13.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-15 10:29:09 -07:00
Juan Castro 230241d6d2 fix: let an owner retire the seats of a deleted team, and only those
Deleting a team suspends every provisioned seat, but every member route
resolved live teams only — so the suspended seats could never be
deleted afterwards, stranding the accounts and, on the billing side,
their paused subscriptions. deleteMember now resolves the team
soft-deleted or not, matching the audit reader.

It also gains the guard enableMember always had: only the team's own
suspension qualifies. A platform-suspended seat could previously be
cascade-deleted by the owner, destroying an account Puter had frozen.
2026-09-15 13:22:13 -04:00
Juan Castro 577693bf84 feat: name the issuing app in share emails, and cut the digest window to 5s
When an app shares on a user's behalf, the mail now says so — the
digest line reads 'alice shared report.txt via Mail App', preferring
the app's title, then its name, then its index_url host. Both the
per-item links and the 'Open Puter' button carry a new shared_app
query param (the app's name, or its uid) so the GUI can match the
share back to the app; the button only carries it when the whole
digest came from one app. Plain shares are byte-for-byte unchanged.

Also drops SHARE_EMAIL_BATCH_SECONDS from 30 to 5: the window only
has to fold one gesture's worth of calls, and every second there is
a second the common single-share email arrives late.

Closes PUT-1798.
2026-09-15 12:15:30 -04:00
Juan Castro 27dd0f8ce6 docs: publish only the team surface an app can actually call
Every team route except the directory refuses app and API-token callers —
administration belongs to the account console. Publishing provisioning,
credentials, suspension and audit in the app-developer docs invited
integrations that would 403 on their first request, so those thirteen
pages are gone and the overview is rewritten around what an app can do:
detect team context with list(), look up colleagues with listDirectory(),
and share with a whole team.

listDirectory — the one app-callable method — was also the only one with
no page; it has one now, leading with its consent gate. The team
recipient joins the share() docs, where the integration actually happens.
2026-09-15 09:34:17 -04:00
Daniel Salazar 50d3794283 feat: handle sub required endpoints better (#3859) 2026-09-14 21:39:01 -07:00
Daniel Salazar 7fdbc0247f fix: bug bounty dupes (#3858) 2026-09-14 16:04:00 -07:00
Juan Castro 3bf5ba3525 feat: i18n keys for the seat cancel flow 2026-09-14 18:40:37 -04:00
Juan Castro 018196f8b4 feat: i18n keys for the team billing surfaces
The private billing extensions render user-facing strings; their keys
live here with every other translation.
2026-09-14 17:22:04 -04:00
Juan Castro c3e2717e8e fix: close the review findings on the seat experience
From the adversarial review of this PR.

The forced password-change gate could deadlock: it POSTed to the
cookie-only route with a bare fetch, and both initgui call sites open it
before update_auth_data mints the session cookie — a fresh browser with a
token URL 401'd every submit inside a non-dismissible loop. It uses the
session-cookie retry wrapper now, taking the caller's token because
window.auth_token does not exist yet on that path. It also gains the
logout footer its sibling gates have; a lost temporary password was a
hard lock with devtools as the only exit.

Password recovery refused for seats: the address is admin-supplied and
never verified, so whoever holds that inbox could take the seat over at
any later time. A seat's recovery channel is its admin's reset.

change-email gets the same seat guard as change-username and deletion —
the address is where admin-issued credentials go.

The login response now carries `team` alongside requires_password_change:
no-reload logins store that payload as window.user verbatim, and every
seat restriction keys on it.

Smaller: the team-badge tooltip no longer double-encodes; the create-token
hint for an emailless account stops pointing at a verification it can
never perform; the quotas doc records the halved org_seat_free allowance;
the config template tells upgrading operators how to keep the old flat
cap; the SDK suite covers emailless provisioning and the owner-only uuid.
2026-09-14 17:00:18 -04:00
Juan Castro 15527eac4c fix: hold a team seat to the free caps it was meant to have
Review catch by @Salazareo: `org_seat_free` reached `FREE_SUBSCRIPTION_IDS`
and so the `requireSubscription` gate, but two other surfaces decide on
plan and neither consults that set.

`bySubscription` maps name `user_free` and `temp_free`. A plan that
matches no key fell through to the top-level `limit` -- the paid cap --
so a seat outranked an ordinary free account: 240 event listings a minute
against their 120, and the same shape across the kv, notification,
subdomain and worker drivers. Both resolvers now fall back to the
`user_free` entry for anything in the free set, which covers every driver
at once and any free plan added later.

`subscriberOnly` compared against the two named ids, so a seat could
reach a paid-only model. It asks the set now.

A paid plan that names no cap of its own still takes the base, and an
unresolved plan still takes the base; there are tests for both so the
fallback cannot widen into "free by default".
2026-09-14 09:44:32 -04:00
Nariman Jelveh 4a23c296f0 Fix/dashboard apps files audit (#3857)
* Dashboard Files: keep rows in sorted order when icons resolve late

renderDirectory appended each row as soon as its icon resolved. Icons for
weblinks and .app files are read from the file itself, so those rows always
landed at the end of the listing regardless of the sort column or direction.
Resolve every icon first, then append the rows in sorted order; renderItem
takes the pre-resolved icon and still looks it up itself for single-row
callers (socket adds, instant folder creation).

* Rename: store the new item name raw in data attributes and the editor

rename_file wrote html_encode(new_name) into data-name, title and the
name editor's value. jQuery's attr()/val() store strings literally, so a
file renamed to "a&b.txt" carried data-name "a&amp;b.txt": the dashboard
Files tab, which re-reads data-name for column truncation, then showed the
entity on screen, type-to-select and sorting compared the encoded string,
and the editor reopened on the encoded name. The initial render already
stores these raw; make the rename path match.

* Dashboard Files: Enter with several folders selected enters only the first

The Enter handler called pushNavHistory + renderDirectory for every selected
folder. Only the first render runs (renderDirectory drops calls while one is
in flight), but every folder was pushed onto the navigation history, so after
selecting two folders and pressing Enter, Back led to the folder already on
screen and Forward to one that was never opened. Enter now navigates into the
first selected folder only; selected files still open in their apps.

* Dashboard Files: don't offer to move items the user can't move

Trashing or cutting sends an item to its owner's trash or a new folder, which
a shared root or a read-only share can't do — the server answers Forbidden and
the user gets an error alert for an action that should not have been offered.
The single-item context menu already leaves Delete out for such rows; the
Delete key, Ctrl/Cmd+X, the multi-selection menu and the mobile selection bar
did not check. Route all of them through can_restructure, skipping rows that
can't move and hiding Cut/Delete when the whole selection can't.

* Dashboard Files: hide New Folder and Upload in the Shared view

Shared is a query over other people's items, not a directory. Both buttons
stayed visible there: New Folder did nothing, and Upload opened the OS file
picker and then silently dropped whatever the user picked. Hide them the same
way Trash already does.

* Dashboard Files: let the same file be picked again after a failed upload

The upload input was only cleared in the success callback, so after an upload
that failed, was cancelled, or was blocked (Shared view), choosing the same
file again fired no change event and nothing happened. Snapshot the picked
files and clear the input as soon as the change fires. This also drops the
document.querySelector('form').reset() that reset whichever form came first
in the document rather than this one.

* Dashboard Files: read saved preferences in parallel and re-sort immediately

init awaited four independent kv reads one after another before the first
listing could start, and handleSort awaited two kv writes before re-rendering,
so a sort click cost two round-trips before anything moved. Issue the reads
together and let the writes settle in the background. While here, set
$el_window before the first await (renderDirectory reached through a route
change or socket event during init threw and left renderingDirectory stuck),
and tolerate a corrupt saved column_widths value instead of failing init.

* Dashboard Files: show the loading spinner on the first directory load

showSpinner keyed off a loading flag, but clearing the listing at the start of
a navigation removed the overlay without resetting it: init's spinner was
wiped by the first render's clear, and the render's own showSpinner call then
no-oped, so the initial load ran with no indicator. Guard on the overlay's
presence instead.
2026-09-13 11:31:16 -07:00
Ayoub Ait ChikhandDaniel Salazar 790db87e13 Fix/node signed batch upload (#3816)
* puter_signed_upload_error_correction

* docs: update signed upload behavior

* fix(puter-js): complete a bodiless response in the XHR shim

A response with no body at all (`fetch` gives `null`) threw inside the
shim's own `then`, the same way a missing content-type did: no 'load',
no 'error', the request hangs forever. Signed storage PUTs answer in
exactly that shape.

Adds the regression tests the content-type fix was missing — all three
hang against the shim as it was.

* refactor(puter-js): drop the signed batch-write env allowlist

With `nodejs`, `service-worker` and `web-worker` added, the list held
every value `puter.env` can take, so the gate decided nothing — and a
new env would have been silently routed to the legacy path.

The backend capability check (`signedBatchWriteSupported`, set from a
404/405/501 on `startBatchWrite`) is the one that still does work.

* test: run the directory-upload suite on node and workers

Both were pinned to browser because the legacy `/batch` fallback cannot
create a directory tree. Now that every platform takes the signed path
they are the regression test for it, and the stale comment goes with
them.

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-09-12 16:32:36 -07:00
dependabot[bot] b901040c14 chore(deps): bump nodemailer from 9.0.6 to 9.1.1 (#3832)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 9.0.6 to 9.1.1.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.0.6...v9.1.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.1.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-12 00:32:34 -07:00
Daniel Salazar ed7e0b9cc4 fix: sharing, events and worker-credential authorization hardening (#3855)
- PUT-1800: gate `createWorkerSessionToken` on actor type, so an app or an
  access token can no longer mint an app-less, root-shaped worker session;
  `WorkerDriver` binds on `effectiveApp` instead of `app`.
- PUT-1799: add `isAccountContext` and read it where "no app" was being read
  as "the account" — handler publish, events-worker listing, kv handle
  mint/revoke/list. A scoped API token is no longer an account session.
- PUT-1802: re-authorize a durable row before its backlog drains, settling it
  permanently when the grant is gone. Covers an ancestor-level unshare, which
  the revoke settle deliberately leaves to the delivery re-check.
- PUT-1803: mask the owner's absolute path out of deliveries and subscription
  anchors on a foreign node, the way every FS surface already does.
- PUT-1804: let a revoke reach rows already suspended for a resumable reason,
  re-stamping them so a resume cannot hand over the held backlog.
- PUT-1805: apply the subscribe path's audience gate to `/events/fetch` before
  the query, so a cursor can no longer count and name invisible notifications.
- PUT-1807: refuse `mode: 'manage'` from any actor holding an app — inside its
  own AppData the ACL short-circuit would otherwise supply the reach.
- PUT-1808: take the sending peer from the verified signature header rather
  than the request body.
- PUT-1810: re-base a kv share-handle row's stored match filter on the handle,
  so the owner's absolute key prefix stays hidden.
- PUT-1814: escape LIKE wildcards and anchor the issuer-prefix queries on a
  segment; anchor `manage:` stripping; reject a backslash in a share prefix;
  assert a resolved actor in `subscribeDurable`.
- PUT-1815: bound the char/varchar columns behind `event_subscriptions` and
  `kv_share_handles` at the store layer.
2026-09-11 21:18:49 -07:00
Neal Shah 883072d144 add email composer api (puter.email.send) (#3854)
* add email composer

* fix imports

* add import for fetchUrl

* add import for compose

* add API origin to fetchUrl

* CI related fixes

* types fix

* address leaf function nitpick

* tests and validation checks

* Fix lockfile
2026-09-12 01:20:41 +00:00
Juan Castro 8d90418c9e fix: don't offer a seat a plan link it cannot use
The Usage card's Upgrade link relabels to "Manage →" for anyone on a
plan, including a seat whose plan is its team's. Following it only
reaches a dialog saying so. Hidden for a seat, unchanged for everyone
else.
2026-09-11 18:00:53 -04:00
Juan Castro de74b0c488 fix: name the plan a team bought, not its policy id
`i18n()` echoes a key it has no translation for, and a team tier has
none — so a seat's dashboard read "team-basic" where a personal plan
reads "Basic". The offering already travels with the subscription, so
its display name is right there; `i18n` stays the fallback for the
personal tiers that do have keys.
2026-09-11 17:44:30 -04:00
404oops 967b974fe9 Merge pull request #3852 from HeyPuter/404oops/infron-fix-tiers
fix: quote and pin Infron service tiers
2026-09-11 23:14:52 +02:00
Daniel Salazar 93885dfc54 fix: email driver rename (#3853) 2026-09-11 13:58:53 -07:00
Filip KujundžićandClaude Opus 5 ab077f047b Revert "docs: document Infron service tiers"
This reverts commit 5fbdc7cac5. Docs will land
in a separate PR.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-11 21:32:42 +02:00
Juan Castro 63f857ce3c fix: make the seat cap the owner's plan decides actually apply
`config.default.json` shipped `max_seats_per_team: 50`, and that flat
override wins over the plan, so the free/paid caps were dead code on
every deployment that did not delete the key. A free owner got 50 seats.
The defaults now carry the two per-plan numbers and leave the flat
override unset; a deployment that wants one number for everyone still
sets it and still wins.

`#ownerPays` asked metering about an `{id, uuid}` stub, so a resolver
keyed on any other field missed and the answer came down to whether
something else had cached that user's plan first — the same team, at the
same seat count, was refused or allowed depending on nothing.

Running out of seats now says what raises the limit and offers the plan
picker, rather than reporting the number and stopping there. The record
table pages at ten rows instead of running off the bottom, and the team
card no longer labels the absence of a handle nothing here can set.
2026-09-11 14:51:54 -04:00
Juan Castro 480d6bc3b5 feat: give a team seat the account surface that is actually its own
A provisioned account could rename itself, delete itself, and see a
Billing tab for a subscription it does not hold — all of it the team's,
not the account's. Each is now refused server-side and dropped from the
UI, keyed on one predicate: whoami reports a team only for an org-owned
seat, and the owner joined their own team.

The Teams tab showed a seat nothing but its own audit rows, so a member
could not see who else was on the team they were told they shared it
with. It now lists them; listMembers was already membership-gated and
already withholds from a member what is not theirs.

The dashboard share modal had no way to reach a team, though the desktop
dialog has had one since team sharing shipped and the SDK has always
taken `{ team }`. Same control, same copy, same helper. The access list
needed a team bucket to go with it: a team share names no holder, so the
aggregate dropped it and a team you had just shared with vanished.
2026-09-11 14:09:07 -04:00
Juan Castro 1efe086565 fix: give the owner the seat uuid the plan action is keyed on
The per-account subscription button did nothing. `listMembers` never
returned a member's uuid, the SDK's `toMember` dropped it, and the Plan
column read `seatTiers[undefined]`, so every seat rendered as Free and
the action dispatched with no seat.

The uuid is owner-only: it is what billing keys a seat's plan on, and one
member has no business identifying another.
2026-09-11 13:14:09 -04:00
Juan Castro 06bdc26efd refactor: let the billing extension own the plan picker
The Teams tab was asking which tier with a UIAlert, which looked nothing like
the personal plan modal. It now dispatches the seat and lets the extension
render the grid, so the two match and OSS stays free of prices.

Carries `username` and `currentTier` so the picker can title itself and mark
the plan the account is already on.
2026-09-11 10:51:57 -04:00
Juan Castro c2a975b3f7 refactor: drop the team plan card
It described a team-wide tier, which stopped existing when plans moved to the
account. What it still showed -- what each tier costs -- is in the Change plan
picker, next to the account it applies to, so nothing is lost by removing it.

`teamPlan.js` and its tests go with it, along with twelve i18n keys nothing
reads any more. `state.plan` stays: the Plan column and the picker both need
the catalogue and the seat assignments.
2026-09-11 10:20:09 -04:00
Juan Castro b8b0650990 feat: list the uuids of the seats a team is billed for
Per-tier quantities need to know *which* seats are active, not just how many:
the count has to be split by the tier each one is on. `countActiveSeats` answers
the old question and stays for the cap and the console copy.

Keyed by `uid` rather than the numeric id, because the billing side holds uids
and would otherwise have to look the team up twice. Same suspension filter, and
a test asserts the two agree so they cannot drift apart.

Falsified: dropping the suspension clause fails "leaves out a suspended seat,
matching countActiveSeats".
2026-09-11 10:11:34 -04:00
Juan Castro bafe8af71b feat: icon buttons for the per-account actions
A row now carries up to five actions -- change plan, reissue, suspend or enable,
delete -- and five labelled buttons do not fit the cell. Each becomes a 30px
icon button.

The label is not dropped, only hidden: it stays as `title`, as `aria-label`, and
as visually-hidden text, so a screen reader and a hover both still get it. The
glyphs are `aria-hidden`, since the button already carries the name.

Inline SVG rather than files under `icons/`: these are one-place 24px line
glyphs, and the dashboard already inlines its sidebar chevron the same way. An
`edit` glyph is defined but unused, ready for the account-edit action.

Falsified: removing the visually-hidden label fails "keeps the label reachable
without showing it".
2026-09-11 10:09:39 -04:00
Juan Castro 187e4c8a2f feat: set a plan per account, not one tier for the whole team
Reverses PUT-1788 D1 at the UI. A team keeps one subscription; each account sits
on its own tier within it.

The plan card stops being the chooser and becomes a summary: what each tier
costs per account, and how many accounts are on it. Choosing happens on the
account, with a Change plan action per row, because that is the thing the tier
now belongs to.

`memberPlanLabel` reads the seat's own assignment rather than the team's single
tier, so two accounts can honestly show different plans. The other three states
are unchanged and still matter: the owner is the payer, a suspended seat is not
billed whatever it was on, and a seat nobody bought a tier for is free.

The action only appears where a billing extension is present and a catalogue
came back, so a deployment that sells nothing shows prices and no dead buttons.
2026-09-11 10:04:40 -04:00
Juan Castro f2c9737bd8 feat: show each account's plan in the team's accounts table
Per-seat billing context without per-seat subscriptions. PUT-1788 D1 stands: one
tier per team, one Stripe subscription, quantity = seat count. The tier is still
changed once, in the plan card.

Four states, because "on Team Basic" is not true of every row. The owner is the
payer and keeps their own personal plan, so they show as such rather than
inheriting the team's. A suspended seat reads "not billed" -- it stops costing a
per-account charge, which is the same rule the billing summary and the plan
card's seat count already use. A seat of a team that bought nothing reads free,
which is the reduced org-seat allowance. Everyone else shows the tier.

The rule is a helper in teamsConsole so it is testable, like the rest of that
file.

Falsified: dropping the payer branch fails "says the owner is the payer, not a
seat"; dropping the suspended branch fails "says a suspended seat is not
billed".
2026-09-11 09:37:34 -04:00
Filip KujundžićandClaude Opus 5 5fbdc7cac5 docs: document Infron service tiers
Tiers beyond a model's default are reachable as `<model>:<tier>` ids, so
the chat docs gain a Service tiers section covering what the suffixes
mean, that the tier named is the tier billed, and that a tier can carry
a smaller context window than its siblings.

The listModels return value described a `cost` object with `input` and
`output` keys. No such field is returned — pricing comes back as `costs`
alongside `costs_currency`, keyed per vendor and named by the model's
own `input_cost_key` / `output_cost_key`. Correct the prose and replace
the example with a real entry.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-11 14:52:28 +02:00
Filip KujundžićandClaude Opus 5 3d2bdfceef fix: quote and pin Infron service tiers
Infron sells the same model at several service tiers. `min_prompt_price`
and `min_completion_price` are the floor across all of them, so every
model with a flex tier was advertised at a batch-job price nobody gets
by default: 25 of 286 chat models, among them gpt-6-astra at $5/$25
against the $7.5/$37.5 a default request actually bills.

Price each tier from its own row in `providers[]` and pin that tier on
the request, so the price quoted is the price charged. Tiers beyond the
default are listed under their own `<model>:<tier>` ids, letting callers
opt into flex or priority by model name:

    puter.ai.chat(prompt, { model: 'infron:openai/gpt-6-astra:flex' })

Suffix parsing matches the catalog exactly before reading a trailing
segment as a tier, since catalog ids can carry a colon of their own
(`deepseek/deepseek-v4-flash:free`). Tier variants take the context
window of their own offering, which differs from the model-level figure
for 11 of them.

Billing was never wrong — it bills Infron's reported `cost` — but the
understated prices fed the credit gate's output cap, which let a request
run roughly 50% past the balance it was gated against, and the fallback
path that prices per token when a response carries no cost.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-11 14:47:41 +02:00
Juan Castro 2083ef47c0 fix: the plan card counted no seats
It filtered on `org_owned`, but the GUI annotates members to `orgOwned` --
so the count was always zero and the card read "billed for 0 accounts" beside
an accounts table saying two.

Uses `membersBillingSummary` now, the same count the table shows, which also
excludes suspended seats: they stop costing a per-account charge, which the
naive filter would have billed for.
2026-09-10 18:32:31 -04:00
Daniel Salazar 9940beb4bb fix: card verification missing email (#3850) 2026-09-10 15:32:01 -07:00
Juan Castro fa36f2e3cf fix: run the forced password change on the login path, not only token-in-URL
`initgui` runs the verification gates in two places: the token-in-URL branch and
the session-restore/login branch. The password gate went into the first only, so
it never fired for the case it exists for -- a seat signing in through the login
form with the credential its administrator issued. The account reached the
desktop and then failed at every gated call with no prompt, which is the state
the gate was written to prevent.

Caught by manual testing, not by any test: both chains looked right in
isolation. Added an invariant test over initgui's source asserting each gate
appears on both paths and each loops until cleared, which is the shape of the
mistake rather than the instance of it.

Falsified: removing the login-path gate fails "runs the forced password change
on both paths" with `expected 1 to be 2`.
2026-09-10 18:27:01 -04:00
Juan Castro efe6ef09bd feat: show a team's plan in the Teams tab, and let the owner change it
PUT-1796's UI half. The owner already manages accounts here, so the plan they
are billed for belongs on the same page rather than a tab away.

Pricing stays out of OSS. The card is drawn from whatever catalogue the server
returns, so this code knows no tier, no price and no payment provider -- a
deployment that sells nothing serves no catalogue and the card does not render.
`TabHome` already reads `/marketplace/subscriptions/current` the same way, so
OSS reading a prod-served endpoint is not a new idea here.

Two things are deliberately not offered rather than offered and broken. A tier
the server marks unavailable gets no button, because no Stripe price is
configured for it and buying would 422. And no button appears at all unless a
billing extension has set `window.team_billing_ui` -- the prices still render,
which is useful on its own, but nothing invites a click nobody can handle.
Checkout is Stripe's, so the button only dispatches `team-plan-purchase` with
the team and item id and lets the extension take it from there.

The markup is a helper rather than another branch in TabTeams, matching
teamBadge/credits/usageBudget, so it can be tested without the window stack.

Falsified: offering an unavailable tier fails "offers no button for a tier with
no configured price"; rendering buttons regardless of the extension fails
"offers no button without a billing extension to act on it".

342 GUI/SDK tests, 136 team backend tests, typecheck clean.
2026-09-10 17:42:51 -04:00
Daniel Salazar 367d20a55c test: kv tests (#3844)
* test: kv tests

* chore: bump deps
2026-09-10 14:35:46 -07:00
Juan Castro e886e86cb5 feat: size a team by whether its owner pays, and halve a free seat's allowance
Three related limits.

Seats per team now depend on the owner's plan: 4 free, 40 paid, decided by
`subscriptionSatisfies(id, true)` -- which is `!FREE_SUBSCRIPTION_IDS.has(id)`,
so a plan an extension adds counts as paid without core knowing its name. The
existing `max_seats_per_team` still overrides both, so a deployment that already
set it keeps what it asked for, and `max_seats_per_team_free` / `_paid` tune
each. An unreadable plan takes the smaller cap: over-provisioning a free team is
the worse failure.

A seat of a team that pays for nothing resolves `org_seat_free`, half the
registered free plan. Without it a team is a way to mint free tiers -- provision
four seats and each arrives with a full free allowance nobody paid for. The
figures are derived from `REGISTERED_USER_FREE` rather than restated, so the two
cannot drift, and the id joins `FREE_SUBSCRIPTION_IDS` because nobody paid for
it either and it must not satisfy a plan gate.

It is a *default* resolver, so a paid team tier -- which only prod knows about,
through `registerSubscriptionResolver` -- still outranks it. The lookup is
`getOrgSeat`, already cached with its negatives, because almost nothing is a
seat.

Found while testing: the suite was reading `max_seats_per_team` out of the
developer's own config.json, so the cap under test was whatever that file said.
It would have passed here and failed in CI, which has no such file. The suite
now pins the value and the cap tests set their own.

Falsified three ways, each breaking only its own test: equal caps fails "lets a
paid owner past four"; a resolver returning null, and an unhalved allowance,
both fail "resolves half the free plan".

186 team/whoami tests, 159 metering tests, typecheck clean.
2026-09-10 17:04:43 -04:00
Juan Castro 106978e714 feat: tell a seat which team its account belongs to
A provisioned account had no way to know it was one. That matters: the team can
reset its password and close it, which is exactly what the account-created email
already warns about, and nothing in the product repeated it afterwards.

`whoami` now carries `team: { uid, name }`. Two gates on it. Only user actors --
a seat's employer is no more an app's business than its phone number, which the
same handler already withholds. And only where `teams_enabled` is on, so a
deployment without teams is byte-identical.

It rides whoami rather than a route of its own because the sidebar needs it at
first paint. A `/teams/whoami` would add a request to every page load for every
user, and almost none of them are seats. The lookup costs nothing either way:
`getOrgSeat` is already cached, negative results included, precisely because
almost nothing is a seat. `team_name` comes off a join the query already made.

In the sidebar it sits under the Puter wordmark -- the conventional slot for
workspace context -- as a muted second line, hidden when the sidebar collapses.
Owners see nothing: they already know, and one may own several teams, so there
would be no single name to show.

The markup is a helper rather than another branch inside UIDashboard, matching
how appGroups/credits/usageBudget were pulled out, so it can be tested without
mocking the window stack.

Falsified: dropping the `isUser` gate fails "withholds it from an app actor" and
nothing else.

181 backend tests, 331 GUI/SDK tests, typecheck clean.
2026-09-10 16:52:10 -04:00
Juan Castro fb74789de1 feat: email a seat its username and temporary password, when an address is given
Follows the previous commit. Dropping the email field entirely went one step too
far: without an address the temporary password shown once in the panel is the
only copy, and an admin who closes that panel has to issue a new one. The field
is back, marked optional, and now it buys something concrete.

`team_account_created` carried no credential -- it said the team "will send you
a temporary password separately". It now carries the username and the temporary
password, so an admin who supplies an address hands nothing over by side channel.

`#notifyUser` takes extra template variables, and both credential-issuing paths
pass the one they just minted: provisioning and re-issue. Re-issue passing the
fresh credential rather than the stale one is the case worth checking, and there
is a test that asserts the old password is absent from that mail.

With no address nothing is sent, which was already true -- `#notifyUser` returns
early without one -- and is now covered.

The docs said the notice carries no credential in two places. Both corrected.

Falsified: dropping the credential from the re-issue call fails "emails the fresh
credential on re-issue, not the old one" and nothing else.

178 backend tests, 326 GUI/SDK tests, typecheck clean.
2026-09-10 16:41:03 -04:00
Juan Castro a983969f32 feat: prompt a team seat to choose its own password on first sign-in
The backend already refused every route with `password_change_required` until a
provisioned account replaced the password its administrator chose, and
`/user-protected/change-password` was already exempt so the account could act.
The client half was missing entirely: nothing in the GUI referenced that code,
so a seat signed in and then failed at everything with no prompt and no way out.

Two gaps, both closed here.

The flag never reached the client. Neither the login response nor `/whoami`
carried `requires_password_change`, so the GUI could not have known even if it
wanted to. It ships from both now, alongside the other three verification flags
the whoami extension already describes as "the flags the GUI acts on".

There was no window to show. `UIWindowChangePassword` is a settings dialog --
closable, and it never resolves on success -- so it cannot act as a gate.
`UIWindowPasswordChangeRequired` mirrors the existing `*Required` windows: it
resolves true only once the change lands, and `initgui` loops on it. It runs
last in the boot chain, matching the server order in assertVerifiedAccount.

It refuses a new password equal to the current one. Without that the account
stays on the credential its administrator still holds, which is the entire thing
the gate exists to end.

Falsified twice: dropping the same-password guard fails "refuses to reuse the
password the admin handed over", and resolving on a rejected response fails
"stays open on a rejected change, so the gate cannot be escaped" -- each
breaking only its own test.

175 backend tests, 326 GUI/SDK tests, typecheck clean.
2026-09-10 16:35:05 -04:00
Juan Castro b19b312e30 feat: a team seat needs no email address, and is never asked to confirm one
PUT-1792. Two separate problems, both from treating a provisioned account like
a self-registered one.

`email` was required, so an admin creating ten seats had to invent ten addresses
and then keep track of ten uniqueness constraints -- for accounts that sign in
by username and never use the address. It is now optional at every layer, and
the add-account form does not ask for it at all: username is the only thing a
seat needs.

`requires_email_confirmation` was set to true, with the reasoning that an
admin-supplied address is unverified. True, but `requireVerifiedAccount` turns
away on exactly `requires_email_confirmation && !email_confirmed`, so a
freshly created seat was asked to confirm an address it may not hold and could
not use the product until it did. The team creating the account is the trust
anchor, not the mailbox, so this is now false either way.

An address is still accepted and still stored when given, because the notices
are worth delivering. `#notifyUser` already returned early on a missing
address, so `team_account_created`, `team_account_disabled`,
`team_password_reset` and `team_closed` degrade quietly with no new branching --
the temporary password is in the API response, which is the documented delivery.

`idx_user_owned_email` is partial and skips password-null rows, so omitting the
address sidesteps it rather than creating a collision surface. Two seats with no
address do not conflict, and there is a test for it.

Docs now say an emailless seat is recoverable only through its team's owner.
That falls out of the design rather than being a limitation of this change, but
it should be written down rather than discovered.

Falsified: putting `requires_email_confirmation: true` back fails
"never demands confirmation, with or without an address" with
`expected true to be false`, and nothing else.

164 team tests, 40 SDK tests, typecheck clean.
2026-09-10 15:55:50 -04:00
566 changed files with 57050 additions and 8226 deletions

No files matched your search

+45 -45
View File
@@ -6,56 +6,56 @@
/src/gui/ @jelveh @Salazareo
# Documentation, developer site, and CLI
/doc/ @reynaldichernando
/src/docs/ @reynaldichernando
/src/dev-center/ @reynaldichernando
/src/cli/ @reynaldichernando
/doc/ @reynaldichernando @Salazareo
/src/docs/ @reynaldichernando @Salazareo
/src/dev-center/ @reynaldichernando @Salazareo
/src/cli/ @reynaldichernando @Salazareo
# Backend: AI, hosting, workers, networking, and WebDAV
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland
/src/backend/drivers/util/ @ProgrammerIn-wonderland
/src/backend/drivers/workers/ @ProgrammerIn-wonderland
/src/backend/services/localworker/ @ProgrammerIn-wonderland
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland
/extensions/workerSandbox* @ProgrammerIn-wonderland
/src/worker/ @ProgrammerIn-wonderland
/src/worker-types/ @ProgrammerIn-wonderland
/src/backend/controllers/peer/ @ProgrammerIn-wonderland
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/util/ @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/workers/ @ProgrammerIn-wonderland @Salazareo
/src/backend/services/localworker/ @ProgrammerIn-wonderland @Salazareo
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland @Salazareo
/extensions/workerSandbox* @ProgrammerIn-wonderland @Salazareo
/src/worker/ @ProgrammerIn-wonderland @Salazareo
/src/worker-types/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/peer/ @ProgrammerIn-wonderland @Salazareo
# Backend: sharing, permissions, and teams
/src/backend/controllers/share/ @jfcastro92
/src/backend/controllers/team/ @jfcastro92
/src/backend/services/share/ @jfcastro92
/src/backend/services/permission/ @jfcastro92
/src/backend/services/acl/ @jfcastro92
/src/backend/services/team/ @jfcastro92
/src/backend/services/apps/AppPermissionService* @jfcastro92
/src/backend/stores/share/ @jfcastro92
/src/backend/stores/permission/ @jfcastro92
/src/backend/stores/group/ @jfcastro92
/src/backend/stores/team/ @jfcastro92
/src/backend/controllers/share/ @jfcastro92 @Salazareo
/src/backend/controllers/team/ @jfcastro92 @Salazareo
/src/backend/services/share/ @jfcastro92 @Salazareo
/src/backend/services/permission/ @jfcastro92 @Salazareo
/src/backend/services/acl/ @jfcastro92 @Salazareo
/src/backend/services/team/ @jfcastro92 @Salazareo
/src/backend/services/apps/AppPermissionService* @jfcastro92 @Salazareo
/src/backend/stores/share/ @jfcastro92 @Salazareo
/src/backend/stores/permission/ @jfcastro92 @Salazareo
/src/backend/stores/group/ @jfcastro92 @Salazareo
/src/backend/stores/team/ @jfcastro92 @Salazareo
# SDK: AI, hosting, workers, and networking
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland @Salazareo
# SDK: sharing and permissions
/src/puter-js/src/modules/perms/ @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92
/src/puter-js/src/modules/perms/ @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92 @Salazareo
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92 @Salazareo
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92 @Salazareo
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92 @Salazareo
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92 @Salazareo
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92 @Salazareo
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
backend: ${{ steps.filter.outputs.backend }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
git reset --hard main
- name: Create/Update PR
if: steps.find-pr.outputs.number == ''
uses: peter-evans/create-pull-request@v6
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: Update release branch
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
puterjs: ${{ steps.filter.outputs.puterjs }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
+3
View File
@@ -72,3 +72,6 @@ servers.json
config.*.json
volatile/
# Local build override
docker-compose.override.yml
+30
View File
@@ -58,6 +58,36 @@ When in doubt, return less. Auth-, permission-, or data-export-related changes d
---
## Local development configuration
To test a local Docker build, create `docker-compose.override.yml` in the repository
root (or merge into the existing file):
```yaml
services:
puter:
pull_policy: never
build:
context: .
```
Compose merges it automatically. Run `docker compose up -d --build` to build and
start the stack, and repeat after source changes. Keep local build settings in
this gitignored override instead of editing `docker-compose.yml`, so they stay
out of pull requests and do not conflict when pulling updates. See
[Self-Hosting Puter](doc/self-hosting.md#building-from-source-instead-of-pulling)
for the required runtime configuration.
For local API testing, agents may decide whether to set `"unlimitedMetering": true`
when budget checks block unrelated tests; no additional approval is needed.
Merge it into the ignored `config.json` for `npm start` or
`puter/config/config.json` for Docker, then restart Puter. It applies to all
accounts on that local instance, including guests, and usage is still recorded.
Keep normal settings when testing budget or subscription enforcement, and leave
shared defaults unchanged.
---
## Backend
A layered stack with explicit dependency injection: each layer depends only on the layers beneath it, receives them through its constructor, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together. [doc/architecture.md](doc/architecture.md) is the full reference.
+10 -3
View File
@@ -21,12 +21,19 @@ Out-of-scope:
The following have already been reviewed and determined **not to be vulnerabilities**. Reports that only re-describe one of these are **not eligible for a reward** and will be closed as non-issues — even if they include new code references. Please review this list before submitting:
- **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we _do_ evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
- **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is out of scope. This explicitly includes **missing `Content-Security-Policy: sandbox` on any endpoint that serves a file inline** — `/token-read`, `/file`, `/fs/read`, WebDAV, and any MIME type the `applyInlineContentSecurity` allow-list does not cover (`text/xsl`, `image/svg+xml`, `application/xhtml+xml`, …). Script execution on the API origin has nothing to reach: there is no session cookie, no `localStorage`, and no ambient credential there. WebDAV additionally has to return an accurate MIME type for real WebDAV clients. The one exception we _do_ evaluate on its merits is anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host — if your report does not name a specific credential that is actually reachable from the API origin, it is this non-issue.
- **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
- **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
- **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
- **App metadata or app user-count "leaks".** This information is currently public by design.
- **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (taskbar items, `/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
- **Worker hot-reload driven by a share recipient.** Sharing a worker's source file with write access is how you let a collaborator change that worker's code and have it deploy live. The redeploy running as the file owner, with the owner's worker credentials, is the intended behavior of that grant — the owner chose to hand out write access to executable code. If you do not want that, do not share the source file with write access.
- **Share-mode downgrades that do not cascade.** Demoting a delegate does not retroactively revoke the grants that delegate already issued downstream. Shares are independent grants once issued, not a live-evaluated chain. Revoking the downstream grant is a separate, supported action.
- **Concurrency / TOCTOU overspend of credits or storage quota.** Firing concurrent requests to exceed an AI credit hold or a storage quota by some margin is known and accepted; the per-account concurrency and rate-limit gates are the control we rely on, and the residual overage is bounded and billed. Report these only if you can demonstrate _unbounded_ overspend.
- **Session and credential lifecycle around 2FA enrollment.** Enrolling in 2FA is not a credential-revocation event: existing authenticated sessions stay valid, and outstanding password-reset links stay valid. Likewise, account-management actions behind the `/user-protected/*` gate are password-gated by design, not TOTP-gated. A recovery code that stops being accepted fails _closed_ — it denies access rather than granting it, so it is a bug report, not a vulnerability.
- **Group permissions** (`/auth/revoke-user-group` and siblings). The group-permission surface is unfinished and not exposed anywhere in the product. Findings there are not reachable by any user.
- **Correctness bugs with no security boundary crossed.** For example, an API that reports success while silently leaving data behind. We want these reported — please open a GitHub issue — but they are not bounty-eligible.
- **Missing Subresource Integrity (SRI) on external scripts/styles.** Accepted as hardening, not as a vulnerability, absent a demonstrated compromise of the CDN in question.
- **General "token in a URL" / token-lifetime / token-storage designs** — signed directory URLs exposing children, a write signature implying read, app tokens outliving a web session, or the session token being held in `localStorage` on `puter.com`. These are current intended behaviors. Shorter-lived tokens with a refresh flow are on the roadmap; their absence is not a vulnerability on its own. Show a reproducible attack that does not already presuppose script execution on `puter.com`.
- **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
- **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
- **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
+4
View File
@@ -10,6 +10,8 @@
·
<a href="https://apps.puter.com/">App Store</a>
·
<a href="https://builder.puter.com/">AI Builder</a>
·
<a href="https://developer.puter.com" target="_blank">Developers</a>
·
<a href="https://twitter.com/HeyPuter">X</a>
@@ -46,6 +48,8 @@ npm start
```
**→** This should launch Puter at http://puter.localhost:4100
To run this checkout with Docker, follow [Building from source](./doc/self-hosting.md#building-from-source-instead-of-pulling). Create a local `docker-compose.override.yml` to select the local build; keeping these settings out of `docker-compose.yml` avoids conflicts when pulling updates and keeps local configuration out of pull requests.
<br/>
+2 -1
View File
@@ -24,7 +24,8 @@
"disable_user_signup": false,
"teams_enabled": false,
"max_teams_per_user": 1,
"max_seats_per_team": 50,
"max_seats_per_team_free": 4,
"max_seats_per_team_paid": 40,
"strict_email_verification_required": false,
"gui_assets_root": "./src/gui",
"puterjs_root": "./src/puter-js/dist",
+30 -6
View File
@@ -32,6 +32,10 @@
"port": 4100,
// Externally-visible port (set this when behind a reverse proxy on 80/443).
"pub_port": 4100,
// Idle keep-alive timeout in ms. Must stay above the idle timeout of any
// proxy in front, or the proxy reuses connections this server has closed
// and its clients see 502s.
"keep_alive_timeout": 620000,
"protocol": "http",
"domain": "puter.localhost",
// Fully-qualified externally-visible URL. Computed from protocol/domain/
@@ -285,12 +289,26 @@
// deleting yours frees the slot. Default 1.
"max_teams_per_user": 1,
//
// Seats one team may provision. Each seat is a real Puter account
// taking a name from the global username pool, so this is what bounds a
// team's blast radius until per-seat billing lands. Lowering it below
// a team's current seat count blocks new provisioning and disables
// nobody. Default 50.
"max_seats_per_team": 50,
// Seats one team may provision. Each seat is a real Puter account taking a
// name from the global username pool, so this is what bounds a team's blast
// radius. The owner's plan decides which of the two applies; defaults are 4
// and 40. Lowering either below a team's current seat count blocks new
// provisioning and disables nobody. Deployments upgrading from the old flat
// default of 50: without a billing extension every owner reads as free, so
// set `max_seats_per_team` to keep the old behavior.
"max_seats_per_team_free": 4,
"max_seats_per_team_paid": 40,
//
// One flat cap for every team, whatever the owner pays. Setting it
// overrides both of the above, so a deployment that does not sell seats can
// ignore plans entirely. Unset by default — leave it that way to keep the
// free cap meaningful.
// "max_seats_per_team": 50,
//
// Staged rollout: only these email domains may create a team, and the tab
// renders for them even with `gui_params.teams_ui` off. Members of an
// existing team always pass. Unset means everyone, gated by `teams_ui`.
// "teams_allowed_email_domains": ["puter.com"],
// ── Notifications ───────────────────────────────────────────────────
// How long a notification is kept, in days from creation. Acknowledged or
@@ -524,4 +542,10 @@
// what this deployment's normal traffic costs, so it has to be measured
// rather than guessed, and a stale number here alarms on healthy growth.
// "maxGlobalUsagePerMinute": 200000000
// How many full months past its own a monthly usage record is kept before
// it expires. Defaults to 3; set to 0 to keep every month's records
// forever. Changing it, including to 0, applies to records first written
// after the change; records already stamped keep their expiry.
// "meteringRetentionMonths": 3
}
+10
View File
@@ -59,6 +59,16 @@ We use [`Context`](../src/backend/core/context.ts) — backed by `AsyncLocalStor
Prefer explicit arguments. Reach for `Context` only when the value is truly request-scoped and would otherwise need to thread through many layers.
## Actors
An [`Actor`](../src/backend/core/actor.ts) is who a request is acting as. It has two app-shaped fields and they are not interchangeable: `app` is the app the actor carries *directly* (an app-under-user token), while `effectiveApp` is the app it ultimately acts as — its own, or the one that issued its access token. An access-token actor has no `app` of its own, so a gate that reads `app` answers "no app" for it and falls open.
`makeActor` resolves `effectiveApp` once at construction, and `assertResolvedActor` at the request edge rejects any actor that skipped it — so reading `effectiveApp` needs no fallback. Reach for `app` only where the direct app genuinely is the question, and say why in a comment.
- `actor.effectiveApp` — which app is acting. This is the default; nearly every scoping rule, namespace key and attribution wants it.
- `isAppActor(actor)` — a direct app actor specifically. Use it where a token must *not* be treated as its issuer, e.g. the permission scanners that read an app's own grant rows.
- `isPlainUserActor(actor)` — the account acting through nothing at all; `isAccountContext(actor)` for the wider "the account's own reach", which also admits a full-access token.
## Extensions
Extensions live alongside core ([packages/puter/extensions/](../extensions/)) and parallel the layered stack. They are meant for **non-crucial parts of the system** — things Puter still works without if removed.
+1 -1
View File
@@ -56,7 +56,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), `requireSubscription`, `requireReputation`, and `noUserSession`. The `/drivers/call` surface enforces them.
- **A surface only paying accounts should reach** declares `requireSubscription` — the route option on a controller endpoint, the per-method `@Driver({ requireSubscription })` block on a driver (`/drivers/call` is one shared route, so a driver's requirement can't live in route options). `true` accepts any plan that isn't free, so a plan registered by an extension counts without core naming it; an array of policy ids (`['business', 'pro']`) accepts only those; `false` is "no requirement", the same as leaving it out. An empty array is a boot error rather than a silent no-op — it reads as subscribers-only while admitting everyone. Off unless asked for, and answered from the metering service's cached per-actor subscription, so it costs a map lookup. Distinct from `requireCredits`: this asks which plan the account is on, not whether it has budget left. Deployments with no paid plans (self-hosted installs) turn the whole thing off with `meteringEnforcement.subscriptions: false`.
- **A surface only a trusted-enough account should reach** declares `requireReputation` — the route option on a controller endpoint, the per-method `@Driver({ requireReputation })` block on a driver. The value names a tier; what that tier takes is `reputationGate.tiers` in config, so the score a surface is worth is a deployment call and can be retuned without editing the surface. A tier the running config doesn't define is inert and everyone passes — an install that doesn't score its accounts must not start turning traffic away on a score it never computed — and `reputationGate.enabled: false` stops every declared gate at once. Opt-in, implies `requireAuth`, and denies with a bare 403 `reputation_required` that names neither the score nor the tier. Nothing in the tree declares one yet: the mechanism ships ahead of the enrollment.
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for.
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes, as does a paid plan where `card` is listed (a paying account has a card on file already); otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on), and with none verifiable the gate is inert rather than locking the route on a self-hosted install. It denies with the first verifiable factor's code plus `factors`, the verifiable ones in the route's order, so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, for an SMS-provider outage.
- **An endpoint that spends metered resources** on the caller's behalf — moving file content, making object-store requests, anything else the account is billed for — also declares `requireCredits: true`, which turns an account with nothing left of its budget away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to be able to see what it has, clear it, and reach its billing pages. Drivers have no route options to declare this on, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
### 3. puter.js
+42 -1
View File
@@ -408,6 +408,12 @@ Every account then resolves to an unlimited policy. Usage is still recorded, so
the dashboard still shows what is being consumed; nothing is ever refused for
lack of budget.
For API testing, you can also set `"unlimitedMetering": true` in your
ignored runtime config: `puter/config/config.json` for Docker, or the repository's
`config.json` for `npm start`. Merge it into the existing config and restart Puter.
This includes guest accounts and applies to the whole local instance. Keep normal
metering settings when testing budget or subscription enforcement.
To keep the budgets but stop them blocking anything — recording only:
```json
@@ -418,6 +424,17 @@ Calls driven by a deployed worker are exempt from enforcement by default,
because a worker has no prompt to show and nobody watching it fail. Set
`"meteringEnforcement": { "workers": true }` to include them.
Monthly usage records expire on their own once they're old enough to be of no
further use — `meteringRetentionMonths` full months past the month they
belong to, 3 by default. Set it to `0` to keep every month's records forever:
```json
"meteringRetentionMonths": 6
```
Changing it, including to `0`, applies to records first written after the
change; records already stamped keep their expiry.
### Captcha on signup / login
Built-in proof-of-work captcha — no external service needed.
@@ -488,12 +505,36 @@ For GPU acceleration (NVIDIA), uncomment the `deploy:` block under the `ollama`
## Building from source instead of pulling
If you want to test local Dockerfile changes against the full stack, uncomment the `build:` block in [docker-compose.yml](../docker-compose.yml) under the `puter` service, change `pull_policy: always` → `pull_policy: never`, then:
To run a local build against the full stack, use a source checkout and complete
the configuration steps above. Create `docker-compose.override.yml` in the
repository root, next to [docker-compose.yml](../docker-compose.yml):
```yaml
services:
puter:
pull_policy: never
build:
context: .
```
If that file already exists, merge these settings into its `puter` service.
Compose loads and merges the override automatically. `build.context: .` selects
this checkout, and `pull_policy: never` prevents pulling the published Puter image.
Use this override for local build settings instead of editing `docker-compose.yml`.
The override is ignored by Git, keeping local configuration out of pull requests
and avoiding conflicts in the shared Compose file when pulling updates.
From the repository root, build and start the stack:
```bash
docker compose up -d --build
```
Run the same command after changing or pulling source code to rebuild the image.
To return to the published image, remove the local build settings (or the override
file if those are its only settings), then run `docker compose up -d`.
---
## Managing running backend
+2 -9
View File
@@ -230,15 +230,8 @@ services:
puter:
image: ghcr.io/heyputer/puter:main
pull_policy: always
# Uncomment to build from this directory instead of pulling the published
# image. Also flip pull_policy to `never` so compose doesn't overwrite
# your local build by re-pulling :latest.
# build:
# context: .
# # buildx-only: cross-compile to both archs in a single push
# # platforms:
# # - linux/amd64
# # - linux/arm64
# For local builds, create a docker-compose.override.yml;
# see doc/self-hosting.md#building-from-source-instead-of-pulling.
container_name: puter
restart: unless-stopped
depends_on:
+84 -7
View File
@@ -8,6 +8,7 @@ import {
it,
vi,
} from 'vitest';
import { makeActor } from '../src/backend/core/actor.ts';
import { runWithContext } from '../src/backend/core/context.ts';
import { configContainer } from '../src/backend/exports.ts';
import { PuterServer } from '../src/backend/server.ts';
@@ -48,6 +49,7 @@ beforeAll(async () => {
create_shortcut: true,
payment_bypass: true,
},
teams_enabled: true,
} as never);
});
@@ -66,6 +68,81 @@ const seedUser = async () => {
};
describe('whoami extension — handleWhoami', () => {
// The sidebar label needs this at boot, which is why it rides whoami
// rather than a call of its own.
describe('the team an account belongs to', () => {
// A seat is created, never adopted, so it must have no password.
const seedSeat = async () => {
const slug = Math.random().toString(36).slice(2, 8);
return server.stores.user.create({
username: `wseat_${slug}`,
uuid: uuidv4(),
password: null,
email: null,
});
};
const seatOf = async (teamName: string) => {
const owner = await seedUser();
const seat = await seedSeat();
const team = await server.stores.team.create({
ownerUserId: owner.id as number,
name: teamName,
handle: `wt-${Math.random().toString(36).slice(2, 9)}`,
});
await server.stores.team.addMember(team.uid, seat.id as number, {
orgOwned: true,
});
return { seat, team };
};
it('names the team for a seat', async () => {
const { seat, team } = await seatOf('Acme Corp');
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: seat.uuid, id: seat.id as number } } },
() => handleWhoami(makeReq(), res),
);
expect((captured.body as { team?: unknown }).team).toEqual({
uid: team.uid,
name: 'Acme Corp',
});
});
it('says nothing for an account that is not a seat', async () => {
const user = await seedUser();
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleWhoami(makeReq(), res),
);
expect(captured.body).not.toHaveProperty('team');
});
it('withholds it from an app actor', async () => {
// Same class as the phone number: a seat's employer is not an
// app's business.
const { seat } = await seatOf('Acme Corp');
const { res, captured } = makeRes();
await runWithContext(
{
actor: makeActor({
user: { uuid: seat.uuid, id: seat.id as number },
app: { uid: 'app-1' },
}),
},
() => handleWhoami(makeReq(), res),
);
expect(captured.body).not.toHaveProperty('team');
});
});
it('returns 401 when no actor is on the context', async () => {
const { res, captured } = makeRes();
@@ -186,10 +263,10 @@ describe('whoami extension — handleWhoami', () => {
await runWithContext(
{
actor: {
actor: makeActor({
user: { uuid: user.uuid, id: user.id as number },
app: { uid: 'app-test-actor' },
},
}),
},
() => handleWhoami(makeReq(), res),
);
@@ -238,10 +315,10 @@ describe('whoami extension — handleWhoami', () => {
const { res, captured } = makeRes();
await runWithContext(
{
actor: {
actor: makeActor({
user: { uuid: user.uuid, id: user.id as number },
app: { uid: 'app-test-actor' },
},
}),
},
() => handleWhoami(makeReq(), res),
);
@@ -269,11 +346,11 @@ describe('whoami extension — handleWhoami', () => {
expect(stored?.card_fingerprint).toBe('fp_ABC123');
for (const actor of [
{ user: { uuid: user.uuid, id: user.id as number } },
{
makeActor({ user: { uuid: user.uuid, id: user.id as number } }),
makeActor({
user: { uuid: user.uuid, id: user.id as number },
app: { uid: 'app-test-actor' },
},
}),
]) {
const { res, captured } = makeRes();
await runWithContext({ actor }, () =>
+29 -19
View File
@@ -1,6 +1,9 @@
import { Context } from '@heyputer/backend/src/core';
import { extension } from '@heyputer/backend/src/extensions';
import { isCardFallbackEligible } from '@heyputer/backend/src/util/cardFallback.js';
import {
cardFallbackDepsFrom,
isCardFallbackEligible,
} from '@heyputer/backend/src/util/cardFallback.js';
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
import type { Request, Response } from 'express';
@@ -107,7 +110,7 @@ export const handleWhoami = async (
return;
}
const isUser = !actor.app;
const isUser = !actor.effectiveApp;
const user = await stores.user.getById(actor.user.id);
if (!user) {
res.status(404).json({ error: 'User not found' });
@@ -156,6 +159,8 @@ export const handleWhoami = async (
// every app actor. Only the verification flag ships.
requires_phone_verification: user.requires_phone_verification,
requires_card_verification: user.requires_card_verification,
// A seat reaches nothing until it replaces its admin's password.
requires_password_change: user.requires_password_change,
// The SMS-to-card escape hatch: true once this user is out of SMS send
// attempts and may verify a card instead. It has to ship from here
// because /send-confirm-phone can no longer say so — by the time the
@@ -168,19 +173,7 @@ export const handleWhoami = async (
extension.config,
user,
async (key) => (await stores.kv.get({ key })).res,
{
smsConfigured: () =>
Boolean(clients.prelude?.isConfigured()),
probeCardVerification: async () => {
const status = { enabled: null as boolean | null };
await clients.event?.emitAndWait(
'puter.card-verification.status',
status,
{},
);
return status.enabled;
},
},
cardFallbackDepsFrom(clients),
)
: false,
desktop_bg_url: user.desktop_bg_url,
@@ -251,6 +244,23 @@ export const handleWhoami = async (
details.directories = directories;
}
// The team an account belongs to, when it is one a team pays for. User
// actors only, and only where teams are on.
if (isUser && extension.config.teams_enabled === true) {
try {
const seat = await stores.team.getOrgSeat(user.id);
if (seat) {
details.team = {
uid: seat.team_uid,
name: seat.team_name ?? null,
};
}
} catch (e) {
// Never fail whoami over this; the account still works without it.
console.warn('[whoami] team lookup failed:', (e as Error).message);
}
}
// Last activity
const lastActivityTs = toUnixSeconds(user.last_activity_ts);
if (lastActivityTs !== undefined) {
@@ -277,8 +287,9 @@ export const handleWhoami = async (
delete details.referral_code;
}
if (actor.app) {
details.app_name = actor.app.uid;
const app = actor.effectiveApp;
if (app) {
details.app_name = app.uid;
}
try {
@@ -292,8 +303,7 @@ export const handleWhoami = async (
}
const subscription = details.subscription as
| { offering?: Record<string, unknown> }
| undefined;
{ offering?: Record<string, unknown> } | undefined;
if (subscription?.offering) {
delete subscription.offering.group;
delete subscription.offering.benefits;
+93 -638
View File
@@ -16,7 +16,7 @@
"@aws-sdk/s3-request-presigner": "^3.1028.0",
"dedent": "^1.5.3",
"javascript-time-ago": "^2.5.11",
"libphonenumber-js": "1.13.12",
"libphonenumber-js": "1.13.13",
"miniflare": "^4.20260617.1",
"open": "^10.1.0"
},
@@ -3268,9 +3268,9 @@
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.15.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
"integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
"version": "3.15.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6055,9 +6055,9 @@
}
},
"node_modules/@types/node": {
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"version": "24.13.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
"integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
@@ -7960,14 +7960,15 @@
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"destroy": "1.2.0",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
@@ -7975,6 +7976,10 @@
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/compression/node_modules/debug": {
@@ -8596,6 +8601,16 @@
"node": ">=6"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -11112,10 +11127,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",
@@ -11478,9 +11492,9 @@
}
},
"node_modules/libphonenumber-js": {
"version": "1.13.12",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.12.tgz",
"integrity": "sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA==",
"version": "1.13.13",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.13.tgz",
"integrity": "sha512-hV6SmWnyQ8U1QXhhqbqYBHIStNnscG7A5XbGodgdHhf/8N5PwhYpJA5U/yHDjYwPYTd8uNGXqq416XctuvK2PA==",
"license": "MIT"
},
"node_modules/light-my-request": {
@@ -13318,19 +13332,18 @@
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.22.6",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.6.tgz",
"integrity": "sha512-fPKmeDGUzvFP7bMD5SASlJ5zIgvCC4hbanTmhbUlEmhyrY1hR4Hi3xLOWgTd3luYjLVifx6uGvMNJ2m/LlqEpg==",
"version": "3.24.4",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
"integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"iconv-lite": "^0.7.3",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.4.0"
"sql-escaper": "^1.5.1"
},
"engines": {
"node": ">= 8.0"
@@ -13487,9 +13500,9 @@
}
},
"node_modules/nodemailer": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.6.tgz",
"integrity": "sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg==",
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
"integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -14211,6 +14224,12 @@
"node": ">= 10.12"
}
},
"node_modules/postal-mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-3.0.0.tgz",
"integrity": "sha512-Z4a9ar2Bv3YpK3IXag+Yda30k7bMZfpRuUGyqtHnZ2pjHG8Bl62EhZIk4n1dzv00gfzP9g+94e9kd8+XmjVWLA==",
"license": "MIT-0"
},
"node_modules/postcss": {
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
@@ -14317,9 +14336,9 @@
}
},
"node_modules/prettier": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"dev": true,
"license": "MIT",
"bin": {
@@ -17174,9 +17193,9 @@
}
},
"node_modules/wrangler": {
"version": "4.124.0",
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.124.0.tgz",
"integrity": "sha512-75euoZKjVTJYFy+Xhctt/5JlZL4M6A4xmovZsUlep+6GHcCm14n9VtdGzNIybOW2t8wuNxRj5iMUwjT5E7Ctog==",
"version": "4.132.0",
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.132.0.tgz",
"integrity": "sha512-61HD7Unw3g7h9zSQoqldzfL3MEbCKUVfwtlMgfSQtjYwMjBD8z83K/EXnYGv75R5hczx3grFfjYH+tdmJm5PHg==",
"dev": true,
"license": "MIT OR Apache-2.0",
"dependencies": {
@@ -17184,10 +17203,10 @@
"@cloudflare/unenv-preset": "2.16.1",
"blake3-wasm": "2.1.5",
"esbuild": "0.28.1",
"miniflare": "5.20260815.0-alpha",
"miniflare": "5.20260915.0-alpha",
"path-to-regexp": "6.3.0",
"unenv": "2.0.0-rc.24",
"workerd": "1.20260815.1"
"workerd": "1.20260915.1"
},
"bin": {
"cf-wrangler": "bin/cf-wrangler.js",
@@ -17201,7 +17220,7 @@
"fsevents": "2.3.3"
},
"peerDependencies": {
"@cloudflare/workers-types": "^5.20260815.1"
"@cloudflare/workers-types": "^5.20260915.1"
},
"peerDependenciesMeta": {
"@cloudflare/workers-types": {
@@ -17210,9 +17229,9 @@
}
},
"node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": {
"version": "1.20260815.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260815.1.tgz",
"integrity": "sha512-7PsLdcz6pT9EMd1EJGZEgMyYRfs0CHxGs62PS2L1w3s6+xGmQcRXKm/zoMftmqZF45JBa4MzFeownRKbRt/x5g==",
"version": "1.20260915.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260915.1.tgz",
"integrity": "sha512-EFjNxN4l5caJoHADhchROvO52EFFt+zmJvFZRmwX6UU9pE53BAbF94wRBhbpylv5Ethsd1TdJNwYuUPgZYbETg==",
"cpu": [
"x64"
],
@@ -17227,9 +17246,9 @@
}
},
"node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": {
"version": "1.20260815.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260815.1.tgz",
"integrity": "sha512-60wtg8ng7FVWeOg/UMbZ9Ye0sslpRRAKoftPbdtuH2volq676quxVr6Zm2EjVULH/JFZeCn72dbLlrnbh0Mpcw==",
"version": "1.20260915.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260915.1.tgz",
"integrity": "sha512-Xm2jbLw8M/o5YkuI5VsHCVU4rgnzr3ROl+Awk7FCBJ3mtLJNuxPtJNB/zZI+/t+3QtgsSPnmTisS5Gq6/KLQ/A==",
"cpu": [
"arm64"
],
@@ -17244,9 +17263,9 @@
}
},
"node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": {
"version": "1.20260815.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260815.1.tgz",
"integrity": "sha512-MuqKIHPo0Qyo8MZMmy0lP2B5PeAL7f4T9Fu4Usk3QdbV4JIrKG/OoybN3Ign7m/Dff+L1Oo/ZHydB+hEg1ueFw==",
"version": "1.20260915.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260915.1.tgz",
"integrity": "sha512-hkQp+PWBp7U6uDMd6hqUf4lfErPV79jolLaK6j4U50HrHC4jqWxESPemkxEk8H7p9jTKkZ9I9Z0nA5DLAE0J6g==",
"cpu": [
"x64"
],
@@ -17261,9 +17280,9 @@
}
},
"node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": {
"version": "1.20260815.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260815.1.tgz",
"integrity": "sha512-XNFtJ5rIqJxnY6ISjkfbhT/ODiWJ6LcBvNbntuPD6I/F2k7aZeKgPaXrvWvKde66LXyzFKzc8Hn+Ydx4shevQg==",
"version": "1.20260915.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260915.1.tgz",
"integrity": "sha512-3yi8/irVGNQbuYTSmLXcEGupk4Js42jU3kyAx88MDbwGQUa34VbucKcNwHwfhqwEUNaIrn8ohlOEcNJChQl0Og==",
"cpu": [
"arm64"
],
@@ -17278,9 +17297,9 @@
}
},
"node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": {
"version": "1.20260815.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260815.1.tgz",
"integrity": "sha512-PiIUWrhbMg3quolwjgMvPOd75vKESjT4aDm7nL6mSjL5IOgmpO/zKstXnYfnEH3pq7sC0UCvKlF8ZPcfsh8NMw==",
"version": "1.20260915.1",
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260915.1.tgz",
"integrity": "sha512-+FQ3o/grK2tQy6dw3AqSOYWsYROZqxCQKBqSHQVcXvr2x2QetCT64OrvAAJ0hpQAokREFwQyzsNmMiZbAdW0zw==",
"cpu": [
"x64"
],
@@ -17294,525 +17313,17 @@
"node": ">=16"
}
},
"node_modules/wrangler/node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz",
"integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.1"
}
},
"node_modules/wrangler/node_modules/@img/sharp-darwin-x64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz",
"integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.1"
}
},
"node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz",
"integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz",
"integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz",
"integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz",
"integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz",
"integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz",
"integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz",
"integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/wrangler/node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz",
"integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz",
"integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz",
"integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/wrangler/node_modules/@img/sharp-linux-arm": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz",
"integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.1"
}
},
"node_modules/wrangler/node_modules/@img/sharp-linux-arm64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz",
"integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.1"
}
},
"node_modules/wrangler/node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz",
"integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.1"
}
},
"node_modules/wrangler/node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz",
"integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.1"
}
},
"node_modules/wrangler/node_modules/@img/sharp-linux-s390x": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz",
"integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.1"
}
},
"node_modules/wrangler/node_modules/@img/sharp-linux-x64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz",
"integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.1"
}
},
"node_modules/wrangler/node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz",
"integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1"
}
},
"node_modules/wrangler/node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz",
"integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.1"
}
},
"node_modules/wrangler/node_modules/@img/sharp-win32-arm64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz",
"integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/wrangler/node_modules/@img/sharp-win32-ia32": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz",
"integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/wrangler/node_modules/@img/sharp-win32-x64": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz",
"integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/wrangler/node_modules/miniflare": {
"version": "5.20260815.0-alpha",
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260815.0-alpha.tgz",
"integrity": "sha512-YAaGj4Sh5f4fqHKiMQ8zRHDOOM5IGUVtMhnLIeyjuQfU+9P6hcOTrHUVtbfj/ZPay9Kzik4pWELB39pGgefjiQ==",
"version": "5.20260915.0-alpha",
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260915.0-alpha.tgz",
"integrity": "sha512-ApLsq9X2jbpkLAPXx89afGvuNxAFzd1rUK+nzfBSLu+34YWc2Q2YlgH6f3kSEEQYZ2gAlC5Q5IP6tDNNg/1KqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "0.8.1",
"sharp": "0.35.2",
"sharp": "0.35.4",
"undici": "7.29.0",
"workerd": "1.20260815.1",
"workerd": "1.20260915.1",
"ws": "8.21.0",
"youch": "4.1.0-beta.10"
},
@@ -17827,55 +17338,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/wrangler/node_modules/sharp": {
"version": "0.35.2",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz",
"integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==",
"node_modules/wrangler/node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.4"
},
"license": "MIT",
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.2",
"@img/sharp-darwin-x64": "0.35.2",
"@img/sharp-freebsd-wasm32": "0.35.2",
"@img/sharp-libvips-darwin-arm64": "1.3.1",
"@img/sharp-libvips-darwin-x64": "1.3.1",
"@img/sharp-libvips-linux-arm": "1.3.1",
"@img/sharp-libvips-linux-arm64": "1.3.1",
"@img/sharp-libvips-linux-ppc64": "1.3.1",
"@img/sharp-libvips-linux-riscv64": "1.3.1",
"@img/sharp-libvips-linux-s390x": "1.3.1",
"@img/sharp-libvips-linux-x64": "1.3.1",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.1",
"@img/sharp-libvips-linuxmusl-x64": "1.3.1",
"@img/sharp-linux-arm": "0.35.2",
"@img/sharp-linux-arm64": "0.35.2",
"@img/sharp-linux-ppc64": "0.35.2",
"@img/sharp-linux-riscv64": "0.35.2",
"@img/sharp-linux-s390x": "0.35.2",
"@img/sharp-linux-x64": "0.35.2",
"@img/sharp-linuxmusl-arm64": "0.35.2",
"@img/sharp-linuxmusl-x64": "0.35.2",
"@img/sharp-webcontainers-wasm32": "0.35.2",
"@img/sharp-win32-arm64": "0.35.2",
"@img/sharp-win32-ia32": "0.35.2",
"@img/sharp-win32-x64": "0.35.2"
"node": ">=20.18.1"
}
},
"node_modules/wrangler/node_modules/workerd": {
"version": "1.20260815.1",
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260815.1.tgz",
"integrity": "sha512-8bArFkHmlp7qFEKVPyNzDzHzS35gc2fg0PYBcDtaNLF7UCDryCX2BQnpkUkTHYIy824IRrHOTwOEoTj0sUO2Fg==",
"version": "1.20260915.1",
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260915.1.tgz",
"integrity": "sha512-9Nqh22lW/ondXnMkFTbn65MHZLdsE2izJ9sFIVdqAufQTEVWnUkcpp7T5JtLr4ASN/VuxlTVZ5xgYntyo+d4ag==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
@@ -17886,11 +17362,11 @@
"node": ">=16"
},
"optionalDependencies": {
"@cloudflare/workerd-darwin-64": "1.20260815.1",
"@cloudflare/workerd-darwin-arm64": "1.20260815.1",
"@cloudflare/workerd-linux-64": "1.20260815.1",
"@cloudflare/workerd-linux-arm64": "1.20260815.1",
"@cloudflare/workerd-windows-64": "1.20260815.1"
"@cloudflare/workerd-darwin-64": "1.20260915.1",
"@cloudflare/workerd-darwin-arm64": "1.20260915.1",
"@cloudflare/workerd-linux-64": "1.20260915.1",
"@cloudflare/workerd-linux-arm64": "1.20260915.1",
"@cloudflare/workerd-windows-64": "1.20260915.1"
}
},
"node_modules/wrap-ansi": {
@@ -18152,7 +17628,7 @@
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.0",
"busboy": "^1.6.0",
"compression": "^1.8.1",
"compression": "^1.8.2",
"cookie-parser": "^1.4.7",
"dedent": "^1.5.3",
"dynalite": "^4.0.0",
@@ -18170,7 +17646,7 @@
"mime-types": "^2.1.35",
"murmurhash": "^2.0.1",
"mysql2": "^3.22.6",
"nodemailer": "^9.0.6",
"nodemailer": "^9.1.1",
"openai": "^6.34.0",
"otpauth": "^9.5.2",
"pg": "^8.21.0",
@@ -18191,7 +17667,7 @@
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/mime-types": "^2.1.4",
"@types/node": "^24.13.3",
"@types/node": "^24.13.6",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.6.1",
"@types/validator": "^13.15.10",
@@ -18806,28 +18282,6 @@
"node": ">=0.10.0"
}
},
"src/docs/node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"src/docs/node_modules/jsdom": {
"version": "26.1.0",
"license": "MIT",
@@ -18983,12 +18437,13 @@
},
"src/puter-js": {
"name": "@heyputer/puter.js",
"version": "2.6.2",
"version": "2.6.3",
"license": "Apache-2.0",
"dependencies": {
"@heyputer/kv.js": "^0.2.1",
"open": "^10.2.0",
"path-browserify": "1.0.1",
"postal-mime": "^3.0.0",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
+1 -1
View File
@@ -76,7 +76,7 @@
"@aws-sdk/s3-request-presigner": "^3.1028.0",
"dedent": "^1.5.3",
"javascript-time-ago": "^2.5.11",
"libphonenumber-js": "1.13.12",
"libphonenumber-js": "1.13.13",
"miniflare": "^4.20260617.1",
"open": "^10.1.0"
},
@@ -17,24 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Optional ClickHouse client.
*
* ClickHouse is NOT a hard dependency. Core Puter runs without it — the app
* stats read path (open / unique-user counts) falls back to the primary SQL
* database. `this.clients.clickhouse` is therefore typed as optional and is
* `undefined` in default and self-hosted setups; callers MUST branch on its
* presence and fall back to SQL.
*
* A production deployment can register a real ClickHouse client via an
* extension (`extension.registerClient('clickhouse', client)`) to offload the
* analytics queries off the primary database and keep the stats path fast at
* scale. When registered, the instance flows into `this.clients.clickhouse`
* everywhere it's typed.
*
* Only the surface the stats path actually consumes is declared here. Extend
* this interface (don't widen to `any`) when a new query shape is needed.
*/
// Optional analytics client, registered by an extension. Only the surface the
// app-stats path consumes is declared; extend it rather than widening to `any`.
export interface ClickhouseQueryResult {
json<T = Record<string, unknown>>(): Promise<T[]>;
}
@@ -693,31 +693,19 @@ describe('MySQLDatabaseClient — pool lifecycle', () => {
expect(createdPools[1].ended).toBe(true);
});
it('closes the pools once the drain window elapses', async () => {
it('leaves the pools open through prepare-shutdown; only onServerShutdown closes them', async () => {
vi.useFakeTimers();
try {
const client = await startClient();
await client.onServerPrepareShutdown();
expect(createdPools[0].ended).toBe(false);
// No timer closes the pools on its own — the layers above are
// still draining through them for as long as the process drains.
await vi.advanceTimersByTimeAsync(60_000);
expect(createdPools[0].ended).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('cancels the drain timer when shutdown arrives first', async () => {
vi.useFakeTimers();
try {
const client = await startClient();
await client.onServerPrepareShutdown();
// A second prepare is a no-op — only one drain timer may exist.
await client.onServerPrepareShutdown();
expect(vi.getTimerCount()).toBe(1);
expect(createdPools[0].ended).toBe(false);
await client.onServerShutdown();
expect(vi.getTimerCount()).toBe(0);
expect(createdPools[0].ended).toBe(true);
} finally {
vi.useRealTimers();
@@ -20,7 +20,7 @@
import { readdirSync, readFileSync } from 'fs';
import { isAbsolute, resolve as resolvePath } from 'path';
import { metrics } from '@opentelemetry/api';
import { createPool, type Pool } from 'mysql2';
import { createPool, ExecuteValues, type Pool } from 'mysql2';
import { Span } from '../../util/span.js';
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
import { SQLBatcher } from './SQLBatcher.js';
@@ -54,9 +54,10 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
private replicaPool!: Pool;
private db!: SQLBatcher;
private dbReplica!: SQLBatcher;
/** Primary pool, SELECT-only: same rows as `db`, without the transaction. */
private dbPrimaryRead!: SQLBatcher;
private configuration = Configuration.SINGLE;
private shutdownStarted = false;
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
constructor(config: IConfig) {
super(config);
@@ -79,6 +80,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
console.log('[mysql] connected to primary');
this.db = this.createPrimaryBatcher(this.primaryPool);
this.dbPrimaryRead = this.createPrimaryReadBatcher(this.primaryPool);
if (dbConf.replica) {
this.replicaPool = this.createPool(dbConf.replica);
@@ -98,29 +100,12 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
if (this.shutdownStarted) return;
this.shutdownStarted = true;
// Allow in-flight queries to drain before closing pools
const drainMs = 60_000;
console.log(
`[mysql] draining in-flight queries (${drainMs}ms) before closing pools`,
);
this.shutdownTimer = setTimeout(() => {
this.shutdownTimer = null;
this.closeCurrentPools('drain').catch((e) =>
console.error('[mysql] error closing pools after drain', e),
);
}, drainMs);
if (typeof this.shutdownTimer.unref === 'function') {
this.shutdownTimer.unref();
}
// Blocks reinitPrimary/reinitReplica from here on. Pools stay open;
// onServerShutdown closes them after the layers above have drained.
console.log('[mysql] entering drain mode');
}
override async onServerShutdown(): Promise<void> {
if (this.shutdownTimer) {
clearTimeout(this.shutdownTimer);
this.shutdownTimer = null;
}
await this.closeCurrentPools('shutdown');
}
@@ -162,7 +147,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
query: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
const result = await this.db.execute(query, params);
const result = await this.dbPrimaryRead.execute(query, params);
if (!result) return [];
return (result[0] as Record<string, unknown>[]) ?? [];
}
@@ -201,7 +186,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
await conn.beginTransaction();
try {
for (const { statement, values } of entries) {
await conn.execute(statement, values);
await conn.execute(statement, values as ExecuteValues);
}
await conn.commit();
} catch (err) {
@@ -224,7 +209,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
// Run both reads in parallel — prefer replica when it returns rows,
// otherwise fall back to primary to handle replication lag.
const primaryPromise = this.db.execute(query, params);
const primaryPromise = this.dbPrimaryRead.execute(query, params);
try {
const replicaResult = await this.dbReplica.execute(query, params);
if (
@@ -354,6 +339,22 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
});
}
/**
* Reads that must see the primary still only read, so they skip the
* batcher's transaction wrapper. Worth a separate batcher because BEGIN and
* COMMIT are round trips: against a primary in another region they cost
* more than the query.
*/
private createPrimaryReadBatcher(pool: Pool): SQLBatcher {
return new SQLBatcher(pool, {
maxTimeInQueue: 30,
maxBatchSize: 5,
poolLabel: 'primary',
readOnly: true,
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
});
}
private createReplicaBatcher(pool: Pool): SQLBatcher {
return new SQLBatcher(pool, {
maxTimeInQueue: 10,
@@ -378,6 +379,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
database: dbConf.database ?? 'puter',
});
this.db = this.createPrimaryBatcher(this.primaryPool);
this.dbPrimaryRead = this.createPrimaryReadBatcher(this.primaryPool);
if (this.configuration === Configuration.SINGLE) {
this.replicaPool = this.primaryPool;
@@ -18,7 +18,7 @@
*/
import type { FieldDef, QueryResult } from 'pg';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { IConfig } from '../../types';
import { DatabaseClientFactory } from './index.js';
import {
@@ -357,4 +357,33 @@ describe('PostgresDatabaseClient', () => {
]);
expect(conn.released).toBe(true);
});
it('leaves the pool open through prepare-shutdown; only onServerShutdown closes it', async () => {
vi.useFakeTimers();
try {
const pool = new RecordingPool();
let ended = false;
pool.end = async () => {
ended = true;
};
const client = new PostgresDatabaseClient(
postgresConfig(),
() => pool,
);
await client.onServerStart();
await client.onServerPrepareShutdown();
expect(ended).toBe(false);
// No timer closes the pool on its own — the layers above are
// still draining through it for as long as the process drains.
await vi.advanceTimersByTimeAsync(60_000);
expect(ended).toBe(false);
await client.onServerShutdown();
expect(ended).toBe(true);
} finally {
vi.useRealTimers();
}
});
});
@@ -138,7 +138,6 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
private replicaPool!: PostgresPool;
private configuration = Configuration.SINGLE;
private shutdownStarted = false;
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
config: IConfig,
@@ -172,28 +171,12 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
if (this.shutdownStarted) return;
this.shutdownStarted = true;
const drainMs = 60_000;
console.log(
`[postgres] draining in-flight queries (${drainMs}ms) before closing pools`,
);
this.shutdownTimer = setTimeout(() => {
this.shutdownTimer = null;
this.closeCurrentPools().catch((e) =>
console.error('[postgres] error closing pools after drain', e),
);
}, drainMs);
if (typeof this.shutdownTimer.unref === 'function') {
this.shutdownTimer.unref();
}
// Pools stay open; onServerShutdown closes them after the layers above
// have drained through them.
console.log('[postgres] entering drain mode');
}
override async onServerShutdown(): Promise<void> {
if (this.shutdownTimer) {
clearTimeout(this.shutdownTimer);
this.shutdownTimer = null;
}
await this.closeCurrentPools();
}
+18 -10
View File
@@ -284,11 +284,16 @@ export class SQLBatcher {
// individually below. Without this, MySQL would commit every
// statement up to the failure point and a per-item retry would
// misreport already-committed inserts as duplicate-key failures.
//
// A read-only batch has nothing to roll back, and the wrapper is not
// free: BEGIN and COMMIT are round trips of their own, which triples
// the cost of a read against a pool in another region.
const wrapInTransaction = !this.readOnly;
let batchSucceeded = false;
try {
await connection.beginTransaction();
if (wrapInTransaction) await connection.beginTransaction();
const [results, fields] = await connection.query(query, values);
await connection.commit();
if (wrapInTransaction) await connection.commit();
batchSucceeded = true;
this.#consecutiveFailures = 0;
for (let i = 0; i < batch.length; i++) {
@@ -296,10 +301,12 @@ export class SQLBatcher {
b.resolve([results[i], fields?.[i]]);
}
} catch (batchError) {
try {
await connection.rollback();
} catch (rollbackError) {
console.warn('SQLBatcher rollback failed:', rollbackError);
if (wrapInTransaction) {
try {
await connection.rollback();
} catch (rollbackError) {
console.warn('SQLBatcher rollback failed:', rollbackError);
}
}
console.warn(
'SQLBatcher batch failed; retrying items individually:',
@@ -311,10 +318,11 @@ export class SQLBatcher {
if (batchSucceeded) return;
// Per-item fallback. The transaction was rolled back so no statement
// committed; re-running each item independently produces clean
// success/failure outcomes for each caller. Concurrency is capped to
// avoid briefly saturating the pool when a large batch fails.
// Per-item fallback. Nothing from the batch is live — it was rolled
// back, or carried only SELECTs — so re-running each item
// independently produces clean success/failure outcomes for each
// caller. Concurrency is capped to avoid briefly saturating the pool
// when a large batch fails.
flushFailureCounter.add(1, this.#metricAttrs);
fallbackInvocationsCounter.add(1, this.#metricAttrs);
@@ -86,6 +86,45 @@ describe('SQLBatcher', () => {
expect(conn.release).toHaveBeenCalledTimes(1);
});
it('skips the transaction wrapper on a readOnly batcher', async () => {
const conn = makeConnection(happyBatch);
const { pool } = makePool(conn);
const batcher = new SQLBatcher(pool, {
maxTimeInQueue: 5,
readOnly: true,
});
const [a, b] = await Promise.all([
batcher.query('SELECT a', []),
batcher.query('SELECT b', []),
]);
expect(a[0]).toEqual([{ n: 0 }]);
expect(b[0]).toEqual([{ n: 1 }]);
// BEGIN and COMMIT are round trips; a SELECT-only batch has nothing to
// roll back, so it must not pay for them.
expect(conn.beginTransaction).not.toHaveBeenCalled();
expect(conn.commit).not.toHaveBeenCalled();
expect(conn.release).toHaveBeenCalledTimes(1);
});
it('does not roll back a failed readOnly batch', async () => {
const conn = makeConnection((sql: string) => {
if (isBatchQuery(sql)) throw makeError('ER_LOCK_DEADLOCK');
return [[{ n: 0 }], undefined];
});
const { pool } = makePool(conn);
const batcher = new SQLBatcher(pool, {
maxTimeInQueue: 5,
readOnly: true,
});
await Promise.all([
batcher.query('SELECT a', []),
batcher.query('SELECT b', []),
]);
expect(conn.rollback).not.toHaveBeenCalled();
});
it('drops the oldest item with reason queueOverflow at the high-water mark', async () => {
const conn = makeConnection(happyBatch);
const { pool } = makePool(conn);
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
const CURRENT_SCHEMA_VERSION = 79;
const CURRENT_SCHEMA_VERSION = 82;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -276,6 +276,68 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
]);
});
it('deletes the subdomain rows an app owns when the app is deleted', async () => {
const [user] = (await client.read(
'SELECT MIN(`id`) AS id FROM `user`',
)) as { id: number }[];
await client.write(
'INSERT INTO `apps` (`uid`, `owner_user_id`, `name`, `title`, `index_url`) ' +
'VALUES (?, ?, ?, ?, ?)',
[
'app-cascade-test',
user.id,
'cascade-test',
'cascade-test',
'https://cascade.test/',
],
);
const [app] = (await client.read(
'SELECT `id` FROM `apps` WHERE `uid` = ?',
['app-cascade-test'],
)) as { id: number }[];
const insertSubdomain = (
uuid: string,
name: string,
appOwner: number | null,
) =>
client.write(
'INSERT INTO `subdomains` (`uuid`, `subdomain`, `user_id`, `app_owner`) ' +
'VALUES (?, ?, ?, ?)',
[uuid, name, user.id, appOwner],
);
await insertSubdomain('sd-cascade-owned', 'cascade-owned', app.id);
await insertSubdomain('sd-cascade-unowned', 'cascade-unowned', null);
await client.write('DELETE FROM `apps` WHERE `id` = ?', [app.id]);
await expect(
client.read(
'SELECT `subdomain` FROM `subdomains` WHERE `subdomain` LIKE ? ORDER BY `id`',
['cascade-%'],
),
).resolves.toEqual([{ subdomain: 'cascade-unowned' }]);
});
it('keeps every subdomains column through the 0086 rebuild', async () => {
const columns = (await client.read(
"SELECT `name` FROM pragma_table_info('subdomains') ORDER BY `cid`",
)) as { name: string }[];
expect(columns.map((c) => c.name)).toEqual([
'id',
'uuid',
'subdomain',
'user_id',
'root_dir_id',
'associated_app_id',
'ts',
'app_owner',
'protected',
'domain',
'database_id',
'preamble_version',
]);
});
it('constrains team shares that the user-holder index cannot', async () => {
const [user] = (await client.read(
'SELECT MIN(`id`) AS id FROM `user`',
@@ -113,6 +113,9 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[76, ['0081_event-subscriptions-indexes.sql']],
[77, ['0082_temp-password-expiry.sql']],
[78, ['0083_team-directory.sql']],
[79, ['0084_share-anyone-with-link.sql']],
[80, ['0085_event-subscriptions-include-value.sql']],
[81, ['0086_subdomains-app-owner-cascade.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -0,0 +1,42 @@
-- 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/>.
-- See sqlite/0084_share-anyone-with-link.sql for the column rationale.
-- No per-file applied-state tracking, so the column goes through _puter_add_col
-- and the index through a guarded procedure, as mysql_mig_34 does.
CALL _puter_add_col('share', 'anyone', '`anyone` tinyint(1) DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_share_anyone_index;
DELIMITER //
CREATE PROCEDURE _puter_add_share_anyone_index()
BEGIN
-- Every other row leaves `anyone` NULL, so this binds only link shares.
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'share'
AND INDEX_NAME = 'idx_share_anyone'
) THEN
ALTER TABLE `share` ADD UNIQUE INDEX `idx_share_anyone` (`fsentry_id`, `anyone`);
END IF;
END//
DELIMITER ;
CALL _puter_add_share_anyone_index();
DROP PROCEDURE IF EXISTS _puter_add_share_anyone_index;
@@ -0,0 +1,22 @@
-- 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/>.
-- See sqlite/0085_event-subscriptions-include-value.sql for the column
-- rationale. No per-file applied-state tracking, so it goes through
-- _puter_add_col as mysql_mig_39 does.
CALL _puter_add_col('event_subscriptions', 'include_value', '`include_value` tinyint(1) NOT NULL DEFAULT 0');
@@ -0,0 +1,75 @@
-- 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 0086: `subdomains.app_owner` cascades on app
-- delete instead of nulling. The existing constraint is found by column
-- rather than by name, since an older database need not carry the name
-- mysql_mig_1 declares. Guarded procedure as mysql_mig_34: a replay sees
-- DELETE_RULE already CASCADE and does nothing.
DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade;
DELIMITER //
CREATE PROCEDURE _puter_subdomains_app_owner_cascade()
BEGIN
DECLARE fk_name VARCHAR(64) DEFAULT NULL;
-- A subquery, not SELECT ... INTO: no matching row yields NULL quietly.
SET fk_name = (
SELECT rc.CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
AND kcu.TABLE_NAME = rc.TABLE_NAME
WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
AND rc.TABLE_NAME = 'subdomains'
AND rc.REFERENCED_TABLE_NAME = 'apps'
AND kcu.COLUMN_NAME = 'app_owner'
AND rc.DELETE_RULE <> 'CASCADE'
LIMIT 1
);
IF fk_name IS NOT NULL THEN
SET @s := CONCAT('ALTER TABLE `subdomains` DROP FOREIGN KEY `', fk_name, '`');
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
IF NOT EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
AND kcu.TABLE_NAME = rc.TABLE_NAME
WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
AND rc.TABLE_NAME = 'subdomains'
AND rc.REFERENCED_TABLE_NAME = 'apps'
AND kcu.COLUMN_NAME = 'app_owner'
AND rc.DELETE_RULE = 'CASCADE'
) THEN
ALTER TABLE `subdomains` ADD CONSTRAINT `fk_subdomains_app_owner`
FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END//
DELIMITER ;
CALL _puter_subdomains_app_owner_cascade();
DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade;
@@ -0,0 +1,24 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- See sqlite/0084_share-anyone-with-link.sql for the column rationale.
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
ALTER TABLE share ADD COLUMN IF NOT EXISTS anyone smallint;
-- Default NULLS DISTINCT, matching the other engines, so this binds only link shares.
CREATE UNIQUE INDEX IF NOT EXISTS idx_share_anyone ON share (fsentry_id, anyone);
@@ -0,0 +1,22 @@
-- 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/>.
-- See sqlite/0085_event-subscriptions-include-value.sql for the column
-- rationale. Idempotent via IF NOT EXISTS; there is no per-file applied-state
-- tracking.
ALTER TABLE event_subscriptions ADD COLUMN IF NOT EXISTS include_value smallint NOT NULL DEFAULT 0;
@@ -0,0 +1,59 @@
-- 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 0086: `subdomains.app_owner` cascades on app
-- delete instead of nulling. The existing constraint is found by column, not
-- by name. Idempotent: there is no per-file applied-state tracking, and a
-- replay sees the cascade already in place and does nothing.
DO $$
DECLARE
fk_name text;
BEGIN
SELECT c.conname INTO fk_name
FROM pg_constraint c
JOIN pg_attribute a
ON a.attrelid = c.conrelid
AND a.attnum = ANY (c.conkey)
WHERE c.conrelid = 'subdomains'::regclass
AND c.contype = 'f'
AND c.confrelid = 'apps'::regclass
AND a.attname = 'app_owner'
AND c.confdeltype <> 'c'
LIMIT 1;
IF fk_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE subdomains DROP CONSTRAINT %I', fk_name);
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_attribute a
ON a.attrelid = c.conrelid
AND a.attnum = ANY (c.conkey)
WHERE c.conrelid = 'subdomains'::regclass
AND c.contype = 'f'
AND c.confrelid = 'apps'::regclass
AND a.attname = 'app_owner'
AND c.confdeltype = 'c'
) THEN
ALTER TABLE subdomains ADD CONSTRAINT subdomains_app_owner_fkey
FOREIGN KEY (app_owner) REFERENCES apps (id)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
@@ -0,0 +1,27 @@
-- 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/>.
-- "Anyone with the link": a share row with no holder of any kind — not a
-- person, not a team, not an invite. `anyone` is 1 on such a row and NULL on
-- every other, so the unique index binds only these: one per node, whoever
-- last set it. No permission row stands behind it; ACLService reads the share
-- row itself, and honours it only while the owner's plan covers link sharing.
ALTER TABLE `share` ADD COLUMN `anyone` INTEGER DEFAULT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS `idx_share_anyone`
ON `share` (`fsentry_id`, `anyone`);
@@ -0,0 +1,23 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- A key-value subscription may ask for the key's new value to ride along with
-- each delivery. Off by default: a delivery that carries nothing but the key is
-- what every existing row was made under.
ALTER TABLE `event_subscriptions`
ADD COLUMN `include_value` INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,52 @@
-- 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/>.
-- A deleted app takes the subdomain rows it owns (its hosted sites and its
-- workers) with it. `SET NULL` left them behind as unowned rows, and an
-- unowned worker row is redeployed with the account's own credential.
-- SQLite cannot alter a constraint, so the table is rebuilt as 0043 does.
PRAGMA foreign_keys = OFF;
CREATE TABLE `subdomains_new` (
`id` INTEGER PRIMARY KEY,
`uuid` varchar(40) DEFAULT NULL,
`subdomain` varchar(64) NOT NULL,
`user_id` int(10) NOT NULL,
`root_dir_id` int(10) DEFAULT NULL,
`associated_app_id` int(10) DEFAULT NULL,
`ts` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`app_owner` int(10) DEFAULT NULL,
`protected` tinyint(1) DEFAULT '0',
`domain` varchar(256) DEFAULT NULL,
`database_id` varchar(40) DEFAULT NULL,
`preamble_version` varchar(64) DEFAULT NULL,
FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO `subdomains_new`
(`id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`,
`app_owner`, `protected`, `domain`, `database_id`, `preamble_version`)
SELECT
`id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`,
`app_owner`, `protected`, `domain`, `database_id`, `preamble_version`
FROM `subdomains`;
DROP TABLE `subdomains`;
ALTER TABLE `subdomains_new` RENAME TO `subdomains`;
PRAGMA foreign_keys = ON;
@@ -559,3 +559,118 @@ describe('DDBClient — unprocessed batch items', () => {
).resolves.toEqual({ ConsumedCapacity: [] });
});
});
// UnprocessedKeys are batchGet's analogue of UnprocessedItems above, driven
// against the same kind of wire-format stub.
describe('DDBClient — unprocessed batch keys', () => {
let server: Server;
let endpoint: string;
let responses: unknown[];
let requestCount = 0;
beforeAll(async () => {
server = createServer((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
const next = responses.shift() ?? {};
requestCount += 1;
res.writeHead(200, {
'content-type': 'application/x-amz-json-1.0',
});
res.end(JSON.stringify(next));
});
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve),
);
endpoint = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
const stubClient = () =>
new DDBClient({
port: 0,
extensions: [],
dynamo: {
aws: { access_key: 'a', secret_key: 'b', region: 'us-west-2' },
endpoint,
},
} as unknown as IConfig);
const unprocessed = (pk: string) => ({
UnprocessedKeys: {
[TABLE]: { Keys: [{ pk: { S: pk } }] },
},
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 1 }],
});
it('retries leftover keys and merges responses and capacity across attempts', async () => {
requestCount = 0;
responses = [
unprocessed('retry-me'),
{
Responses: {
[TABLE]: [{ pk: { S: 'retry-me' }, v: { N: '1' } }],
},
UnprocessedKeys: {},
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 2 }],
},
];
const result = await stubClient().batchGet([
{ table: TABLE, items: { pk: 'retry-me' } },
]);
expect(requestCount).toBe(2);
expect(result.Responses?.[TABLE]).toEqual([{ pk: 'retry-me', v: 1 }]);
expect(result.ConsumedCapacity).toEqual([
{ TableName: TABLE, CapacityUnits: 3 },
]);
});
it('gives up with a bad-request error when keys never drain', async () => {
requestCount = 0;
responses = Array.from({ length: 12 }, () => unprocessed('stuck'));
await expect(
stubClient().batchGet([{ table: TABLE, items: { pk: 'stuck' } }]),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
message: 'Failed to batch get all items from DynamoDB',
});
// One initial attempt plus the full retry budget.
expect(requestCount).toBe(9);
}, 30_000);
it('tolerates a response that reports no items or capacity at all', async () => {
requestCount = 0;
responses = [{ Responses: {}, UnprocessedKeys: {} }];
await expect(
stubClient().batchGet([{ table: TABLE, items: { pk: 'quiet' } }]),
).resolves.toEqual({ Responses: {}, ConsumedCapacity: [] });
expect(requestCount).toBe(1);
});
it('ignores capacity entries with no table name', async () => {
requestCount = 0;
responses = [
{
Responses: {},
UnprocessedKeys: {},
ConsumedCapacity: [{ CapacityUnits: 7 }],
},
];
await expect(
stubClient().batchGet([{ table: TABLE, items: { pk: 'anon' } }]),
).resolves.toEqual({ Responses: {}, ConsumedCapacity: [] });
});
});
+81 -17
View File
@@ -56,6 +56,8 @@ const localDynaliteEndpointPromises = new Map<string, Promise<string>>();
const MAX_BATCH_WRITE_ITEMS = 25;
const MAX_BATCH_WRITE_RETRIES = 8;
const BATCH_WRITE_RETRY_BASE_MS = 25;
const MAX_BATCH_GET_RETRIES = 8;
const BATCH_GET_RETRY_BASE_MS = 25;
// In-memory mode gives each DDBClient its own unique key, which keys
// into a fresh dynalite server. This is what test parallelism needs:
@@ -234,6 +236,9 @@ export class DDBClient extends PuterClient {
);
}
// UnprocessedKeys retried with capped exponential backoff, same posture as
// #batchWrite's UnprocessedItems: a batch this size can be throttled or
// partially served, and the caller shouldn't have to know that.
@Span('ddb.batchGet', (params: unknown[]) => ({
'db.batch_size': params.length,
}))
@@ -250,25 +255,84 @@ export class DDBClient extends PuterClient {
{} as Record<string, Record<string, unknown>[]>,
);
const requestItems: BatchGetCommandInput['RequestItems'] =
Object.entries(allRequestItemsPerTable).reduce(
(acc, [table, keyList]) => {
acc[table] = {
Keys: keyList,
ConsistentRead: consistentRead,
};
return acc;
},
{} as NonNullable<BatchGetCommandInput['RequestItems']>,
);
const command = new BatchGetCommand({
RequestItems: requestItems,
ReturnConsumedCapacity: 'TOTAL',
});
let requestItems: BatchGetCommandInput['RequestItems'] = Object.entries(
allRequestItemsPerTable,
).reduce(
(acc, [table, keyList]) => {
acc[table] = {
Keys: keyList,
ConsistentRead: consistentRead,
};
return acc;
},
{} as NonNullable<BatchGetCommandInput['RequestItems']>,
);
const client = await this.#getDocumentClient();
return client.send(command);
const responsesByTable = new Map<string, Record<string, unknown>[]>();
const consumedCapacityByTable = new Map<string, number>();
for (let attempt = 0; attempt <= MAX_BATCH_GET_RETRIES; attempt++) {
if (Object.keys(requestItems).length === 0) break;
const response = await client.send(
new BatchGetCommand({
RequestItems: requestItems,
ReturnConsumedCapacity: 'TOTAL',
}),
);
for (const [table, items] of Object.entries(
response.Responses ?? {},
)) {
const existing = responsesByTable.get(table) ?? [];
existing.push(...items);
responsesByTable.set(table, existing);
}
for (const entry of response.ConsumedCapacity ?? []) {
if (!entry.TableName) continue;
consumedCapacityByTable.set(
entry.TableName,
(consumedCapacityByTable.get(entry.TableName) ?? 0) +
Number(entry.CapacityUnits ?? 0),
);
}
const unprocessedKeys = response.UnprocessedKeys ?? {};
if (Object.keys(unprocessedKeys).length === 0) {
requestItems = {};
break;
}
requestItems = unprocessedKeys as NonNullable<
BatchGetCommandInput['RequestItems']
>;
if (attempt < MAX_BATCH_GET_RETRIES) {
const delayMs = Math.min(
1000,
BATCH_GET_RETRY_BASE_MS * 2 ** attempt,
);
await sleep(delayMs);
}
}
if (Object.keys(requestItems).length > 0) {
throw new HttpError(
400,
'Failed to batch get all items from DynamoDB',
{ legacyCode: 'bad_request' },
);
}
return {
Responses: Object.fromEntries(responsesByTable),
ConsumedCapacity: Array.from(consumedCapacityByTable.entries()).map(
([TableName, CapacityUnits]) => ({
TableName,
CapacityUnits,
}),
),
};
}
@Span('ddb.batchPut', (params: unknown[]) => ({
+3
View File
@@ -39,6 +39,9 @@ export interface EmailAttachment {
path?: string;
contentType?: string;
encoding?: string;
/** Content-ID of an inline part the html body references as `cid:`. */
cid?: string;
contentDisposition?: 'attachment' | 'inline';
}
/** Subset of the transport's send result callers may care about. */
+8 -6
View File
@@ -184,7 +184,7 @@ const SHARE_LIST_ROW = `
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%;">
{{#each shares}}
<tr>
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}</td>
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}{{#if this.via}} via {{this.via}}{{/if}}</td>
</tr>
{{/each}}
</table>
@@ -351,9 +351,11 @@ [email protected] immediately.
subject: 'Your {{team_name}} account on Puter',
html: `
<p>Hi there,</p>
<p>{{team_name}} has created a Puter account for you: <b>{{username}}</b>.
They will send you a temporary password separately; you will be asked to
choose your own the first time you sign in.</p>
<p>{{team_name}} has created a Puter account for you.</p>
<p>Username: <b>{{username}}</b><br>
Temporary password: <b>{{temporary_password}}</b></p>
<p>You will be asked to choose your own password the first time you sign in.
This temporary one stops working then, and it expires on its own if unused.</p>
<p>What this means:</p>
<ul>
<li>This account belongs to {{team_name}}. They pay for it and can close it.</li>
@@ -456,7 +458,7 @@ immediately</p>
Shared with you on Puter:
{{#each shares}}
- {{this.sender}} shared {{this.what}}
- {{this.sender}} shared {{this.what}}{{#if this.via}} via {{this.via}}{{/if}}
{{#each this.items}}{{#if this.link}} {{this.name}}: {{this.link}}
{{/if}}{{/each}}{{/each}}
@@ -494,7 +496,7 @@ immediately</p>
Shared with you on Puter:
{{#each shares}}
- {{this.sender}} shared {{this.what}}
- {{this.sender}} shared {{this.what}}{{#if this.via}} via {{this.via}}{{/if}}
{{/each}}
There's no Puter account for {{email}} yet. Create one with this
+72 -13
View File
@@ -54,19 +54,9 @@ export type TeamBillingEvent = TeamBillingContext & {
};
/**
* Extension-augmentable half of {@link EventMap}. Extensions that emit their own
* events declare the payload here by declaration merging, so both the emitter
* and every listener are typed against the same shape:
*
* declare module '@heyputer/backend/clients/event/types' {
* interface IExtensionEventMap {
* 'my.thing.happened': { thingId: string };
* }
* }
*
* Deliberately member-less and index-signature-free: an index signature here
* would widen `keyof EventMap` to `string` and silently disable key checking on
* every `emit` in the tree.
* Extension-augmentable half of {@link EventMap}, declaration-merged like
* `IExtensionClientInstances`. No index signature: it would widen `keyof
* EventMap` to `string` and disable key checking on every `emit`.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface IExtensionEventMap {}
@@ -237,8 +227,40 @@ export type EventMap = {
fingerprint?: string | null;
/** True when the created account is a temp user (no email/password). */
is_temp?: boolean;
/** The bonus code `puter.signup-bonus.validate` accepted, if any. */
bonus_code?: string;
[key: string]: unknown;
};
// Signup bonus codes are pure mechanism here: an extension decides what a
// code is worth and fills these in (both emitted via `emitAndWait`).
'puter.signup-bonus.check': {
code: string;
ip: string | null;
fingerprint: string | null;
valid: boolean;
/** Opaque to core; forwarded to the client when `valid` is false. */
reason: string | null;
display: { title: string; description: string } | null;
/** Verification the code will require after signup. */
requirements: { phone: boolean; card: boolean } | null;
};
/**
* Emitted once a signup has passed `puter.signup.validate`, so listeners
* see the harness's verdict. A listener sets `accepted` to honor the code,
* and may raise (never lower) the verification requirements.
*/
'puter.signup-bonus.validate': {
code: string;
email?: string;
clean_email?: string;
ip?: string | null;
fingerprint?: string | null;
reputation?: number | null;
source?: 'oidc';
requires_phone_verification: boolean;
requires_card_verification: boolean;
accepted: boolean;
};
'email.validate': {
email: string;
allow: boolean;
@@ -294,6 +316,7 @@ export type EventMap = {
'puter.card-verification.setup': {
user_id: number;
user_uid: string;
email?: string;
ip?: string | null;
// Client-supplied device fingerprint for this request, or null. Lets
// the abuse extension cap card-verification setups per device (across
@@ -320,6 +343,7 @@ export type EventMap = {
'puter.card-verification.confirm': {
user_id: number;
user_uid: string;
email?: string;
setup_intent_id: string;
enabled: boolean | null;
verified: boolean;
@@ -431,6 +455,14 @@ export type EventMap = {
userId: number;
keys: string[];
op: KvOp;
/**
* What each key holds after the change, aligned with `keys`: the
* written value on a `set`, `null` on a `del`. Absent when the write
* did not have it in hand, as an `expire` does not.
*/
values?: unknown[];
/** Keys among `keys` private to the namespace's app. */
noShareKeys?: string[];
};
/**
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
@@ -607,6 +639,16 @@ export type EventMap = {
requestHash?: string;
mime: string;
};
// Asked before a hosted file is streamed. A listener that owns the site
// sets `result.allowed = false` to withhold the entry; the visitor then
// sees the same 404 as for a missing file.
'site.access.check': {
subdomain: string;
host: string;
requestPath: string;
entry: { name: string; path: string };
result: { allowed: boolean };
};
// ---- Thumbnails ----
// The listener rewrites `thumbnail` in place (an s3:// key or legacy
@@ -695,6 +737,11 @@ export type EventMap = {
// normalized path: `route.<method>.<path>.before|after|error|reject`. Same
// wildcard + veto semantics as the driver lifecycle above.
[K in `route.${string}`]: RouteLifecycleEvent;
} & {
// Cost factor for recorded AI usage, keyed by driver and model:
// `ai.cost.factor.<driverName>.<provider>:<model>`. Emitted once per model
// per batch; the last listener to set `factor` wins.
[K in `ai.cost.factor.${string}`]: AiCostFactorEvent;
} & {
[K in `pubsub.login.${string}`]: { authtoken: string };
} & {
@@ -715,6 +762,18 @@ export type EventMap = {
'outer.pubsub.metering.credits-changed': { userUuid: string };
} & IExtensionEventMap;
/** Payload for `ai.cost.factor.<driver>.<model>` events. */
export type AiCostFactorEvent = {
/** Driver doing the pricing, e.g. `ai-chat`. */
driver: string;
/** `<provider>:<model>` the usage is recorded under. */
model: string;
/** Who the usage is being charged to. */
actor: Actor;
/** Applied to the cost, starting at 1. Values <= 0 are ignored. */
factor: number;
};
/**
* Phase of a request/method lifecycle. `reject` is emitted when a `before`
* listener vetoes the call (sets `allow = false`); the call never runs and no
@@ -140,13 +140,9 @@ export interface EventsInvokeCall {
}
/**
* Where an invocation is delivered.
*
* `status` is what the handler answered with. A `null` status means nothing
* ran: no transport, no script, or a dispatcher that refused us — all of which
* are the platform's fault and so retriable. `handled` is set only when the
* answer is provably the script's own (the handled header); a 4xx without it
* came from something between here and the script, not a handler's refusal.
* Delivers an invocation. `status` null means nothing ran (retriable);
* `handled` is set only when the answer provably came from the script, so an
* unmarked 4xx is not a handler's refusal.
*/
export interface EventsInvokeTransport {
send(call: EventsInvokeCall): Promise<{
@@ -159,14 +155,9 @@ export interface EventsInvokeTransport {
}
/**
* What the call did.
*
* - `settled` — the handler took the delivery (2xx).
* - `terminal` — it refused it (a marked 4xx). Retrying sends the same body to
* the same code, so it is not retried.
* - `retriable` — it could not answer: 5xx, 429, a timeout, a transport failure,
* an unmarked 4xx (nothing said it was a handler's answer), or no way to
* reach the worker at all.
* `settled`: handler accepted (2xx). `terminal`: handler refused (marked 4xx),
* not retried. `retriable`: no handler answer (5xx, 429, timeout, transport
* failure, unmarked 4xx).
*/
export type WorkerInvokeOutcome = 'settled' | 'terminal' | 'retriable';
+2 -8
View File
@@ -107,14 +107,8 @@ const buildCluster = (config: IConfig): Cluster => {
};
/**
* `RedisClient` IS the ioredis `Cluster` instance — consumers call
* `this.clients.redis.get(...)` / `.set(...)` directly rather than going
* through an inner `.client` field. Lifecycle methods (`onServerShutdown`) are
* attached onto the cluster instance itself.
*
* Type-wise, `RedisClient` is `Cluster & WithLifecycle`; the registry- facing
* value below is a constructor that returns that shape. Mirrors the
* `DatabaseClientFactory` pattern.
* The ioredis `Cluster` itself, with lifecycle methods attached; the
* constructor below returns it.
*/
export type RedisClient = Cluster & WithLifecycle;
+8 -21
View File
@@ -21,32 +21,19 @@ import type { IConfig, WithLifecycle } from '../types';
import type { ClickhouseClient } from './clickhouse/ClickhouseClient';
/**
* Extension-augmentable client registry. Extensions add their own client
* instance types via TypeScript declaration merging:
*
* declare module '@heyputer/backend/clients/types' {
* interface IExtensionClientInstances {
* myClient: MyClient;
* }
* }
*
* Augmentations flow into `this.clients` everywhere it's typed (PuterStore,
* PuterService, PuterController, PuterDriver) and into the
* `extension.import('client')` proxy.
* Extension-augmentable client registry. Extensions add typed keys by
* declaration-merging this interface from `@heyputer/backend/clients/types`;
* the result is what `this.clients` and `extension.import('client')` see. The
* same pattern applies to the store, service, driver and controller
* registries.
*/
export interface IExtensionClientInstances {
/**
* Open index signature so reads of extension-only client keys return
* `unknown` instead of a type error. Concrete declaration-merged keys
* override this for that name.
*/
/** Unmerged extension keys read as `unknown` rather than erroring. */
[key: string]: unknown;
/**
* Optional ClickHouse analytics client. Absent by default — a production
* deployment registers it via an extension to speed up the app-stats path
* at scale (see {@link ClickhouseClient}). Always branch on its presence and
* fall back to SQL.
* Registered by an extension; absent by default, so branch on it and fall
* back to SQL.
*/
clickhouse?: ClickhouseClient;
}
@@ -186,14 +186,13 @@ export class AppController extends PuterController {
// POST /rao — record a recent app open. When an app-under-user
// actor calls this, the app id is already on the token — clients
// don't re-send it in the body. Fall back to `actor.app.uid`
// don't re-send it in the body. Fall back to the acting app's uid
// before 400-ing for a missing body field.
//
// Authorization: only two callers are trusted to report opens —
// 1. a root user actor (plain session, no `.app` and no access
// token), e.g. the GUI launching apps on behalf of the user;
// 2. the app-under-user actor for the app being reported, i.e.
// `actor.app.uid === app_uid`.
// 2. the app-under-user actor for the app being reported.
// Everything else — access tokens (regardless of issuer), asset
// tokens, app actors reporting for a *different* app — is denied,
// otherwise any authenticated party could inflate another app's
@@ -208,11 +207,11 @@ export class AppController extends PuterController {
async (req, res) => {
const actor = req.actor;
const bodyAppUid = req.body?.app_uid;
const actorAppUid = actor?.app?.uid;
const callerAppUid = actor?.effectiveApp?.uid;
const app_uid =
typeof bodyAppUid === 'string' && bodyAppUid.length > 0
? bodyAppUid
: actorAppUid;
: callerAppUid;
if (!app_uid || typeof app_uid !== 'string') {
throw new HttpError(400, 'Missing or invalid `app_uid`', {
legacyCode: 'bad_request',
@@ -231,7 +230,7 @@ export class AppController extends PuterController {
);
}
if (isAppActor(actor) && app_uid !== actorAppUid) {
if (isAppActor(actor) && app_uid !== callerAppUid) {
throw new HttpError(
403,
'App actors can only report opens for their own app',
@@ -20,7 +20,7 @@
import type { Request, RequestHandler, Response } from 'express';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
import { makeActor, type Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
@@ -411,10 +411,10 @@ describe('AppController POST /rao', () => {
const app = await createApp(owner.actor);
const { res, captured } = makeRes();
const actorWithApp: unknown = {
const actorWithApp: unknown = makeActor({
...owner.actor,
app: { uid: app.uid },
};
});
await withActor(owner.actor, () =>
callRoute(
'post',
@@ -901,10 +901,10 @@ describe('AppController POST /rao actor gating', () => {
const { actor } = await makeUser();
const own = await createApp(actor);
const other = await createApp(actor);
const appActor = {
const appActor = makeActor({
...actor,
app: { uid: own.uid as string },
} as unknown as Actor;
} as unknown as Actor);
const { res } = makeRes();
await expect(
@@ -0,0 +1,346 @@
/*
* 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { makeActor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
/**
* HTTP-level coverage for `/auth/revoke-own-access-token` — the route that
* lets an app revoke a read-URL token it minted itself, without touching the
* account-only `/auth/revoke-access-token`.
*/
describe('revoke-own-access-token over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const call = (
method: string,
path: string,
token: string,
body?: unknown,
) =>
fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const tokenReadStatus = async (fileUid: string, token: string) => {
const url = new URL('/token-read', env.apiOrigin);
url.searchParams.set('uid', fileUid);
url.searchParams.set('token', token);
return (await fetch(url)).status;
};
/** A file with real content in `owner`'s home, readable by uid. */
const makeFile = async (owner: { username: string }) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const content = Buffer.from(`revoke-http-${crypto.randomUUID()}`);
const path = `/${owner.username}/Documents/${crypto.randomUUID()}.txt`;
await env.server.services.fs.write(user!.id, {
fileMetadata: {
path,
size: content.byteLength,
contentType: 'text/plain',
},
fileContent: content,
});
const entry = await env.server.stores.fsEntry.getEntryByPath(path);
return { uid: entry!.uid };
};
/** An app of `owner`'s, with a token and read reach over `file`. */
const makeApp = async (
owner: { username: string },
file: { uid: string },
) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const actor = makeActor({ user: user! });
const app = await env.server.stores.app.create(
{
name: `auth-http-app-${crypto.randomUUID()}`,
title: 'Auth app',
index_url: `https://auth-${crypto.randomUUID()}.test/`,
},
{ ownerUserId: actor.user.id! },
);
await runWithContext({ actor }, () =>
env.server.services.permission.grantUserAppPermission(
actor,
app.uid,
`fs:${file.uid}:read`,
),
);
const token = await env.server.services.auth.getUserAppToken(
actor,
app.uid,
);
return { ...app, token };
};
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
const makeScopedToken = async (owner: {
username: string;
token: string;
}) => {
const res = await call(
'POST',
'/auth/create-access-token',
owner.token,
{ permissions: ['service:foo:ii:read'], expiresIn: '1h' },
);
expect(res.status).toBe(200);
return ((await res.json()) as { token: string }).token;
};
/** Mints a read-URL token the way `getReadURL()` does. */
const mintReadToken = async (callerToken: string, fileUid: string) => {
const res = await call(
'POST',
'/auth/create-access-token',
callerToken,
{ permissions: [`fs:${fileUid}:read`], expiresIn: '1h' },
);
expect(res.status).toBe(200);
return ((await res.json()) as { token: string }).token;
};
it('lets an app revoke a read token it minted itself', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const app = await makeApp(owner, file);
const readToken = await mintReadToken(app.token, file.uid);
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
app.token,
{ token: readToken },
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
});
it('404s a second app of the same user, and the URL still works', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const app = await makeApp(owner, file);
const otherApp = await makeApp(owner, file);
const readToken = await mintReadToken(app.token, file.uid);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
otherApp.token,
{ token: readToken },
);
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'not_found' });
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
});
it('lets the account session revoke an app-minted token', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const app = await makeApp(owner, file);
const readToken = await mintReadToken(app.token, file.uid);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
owner.token,
{ token: readToken },
);
expect(res.status).toBe(200);
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
});
it('404s another user entirely', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const app = await makeApp(owner, file);
const readToken = await mintReadToken(app.token, file.uid);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
env.users.other.token,
{ token: readToken },
);
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'not_found' });
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
});
it('404s an app presenting an account-issued token', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const app = await makeApp(owner, file);
// Minted with the owner's own session, so it carries no `app_uid`.
const accountToken = await mintReadToken(owner.token, file.uid);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
app.token,
{ token: accountToken },
);
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'not_found' });
expect(await tokenReadStatus(file.uid, accountToken)).toBe(200);
});
it('refuses a scoped access token as caller', async () => {
const owner = env.users.user;
const scoped = await makeScopedToken(owner);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
scoped,
{ token: scoped },
);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'forbidden' });
});
it('rejects a non-JWT string with token_invalid', async () => {
const owner = env.users.user;
const res = await call(
'POST',
'/auth/revoke-own-access-token',
owner.token,
{ token: 'not-a-jwt' },
);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ code: 'token_invalid' });
});
it('still requires `token` in the body', async () => {
const owner = env.users.user;
const res = await call(
'POST',
'/auth/revoke-own-access-token',
owner.token,
{},
);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ code: 'bad_request' });
});
/** A second personal API token for `owner`, minted the way the dashboard does. */
const mintPersonalToken = async (owner: { token: string }) => {
const { actor } = await env.server.services.auth.authenticate(
owner.token,
);
return env.server.services.auth.createAccessToken(actor!, [
[FULL_API_ACCESS],
]);
};
const whoamiStatus = async (token: string) =>
(await call('GET', '/whoami', token)).status;
it('refuses to revoke a personal API token, even for its own account', async () => {
const owner = env.users.user;
const pat = await mintPersonalToken(owner);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
owner.token,
{ token: pat },
);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'forbidden' });
expect(await whoamiStatus(pat)).toBe(200);
});
it('keeps a personal API token from revoking a sibling one', async () => {
const owner = env.users.user;
const sibling = await mintPersonalToken(owner);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
owner.apiToken,
{ token: sibling },
);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'forbidden' });
expect(await whoamiStatus(sibling)).toBe(200);
});
it('lets a personal API token revoke its account`s read token', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
// Access tokens can't mint, so the account session creates the URL.
const readToken = await mintReadToken(owner.token, file.uid);
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
owner.apiToken,
{ token: readToken },
);
expect(res.status).toBe(200);
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
});
it('resolves a second revoke of the same token', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const app = await makeApp(owner, file);
const readToken = await mintReadToken(app.token, file.uid);
for (let i = 0; i < 2; i++) {
const res = await call(
'POST',
'/auth/revoke-own-access-token',
app.token,
{ token: readToken },
);
expect(res.status).toBe(200);
}
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
});
});
@@ -0,0 +1,413 @@
/*
* 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/>.
*/
/**
* Signup bonus codes through the real controller: the check route, the signup
* hand-off to `puter.signup-bonus.validate`, and the awaited
* `user.card-verified` a grant on card verification depends on. Tests stand in
* for the extension with shared listeners (EventClient has no `off()`).
*/
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import type { Actor } from '../../core/actor.js';
import type { EventMap } from '../../clients/event/types.js';
import type { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
let server: PuterServer;
let controller: any;
type Handler<K extends keyof EventMap> = ((data: EventMap[K]) => void) | null;
let onBonusCheck: Handler<'puter.signup-bonus.check'> = null;
let onBonusValidate: Handler<'puter.signup-bonus.validate'> = null;
let onSignupValidate: ((data: Record<string, unknown>) => void) | null = null;
let onCardConfirm: ((data: Record<string, unknown>) => void) | null = null;
let onCardVerified: (() => Promise<void>) | null = null;
const heardSignupSuccess: Array<Record<string, unknown>> = [];
beforeAll(async () => {
server = await setupTestServer();
controller = server.controllers.auth;
const events = server.clients.event;
events.on('puter.signup-bonus.check', (_k, data) => onBonusCheck?.(data));
events.on('puter.signup-bonus.validate', (_k, data) =>
onBonusValidate?.(data),
);
events.on('puter.signup.validate', (_k, data) =>
onSignupValidate?.(data as Record<string, unknown>),
);
events.on('puter.signup.success', (_k, data) => {
heardSignupSuccess.push(data as Record<string, unknown>);
});
events.on('puter.card-verification.confirm', (_k, data) =>
onCardConfirm?.(data as Record<string, unknown>),
);
events.on('user.card-verified' as never, async () => {
await onCardVerified?.();
});
});
afterAll(async () => {
await server?.shutdown();
});
afterEach(() => {
onBonusCheck = null;
onBonusValidate = null;
onSignupValidate = null;
onCardConfirm = null;
onCardVerified = null;
});
const uniq = () => Math.random().toString(36).slice(2, 10);
/** A check listener that finds the code open, as an extension would. */
const openCheck: Handler<'puter.signup-bonus.check'> = (data) => {
data.valid = true;
data.display = { title: 'T', description: 'D' };
};
const makeReq = (
body: Record<string, unknown> = {},
extra: { actor?: Actor; ip?: string } = {},
) => ({
body,
headers: {},
connection: { remoteAddress: extra.ip ?? '127.0.0.1' },
socket: { remoteAddress: extra.ip ?? '127.0.0.1' },
ip: extra.ip ?? '127.0.0.1',
params: {},
actor: extra.actor,
});
const makeRes = () => {
const res = {
statusCode: 200,
body: undefined as unknown,
status(code: number) {
this.statusCode = code;
return this;
},
json(b: unknown) {
this.body = b;
return this;
},
cookie() {
return this;
},
clearCookie() {
return this;
},
send() {
return this;
},
end() {
return this;
},
};
return res;
};
const signupBody = (extra: Record<string, unknown> = {}) => {
const username = `b_${uniq()}`;
return {
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
...extra,
};
};
const signup = async (body: Record<string, unknown>) => {
const res = makeRes();
await controller.handleSignup(makeReq(body), res);
return res;
};
const findUser = (username: unknown) =>
server.stores.user.getByUsername(username as string, { force: true });
/** `puter.signup.success` is fire-and-forget; wait for it to land. */
const waitForSuccess = async (userUuid: string) => {
const deadline = Date.now() + 2000;
while (Date.now() < deadline) {
const hit = heardSignupSuccess.find((e) => e.user_uuid === userUuid);
if (hit) return hit;
await new Promise((r) => setTimeout(r, 10));
}
throw new Error('puter.signup.success never arrived');
};
// -- POST /signup/bonus-code/check -----------------------------------
describe('AuthController.handleSignupBonusCheck', () => {
const check = async (body: Record<string, unknown>) => {
const res = makeRes();
await controller.handleSignupBonusCheck(makeReq(body), res);
return res.body;
};
it('answers invalid when no extension is listening', async () => {
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
valid: false,
});
});
it('returns the display and requirements a listener fills in', async () => {
let seen: EventMap['puter.signup-bonus.check'] | null = null;
onBonusCheck = (data) => {
seen = { ...data };
data.valid = true;
data.display = { title: '3 months of Basic', description: 'Free' };
data.requirements = { phone: true, card: false };
};
expect(
await check({
bonusCode: 'Startup3M-ABCD1234',
fingerprint: 'fp1',
}),
).toEqual({
valid: true,
display: { title: '3 months of Basic', description: 'Free' },
requirements: { phone: true, card: false },
});
// Listeners only ever see the canonical form.
expect(seen).toMatchObject({
code: 'startup3mabcd1234',
fingerprint: 'fp1',
ip: '127.0.0.1',
});
});
it('forwards an opaque reason for a refused code', async () => {
onBonusCheck = (data) => {
data.reason = 'ended';
};
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
valid: false,
reason: 'ended',
});
});
it('treats valid without display as invalid', async () => {
onBonusCheck = (data) => {
data.valid = true;
};
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
valid: false,
});
});
it('answers a malformed code without consulting listeners', async () => {
let called = false;
onBonusCheck = () => {
called = true;
};
expect(await check({ bonusCode: 'a:b' })).toEqual({ valid: false });
expect(called).toBe(false);
});
it('400s a non-string bonusCode', async () => {
await expect(
controller.handleSignupBonusCheck(
makeReq({ bonusCode: 42 }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
// -- POST /signup with bonusCode -------------------------------------
describe('AuthController.handleSignup with a bonus code', () => {
it('refuses the signup when nothing accepts the code', async () => {
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
await expect(signup(body)).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bonus_code_invalid',
});
expect(await findUser(body.username)).toBeFalsy();
});
it('refuses a dead code before the abuse gate records the attempt', async () => {
let validated = false;
onSignupValidate = () => {
validated = true;
};
await expect(
signup(signupBody({ bonusCode: 'startup3m-abcd1234' })),
).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' });
expect(validated).toBe(false);
});
it('still refuses a code that passed the check but is refused after the gate', async () => {
onBonusCheck = openCheck;
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
await expect(signup(body)).rejects.toMatchObject({
legacyCode: 'bonus_code_invalid',
});
expect(await findUser(body.username)).toBeFalsy();
});
it('refuses a malformed code before any listener runs', async () => {
let called = false;
onBonusValidate = () => {
called = true;
};
await expect(
signup(signupBody({ bonusCode: 'nope' + '!'.repeat(3) })),
).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' });
expect(called).toBe(false);
});
it('400s a non-string bonusCode', async () => {
await expect(
signup(signupBody({ bonusCode: ['startup3m'] })),
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
});
it('applies the requirements an accepted code raises and reports the code', async () => {
onBonusCheck = openCheck;
let seen: EventMap['puter.signup-bonus.validate'] | null = null;
onBonusValidate = (data) => {
seen = { ...data };
data.accepted = true;
data.requires_card_verification = true;
};
const body = signupBody({
bonusCode: 'STARTUP6M-abcd1234',
fingerprint: 'fp-bonus',
});
const res = await signup(body);
expect(
(res.body as { user: Record<string, unknown> }).user,
).toMatchObject({ requires_card_verification: true });
expect(seen).toMatchObject({
code: 'startup6mabcd1234',
email: body.email,
fingerprint: 'fp-bonus',
requires_phone_verification: false,
requires_card_verification: false,
});
const user = await findUser(body.username);
expect(Boolean(user!.requires_card_verification)).toBe(true);
expect(Boolean(user!.requires_phone_verification)).toBe(false);
const success = await waitForSuccess(user!.uuid);
expect(success.bonus_code).toBe('startup6mabcd1234');
});
it('keeps a requirement the abuse harness set even if the listener clears it', async () => {
onBonusCheck = openCheck;
onSignupValidate = (data) => {
data.requires_phone_verification = true;
};
onBonusValidate = (data) => {
expect(data.requires_phone_verification).toBe(true);
data.accepted = true;
data.requires_phone_verification = false;
};
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
await signup(body);
const user = await findUser(body.username);
expect(Boolean(user!.requires_phone_verification)).toBe(true);
});
it('never offers the code for a signup the abuse harness blocked', async () => {
onBonusCheck = openCheck;
let called = false;
onSignupValidate = (data) => {
data.allow = false;
data.message = 'Signup blocked';
};
onBonusValidate = () => {
called = true;
};
await expect(
signup(signupBody({ bonusCode: 'startup3m-abcd1234' })),
).rejects.toMatchObject({ statusCode: 403 });
expect(called).toBe(false);
});
it('leaves the success event without a code when none was presented', async () => {
const body = signupBody();
await signup(body);
const user = await findUser(body.username);
const success = await waitForSuccess(user!.uuid);
expect(success).not.toHaveProperty('bonus_code');
});
it('ignores a bonus code on a temp signup', async () => {
let called = false;
onBonusValidate = () => {
called = true;
};
const res = makeRes();
await controller.handleSignup(
makeReq({ is_temp: true, bonusCode: 'startup3m-abcd1234' }),
res,
);
expect((res.body as { user: { is_temp: boolean } }).user.is_temp).toBe(
true,
);
expect(called).toBe(false);
});
});
// -- /card-verification/confirm awaits user.card-verified ------------
describe('AuthController.handleCardVerificationConfirm', () => {
it('finishes user.card-verified listeners before responding', async () => {
const body = signupBody();
await signup(body);
const created = await findUser(body.username);
await server.stores.user.update(created!.id, {
requires_card_verification: 1,
});
const actor = {
user: {
id: created!.id,
uuid: created!.uuid,
username: created!.username,
email: created!.email ?? null,
email_confirmed: false,
},
} as Actor;
onCardConfirm = (data) => {
data.enabled = true;
data.verified = true;
data.fingerprint = 'fp_card_1';
};
let listenerDone = false;
onCardVerified = async () => {
await new Promise((r) => setTimeout(r, 30));
listenerDone = true;
};
const res = makeRes();
await controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
);
expect(res.body).toMatchObject({ card_verified: true });
expect(listenerDone).toBe(true);
});
});
@@ -33,7 +33,7 @@ import jwt from 'jsonwebtoken';
import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { EventClient } from '../../clients/event/EventClient.js';
import type { Actor } from '../../core/actor.js';
import { makeActor, type Actor } from '../../core/actor.js';
import { Context, runWithContext } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { requireUserActorGate } from '../../core/http/middleware/gates.js';
@@ -393,8 +393,7 @@ describe('concurrent claims on one email address', () => {
expect(await countOwners(email)).toBe(1);
const rejected = results.find((r) => r.status === 'rejected') as
| PromiseRejectedResult
| undefined;
PromiseRejectedResult | undefined;
expect(rejected?.reason).toMatchObject({ statusCode: 400 });
});
@@ -706,6 +705,79 @@ describe('AuthController.handleSignup', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects 400 when the home path is already occupied by a foreign row', async () => {
const parked = `s_${uniq()}`;
const occupantUsername = `s_${uniq()}`;
await controller.handleSignup(
makeReq({
username: occupantUsername,
email: `${occupantUsername}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const occupant = await server.stores.user.getByUsername(
occupantUsername,
);
const root = (await server.stores.fsEntry.getRootEntryForUser(
occupant!.id,
))!;
await server.clients.db.write(
'UPDATE fsentries SET path = ? WHERE id = ?',
[`/${parked}`, root.id],
);
await expect(
controller.handleSignup(
makeReq({
username: parked,
email: `${parked}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
server.stores.user.getByUsername(parked, { force: true }),
).resolves.toBeNull();
});
it('rejects 400 when only a foreign row sits under the home path', async () => {
const parked = `s_${uniq()}`;
const occupantUsername = `s_${uniq()}`;
await controller.handleSignup(
makeReq({
username: occupantUsername,
email: `${occupantUsername}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const docs = (await server.stores.fsEntry.getEntryByPath(
`/${occupantUsername}/Documents`,
))!;
await server.clients.db.write(
'UPDATE fsentries SET path = ? WHERE id = ?',
[`/${parked}/Documents`, docs.id],
);
await expect(
controller.handleSignup(
makeReq({
username: parked,
email: `${parked}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
server.stores.user.getByUsername(parked, { force: true }),
).resolves.toBeNull();
});
it('rejects reserved usernames (e.g. "admin")', async () => {
await expect(
controller.handleSignup(
@@ -2357,10 +2429,10 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
);
appUid = app.uid;
appId = app.id;
appActor = {
appActor = makeActor({
user: userActor.user,
app: { id: app.id, uid: app.uid },
} as unknown as Actor;
});
});
const grant = (body: Record<string, unknown>) =>
@@ -2404,11 +2476,11 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
const path = (name: string) => `/${user.username}/${name}`;
const rand = () => uuidv4().slice(0, 6);
it('creates a missing directory, grants it, and a subsequent check reports it held', async () => {
it('creates a missing directory by default, grants it, and a subsequent check reports it held', async () => {
const p = path(`.mail-${rand()}`);
expect(await server.stores.fsEntry.getEntryByPath(p)).toBeFalsy();
await grant({ permission: `fs:${p}:write`, create: true });
await grant({ permission: `fs:${p}:write` });
const entry = await server.stores.fsEntry.getEntryByPath(p);
expect(entry?.isDir).toBe(true);
@@ -2454,10 +2526,10 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
).toBe(true);
});
it('without `create`, a missing path still 404s and nothing is created', async () => {
it('`create: false` opts out: a missing path 404s and nothing is created', async () => {
const p = path(`missing-${rand()}`);
await expect(
grant({ permission: `fs:${p}:write` }),
grant({ permission: `fs:${p}:write`, create: false }),
).rejects.toMatchObject({
statusCode: 404,
legacyCode: 'subject_does_not_exist',
@@ -2476,7 +2548,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
expect(parentEntry?.isDir).toBe(true);
const leafEntry = await server.stores.fsEntry.getEntryByPath(leaf);
expect(leafEntry?.isDir).toBe(true);
expect(await grantedPermissions()).toContain(`fs:${leafEntry!.uuid}:write`);
expect(await grantedPermissions()).toContain(
`fs:${leafEntry!.uuid}:write`,
);
});
it('rolls back only the leaf, not auto-created intermediate directories, when a later phase fails', async () => {
@@ -2525,18 +2599,26 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
await expect(
grant({ permission: `fs:${appDataPath}:write`, create: true }),
).rejects.toMatchObject({ statusCode: 403, legacyCode: 'forbidden' });
expect(await server.stores.fsEntry.getEntryByPath(appDataPath)).toBeFalsy();
expect(await server.stores.fsEntry.getEntryByPath(appDataSubdir)).toBeFalsy();
expect(
await server.stores.fsEntry.getEntryByPath(appDataPath),
).toBeFalsy();
expect(
await server.stores.fsEntry.getEntryByPath(appDataSubdir),
).toBeFalsy();
const trashPath = path(`Trash/x-${rand()}`);
await expect(
grant({ permission: `fs:${trashPath}:write`, create: true }),
).rejects.toMatchObject({ statusCode: 403, legacyCode: 'forbidden' });
expect(await server.stores.fsEntry.getEntryByPath(trashPath)).toBeFalsy();
expect(
await server.stores.fsEntry.getEntryByPath(trashPath),
).toBeFalsy();
});
it('rejects a path more than the depth limit below home, creating nothing', async () => {
const deep = path(Array.from({ length: 17 }, () => `d-${rand()}`).join('/'));
const deep = path(
Array.from({ length: 17 }, () => `d-${rand()}`).join('/'),
);
await expect(
grant({ permission: `fs:${deep}:write`, create: true }),
).rejects.toMatchObject({
@@ -2624,7 +2706,7 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
it('does not apply `create` to a `manage:` grant — 404 unchanged, nothing created', async () => {
const p = path(`manage-missing-${rand()}`);
await expect(
grant({ permission: `manage:fs:${p}:write`, create: true }),
grant({ permission: `manage:fs:${p}:write` }),
).rejects.toMatchObject({
statusCode: 404,
legacyCode: 'subject_does_not_exist',
@@ -2639,24 +2721,33 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
createMissingParents: true,
});
const missingPath = path(`.newmail-${rand()}`);
expect(await server.stores.fsEntry.getEntryByPath(missingPath)).toBeFalsy();
expect(
await server.stores.fsEntry.getEntryByPath(missingPath),
).toBeFalsy();
const mkdirSpy = vi.spyOn(server.services.fs, 'mkdir');
const touchSpy = vi.spyOn(server.services.fs, 'touch');
try {
await grant({
permissions: [`fs:${existingPath}:write`, `fs:${missingPath}:write`],
permissions: [
`fs:${existingPath}:write`,
`fs:${missingPath}:write`,
],
create: true,
});
// Only the missing one is actually created.
expect(mkdirSpy.mock.calls.length + touchSpy.mock.calls.length).toBe(1);
expect(
mkdirSpy.mock.calls.length + touchSpy.mock.calls.length,
).toBe(1);
} finally {
mkdirSpy.mockRestore();
touchSpy.mockRestore();
}
const existingEntry = await server.stores.fsEntry.getEntryByPath(existingPath);
const createdEntry = await server.stores.fsEntry.getEntryByPath(missingPath);
const existingEntry =
await server.stores.fsEntry.getEntryByPath(existingPath);
const createdEntry =
await server.stores.fsEntry.getEntryByPath(missingPath);
expect(createdEntry?.isDir).toBe(true);
const perms = await grantedPermissions();
expect(perms).toContain(`fs:${existingEntry!.uuid}:write`);
@@ -2673,7 +2764,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
permissions: [`fs:${p}:write`, `fs:${p}:read`],
create: true,
});
expect(mkdirSpy.mock.calls.length + touchSpy.mock.calls.length).toBe(1);
expect(
mkdirSpy.mock.calls.length + touchSpy.mock.calls.length,
).toBe(1);
} finally {
mkdirSpy.mockRestore();
touchSpy.mockRestore();
@@ -2694,7 +2787,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
}),
).rejects.toMatchObject({ statusCode: 400 });
expect(await server.stores.fsEntry.getEntryByPath(goodPath)).toBeFalsy();
expect(
await server.stores.fsEntry.getEntryByPath(goodPath),
).toBeFalsy();
});
it('rolls back what phase B created when phase C fails afterward', async () => {
@@ -2726,7 +2821,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
// Phase B created the directory; phase C then failed on the second
// entry, and the rollback undid it.
expect(await server.stores.fsEntry.getEntryByPath(missingPath)).toBeFalsy();
expect(
await server.stores.fsEntry.getEntryByPath(missingPath),
).toBeFalsy();
});
it('grant/revoke parity: revoking after a `create` grant removes the row and check no longer holds it', async () => {
@@ -2738,7 +2835,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
expect(await checkAsApp(`fs:${p}:write`)).toBe(false);
const entry = await server.stores.fsEntry.getEntryByPath(p);
expect(await grantedPermissions()).not.toContain(`fs:${entry!.uuid}:write`);
expect(await grantedPermissions()).not.toContain(
`fs:${entry!.uuid}:write`,
);
});
it('check-permissions on a missing fs path answers false rather than 404ing, without poisoning a mixed batch', async () => {
@@ -2754,7 +2853,12 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
await inCtx(appActor, () =>
controller.handleCheckPermissions(
makeReq(
{ permissions: [`fs:${missingPath}:write`, heldPermission] },
{
permissions: [
`fs:${missingPath}:write`,
heldPermission,
],
},
{ actor: appActor },
),
res,
@@ -2764,7 +2868,9 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
const body = res.body as { permissions: Record<string, boolean> };
expect(body.permissions[`fs:${missingPath}:write`]).toBe(false);
expect(body.permissions[heldPermission]).toBe(true);
expect(await server.stores.fsEntry.getEntryByPath(missingPath)).toBeFalsy();
expect(
await server.stores.fsEntry.getEntryByPath(missingPath),
).toBeFalsy();
});
it('ignores `create` on a non-fs permission and grants it normally', async () => {
@@ -2777,7 +2883,10 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
for (const badCreate of ['sock', 1, {}]) {
const p = path(`.badcreate-${rand()}`);
await expect(
grant({ permission: `fs:${p}:write`, create: badCreate as never }),
grant({
permission: `fs:${p}:write`,
create: badCreate as never,
}),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
@@ -2934,6 +3043,35 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
expect(typeof body.token).toBe('string');
});
// What decides whether a cancelled "Sign in with Puter" still hands over a token.
it('check-app reports an app the user never opened as unauthorized, with no token', async () => {
const untouched = await (
server.stores.app.create as unknown as (
fields: Record<string, unknown>,
opts: { ownerUserId: number },
) => Promise<{ uid: string; id: number }>
)(
{
name: `ca-${uuidv4()}`,
title: 'Never opened',
index_url: 'https://never-opened.example.test/index.html',
},
{ ownerUserId: user.id },
);
const res = makeRes();
await inCtx(actor, () =>
controller.handleCheckApp(
makeReq({ app_uid: untouched.uid }, { actor }),
res,
),
);
expect(res.body).toEqual({
app_uid: untouched.uid,
authenticated: false,
});
});
it('check-app returns the {app_uid, authenticated} envelope shape', async () => {
// Create a brand-new actor with no app-related history so the
// permission scan can't cache-hit anything from prior tests, AND
@@ -2996,16 +3134,7 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
authenticated: boolean;
token?: string;
};
expect(body.app_uid).toBe(otherApp.uid);
expect(typeof body.authenticated).toBe('boolean');
// Whether `authenticated` is true depends on the user's full
// permission set (default group, owned-app implicits, etc.) — this
// test only pins the response *shape*, since the substantive case
// (`authenticated: true` after a paired get-user-app-token) is
// covered by the test above.
if (!body.authenticated) {
expect(body.token).toBeUndefined();
}
expect(body).toEqual({ app_uid: otherApp.uid, authenticated: false });
});
it('falls back to origin → app_uid resolution and bootstraps a new app row', async () => {
@@ -3063,6 +3192,60 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
expect(bootstrapped).toBeTruthy();
expect(bootstrapped?.owner_user_id).toBe(owner!.id);
});
it('canonicalizes alternate-host origins to one bootstrap row per subdomain', async () => {
const subdomain = `sd-${uuidv4().slice(0, 8)}`;
await server.stores.subdomain.create({ userId: user.id, subdomain });
const res = makeRes();
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq(
{ origin: `https://${subdomain}.host.puter.localhost` },
{ actor },
),
res,
),
);
const body = res.body as { token: string; app_uid: string };
const bootstrapped = await server.stores.app.getByUid(body.app_uid);
expect(bootstrapped?.index_url).toBe(
`http://${subdomain}.site.puter.localhost`,
);
const res2 = makeRes();
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq(
{ origin: `https://${subdomain}.app.puter.localhost` },
{ actor },
),
res2,
),
);
expect((res2.body as { app_uid: string }).app_uid).toBe(body.app_uid);
});
it('supports browser extension origins in handleGetUserAppToken', async () => {
// Random id: a pre-existing row for this origin would resolve through
// the canonical lookup and never exercise the bootstrap path.
const origin = `chrome-extension://${uuidv4()}`;
const res = makeRes();
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq({ origin }, { actor }),
res,
),
);
const body = res.body as { token: string; app_uid: string };
expect(body.app_uid).toBe(
`app-${uuidv5(origin, APP_ORIGIN_UUID_NAMESPACE)}`,
);
const bootstrapped = await server.stores.app.getByUid(body.app_uid);
expect(bootstrapped?.index_url).toBe(origin);
// Proves the row came from the bootstrap path, not an earlier test.
expect(bootstrapped?.description).toMatch(/^App created from origin /);
});
});
// ── Access tokens: create + revoke ─────────────────────────────────
@@ -3879,8 +4062,8 @@ describe('AuthController.handleConfirmPhone', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('short-circuits to verified when the gate is not set (no Prelude call)', async () => {
const { actor } = await makeUserAndActor();
it('short-circuits to verified when a verified number is on file (no Prelude call)', async () => {
const { actor } = await makeUserAndActor({ phone: '+14155550123' });
const checkVerification = vi.fn();
await withPrelude(stubPrelude({ checkVerification }), async () => {
const res = makeRes();
@@ -3893,6 +4076,34 @@ describe('AuthController.handleConfirmPhone', () => {
});
});
it('verifies for real when the gate was never set but no number is on file', async () => {
// A route requiring a verified phone sends never-flagged users here;
// answering "verified" on the clear flag alone would store nothing and
// leave that route refusing them.
const { user, actor } = await makeUserAndActor();
await server.stores.kv.set({
key: `phone-verify-pending:${user.id}`,
value: '+14155550123',
});
const checkVerification = vi.fn(async () => ({ status: 'success' }));
await withPrelude(stubPrelude({ checkVerification }), async () => {
const res = makeRes();
await controller.handleConfirmPhone(
makeReq({ code: '123456' }, { actor }),
res,
);
expect(res.body).toMatchObject({ phone_verified: true });
});
expect(checkVerification).toHaveBeenCalledWith(
'+14155550123',
'123456',
);
const after = await server.stores.user.getById(user.id, {
force: true,
});
expect(after!.phone).toBe('+14155550123');
});
it('throws 400 when the gate is set but no phone is on file', async () => {
const { actor } = await makeUserAndActor({
requires_phone_verification: 1,
@@ -4970,6 +5181,102 @@ describe('AuthController.handleSaveAccount address conflicts', () => {
expect(untouched!.email).toBeNull();
expect(untouched!.password).toBeNull();
});
it('refuses to promote a temp account when the home path is already occupied', async () => {
const parked = `save_home_${Math.random().toString(36).slice(2, 10)}`;
const { user: occupant } = await makeUserAndActor();
const root = (await server.stores.fsEntry.getRootEntryForUser(
occupant.id,
))!;
await server.clients.db.write(
'UPDATE fsentries SET path = ? WHERE id = ?',
[`/${parked}`, root.id],
);
const tempRes = makeRes();
await controller.handleSignup(makeReq({ is_temp: true }), tempRes);
const tempUser = (
tempRes.body as { user: { username: string; uuid: string } }
).user;
const tempRow = await server.stores.user.getByUuid(tempUser.uuid);
const actor = {
user: {
id: tempRow!.id,
uuid: tempRow!.uuid,
username: tempRow!.username,
email: null,
email_confirmed: false,
},
} as Actor;
await expect(
controller.handleSaveAccount(
makeReq(
{
username: parked,
email: `${parked}@test.local`,
password: 'another-strong-password',
},
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
const untouched = await server.stores.user.getById(tempRow!.id, {
force: true,
});
expect(untouched!.username).toBe(tempRow!.username);
expect(untouched!.password).toBeNull();
});
it('refuses to promote a temp account when only a foreign row sits under the home path', async () => {
const parked = `save_home_${Math.random().toString(36).slice(2, 10)}`;
const { user: occupant } = await makeUserAndActor();
const docs = (await server.stores.fsEntry.getEntryByPath(
`/${occupant.username}/Documents`,
))!;
await server.clients.db.write(
'UPDATE fsentries SET path = ? WHERE id = ?',
[`/${parked}/Documents`, docs.id],
);
const tempRes = makeRes();
await controller.handleSignup(makeReq({ is_temp: true }), tempRes);
const tempUser = (
tempRes.body as { user: { username: string; uuid: string } }
).user;
const tempRow = await server.stores.user.getByUuid(tempUser.uuid);
const actor = {
user: {
id: tempRow!.id,
uuid: tempRow!.uuid,
username: tempRow!.username,
email: null,
email_confirmed: false,
},
} as Actor;
await expect(
controller.handleSaveAccount(
makeReq(
{
username: parked,
email: `${parked}@test.local`,
password: 'another-strong-password',
},
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
const untouched = await server.stores.user.getById(tempRow!.id, {
force: true,
});
expect(untouched!.username).toBe(tempRow!.username);
expect(untouched!.password).toBeNull();
});
});
// ── Password recovery flow ──────────────────────────────────────────
@@ -5006,6 +5313,34 @@ describe('AuthController password recovery', () => {
expect(after!.pass_recovery_token).toBeTruthy();
});
it('send-pass-recovery-email: refuses a team seat, and writes no token', async () => {
// The seat's address is admin-supplied and unverified; recovery there
// would be a takeover channel. Its recovery is the admin's reset.
const { user: owner } = await makeUserAndActor();
const { user: seat } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
const res = makeRes();
await controller.handleSendPassRecoveryEmail(
makeReq({ username: seat.username }),
res,
);
expect((res.body as { message: string }).message).toMatch(
/If that account exists/i,
);
const after = await server.stores.user.getById(seat.id, {
force: true,
});
expect(after!.pass_recovery_token).toBeFalsy();
});
it('verify-pass-recovery-token: 400 on missing token', async () => {
await expect(
controller.handleVerifyPassRecoveryToken(makeReq({}), makeRes()),
@@ -5253,6 +5588,57 @@ describe('AuthController user-protected mutations (validation paths)', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('change-email: 403 for an account its team provisioned', async () => {
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
await expect(
controller.handleChangeEmail(
makeReq(
{ new_email: `moved_${uniq()}@example.com` },
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('change-username: 403 for an account its team provisioned', async () => {
// The console lists members by username and the audit log records them
// by it; a self-service rename would desync both.
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
// addMember refuses to adopt an account that has one.
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
await expect(
controller.handleChangeUsername(
makeReq({ new_username: `r_${uniq()}` }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
const after = await server.stores.user.getById(seat.id, {
force: true,
});
expect(after!.username).toBe(seat.username);
});
it('change-username: persists the rename and emits user.username-changed', async () => {
const { user, actor } = await makeUserAndActor();
const newUsername = `r_${uniq()}`;
@@ -5287,6 +5673,62 @@ describe('AuthController user-protected mutations (validation paths)', () => {
}
});
it('change-username: 400 when the home path is already occupied, before the rename', async () => {
const { user, actor } = await makeUserAndActor();
// A name no account holds, whose home path a stray row does. This is
// the shape of legacy drift; it used to leave two roots at one path.
const parked = `p_${uniq()}`;
const root = (await server.stores.fsEntry.getRootEntryForUser(
user.id,
))!;
await server.clients.db.write(
'UPDATE fsentries SET path = ? WHERE id = ?',
[`/${parked}`, root.id],
);
const { user: mover, actor: moverActor } = await makeUserAndActor();
await expect(
controller.handleChangeUsername(
makeReq({ new_username: parked }, { actor: moverActor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
// The username claim is refused whole: the user row keeps its name.
const after = await server.stores.user.getById(mover.id, {
force: true,
});
expect(after!.username).toBe(mover.username);
void actor;
});
it('change-username: 400 when only a foreign row sits under the target home path', async () => {
const { user } = await makeUserAndActor();
// Nothing sits at `/parked` itself — only a leftover child row does,
// the shape a partial cascade or a user-scoped cleanup leaves behind.
const parked = `p_${uniq()}`;
const docs = (await server.stores.fsEntry.getEntryByPath(
`/${user.username}/Documents`,
))!;
await server.clients.db.write(
'UPDATE fsentries SET path = ? WHERE id = ?',
[`/${parked}/Documents`, docs.id],
);
const { user: mover, actor: moverActor } = await makeUserAndActor();
await expect(
controller.handleChangeUsername(
makeReq({ new_username: parked }, { actor: moverActor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
const after = await server.stores.user.getById(mover.id, {
force: true,
});
expect(after!.username).toBe(mover.username);
});
it('change-email: 400 on missing/invalid email and on a confirmed-account collision', async () => {
const { actor } = await makeUserAndActor();
await expect(
@@ -5686,10 +6128,10 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
const granted = `user:${user.uuid}:email:read`;
const ungranted = `apps-of-user:${user.uuid}:read`;
const appActor = {
const appActor = makeActor({
user: actor.user,
app: { id: app.id, uid: app.uid },
} as unknown as Actor;
});
const before = makeRes();
await inCtx(appActor, () =>
controller.handleCheckPermissions(
@@ -5729,6 +6171,152 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
});
});
// What lets a launch settle a consent prompt it holds no app token for.
it('check-permissions: `app_uid` answers for that app, not for the asking user', async () => {
const { user, actor } = await makeUserAndActor();
const app = await server.stores.app.create(
{
name: `cpa-${uuidv4()}`,
title: 'TestCheckPermsAppUid',
index_url: 'https://check-perms-uid.example.test/index.html',
},
{ ownerUserId: user.id },
);
const permission = `user:${user.uuid}:email:read`;
// Held by the user, so asking as the user would answer `true`.
const asUser = makeRes();
await inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq({ permissions: [permission] }, { actor }),
asUser,
),
);
expect(asUser.body).toEqual({ permissions: { [permission]: true } });
const before = makeRes();
await inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq(
{ permissions: [permission], app_uid: app.uid },
{ actor },
),
before,
),
);
expect(before.body).toEqual({ permissions: { [permission]: false } });
await inCtx(actor, () =>
controller.handleGrantUserApp(
makeReq({ app_uid: app.uid, permission, extra: {} }, { actor }),
makeRes(),
),
);
const after = makeRes();
await inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq(
{ permissions: [permission], app_uid: app.uid },
{ actor },
),
after,
),
);
expect(after.body).toEqual({ permissions: { [permission]: true } });
});
it('check-permissions: an app cannot ask about another app, and an unknown app 404s', async () => {
const { user, actor } = await makeUserAndActor();
const app = await server.stores.app.create(
{
name: `cpx-${uuidv4()}`,
title: 'TestCheckPermsCrossApp',
index_url: 'https://check-perms-x.example.test/index.html',
},
{ ownerUserId: user.id },
);
const appActor = makeActor({
user: actor.user,
app: { id: app.id, uid: app.uid },
});
await expect(
inCtx(appActor, () =>
controller.handleCheckPermissions(
makeReq(
{
permissions: ['service:foo:ii:read'],
app_uid: app.uid,
},
{ actor: appActor },
),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 403 });
await expect(
inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq(
{
permissions: ['service:foo:ii:read'],
app_uid: `app-${uuidv4()}`,
},
{ actor },
),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 404 });
await expect(
inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq(
{
permissions: ['service:foo:ii:read'],
app_uid: { not: 'a string' } as unknown as string,
},
{ actor },
),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 400 });
});
// Falling through to the user here would answer `true` for any file they own.
it('check-permissions: a present-but-empty `app_uid` is refused, not read as "no app"', async () => {
const { user, actor } = await makeUserAndActor();
const permission = `user:${user.uuid}:email:read`;
for (const app_uid of ['', null]) {
await expect(
inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq(
{ permissions: [permission], app_uid },
{ actor },
),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 400 });
}
// Omitted entirely still means "what do I hold?".
const res = makeRes();
await inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq({ permissions: [permission] }, { actor }),
res,
),
);
expect(res.body).toEqual({ permissions: { [permission]: true } });
});
it('list-permissions: returns the shape and includes a user→app grant with its app_uid', async () => {
const { user, actor } = await makeUserAndActor();
const app = await server.stores.app.create(
@@ -6487,6 +7075,30 @@ describe('AuthController.handleDeleteOwnUser', () => {
expect(after).toBeFalsy();
});
it('refuses, and keeps the row, for an account its team provisioned', async () => {
// The team is billed for the seat and closing it is theirs to do, from
// the console that keeps the audit trail.
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
await expect(
controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 403 });
const after = await server.stores.user.getById(seat.id, {
force: true,
});
expect(after).toBeTruthy();
});
it('emits user.delete with the uuid + stripe customer id for downstream teardown', async () => {
// `stripe_customer_id` ships in the MySQL/Postgres migrations but not
// the sqlite ones the test harness runs — add it so the delete path
+361 -61
View File
@@ -28,7 +28,12 @@ import type { HttpErrorOptions } from '../../core/http/HttpError.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 {
hasVerifiedCard,
hasVerifiedPhone,
} from '../../core/http/middleware/gates.js';
import type { Actor } from '../../core/actor.js';
import { isPlainUserActor, makeActor } from '../../core/actor.js';
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
import {
signStepUpToken,
@@ -61,6 +66,7 @@ import {
SEND_PHONE_RATE_LIMIT,
SEND_PHONE_RATE_WINDOW_MS,
cardFallbackAfterAttempts,
cardFallbackDepsFrom,
cardFallbackFlagKey,
isCardFallbackEligible,
isCardFallbackEnabled,
@@ -70,6 +76,13 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { generate_identifier } from '../../util/identifier.js';
import { parsePhone } from '../../util/phone.js';
import {
bonusCodeInvalidError,
checkSignupBonus,
isAbsentBonusCode,
normalizeBonusCode,
validateSignupBonus,
} from '../../util/signupBonus.js';
import { isTemporaryPasswordExpired } from '../../util/temporaryPassword.js';
import { getTaskbarItems } from '../../util/taskbarItems.js';
import {
@@ -104,6 +117,8 @@ const FINGERPRINT_MAX_LENGTH = 128;
// crafted request from turning a single grant call into a bulk write.
const MAX_PERMISSIONS_PER_REQUEST = 16;
const DISPATCH_ID_MAX_LENGTH = 128;
// One name for the flag, so the write and the read cannot drift apart.
const APP_AUTHENTICATED_FLAG = 'flag:app-is-authenticated';
// -- Post-login route limits -----------------------------------------
//
@@ -125,6 +140,14 @@ const CREDENTIAL_MINT_LIMIT = {
key: 'user',
} as const;
/** Revoking is cheap and undoing a mistake shouldn't fight the mint budget. */
const ACCESS_TOKEN_REVOKE_LIMIT = {
scope: 'auth-access-token-revoke',
limit: 60,
window: 60_000,
key: 'user',
} as const;
/**
* Second-factor configuration, including the verify leg. Shorter window than
* the mint limit because enabling 2FA legitimately involves a few attempts in a
@@ -196,20 +219,6 @@ const SESSION_LIMIT = {
// enough to cover the typical support round-trip.
const SMS_SEND_ERROR_TTL_SECONDS = 7 * 24 * 60 * 60;
/**
* Whether this account has already been through the card flow. Both halves
* matter: the pending flag being clear only says nobody asked, while the
* fingerprint is the artifact a completed check leaves behind. A route that
* requires a verified card (`requireCardVerified`) sends users here with the
* flag clear and no card on file, and answering "already verified" on the flag
* alone would bounce them between a dialog that reports success and a route
* that keeps refusing.
*/
const cardAlreadyVerified = (user: {
card_fingerprint?: string | null;
requires_card_verification?: boolean;
}): boolean =>
Boolean(user.card_fingerprint) && !user.requires_card_verification;
export const RESERVED_USERNAMES = new Set([
'admin',
'administrator',
@@ -813,6 +822,18 @@ export class AuthController extends PuterController {
}
}
// Only the shape is judged here; whether the code is honored is up to
// `puter.signup-bonus.validate`, after the abuse checks below.
let bonusCode: string | null = null;
if (!is_temp && !isAbsentBonusCode(body.bonusCode)) {
if (typeof body.bonusCode !== 'string')
throw new HttpError(400, 'bonusCode must be a string.', {
legacyCode: 'bad_request',
});
bonusCode = normalizeBonusCode(body.bonusCode);
if (!bonusCode) throw bonusCodeInvalidError();
}
// Signup-disabled gate. Runs before the duplicate checks so a
// disabled endpoint doesn't reveal which usernames or emails
// exist. Claiming a pre-existing placeholder row is still
@@ -845,6 +866,22 @@ export class AuthController extends PuterController {
);
}
// ...and the same against the filesystem: a free username whose home
// path is occupied (exactly or by leftover rows underneath it) would
// provision a second root there, and the two trees then resolve
// interchangeably.
if (
await this.stores.fsEntry.findHomePathConflict(
body.username,
undefined,
{ includeDescendants: true },
)
) {
throw new HttpError(400, 'This username is not available.', {
legacyCode: 'bad_request',
});
}
// Duplicate confirmed-email check. A confirmed account (any
// credential type — password OR OIDC) on this email → reject.
//
@@ -877,6 +914,20 @@ export class AuthController extends PuterController {
? null
: await this.#resolveSignupEmailClaim(body.email);
// A dead code fails here rather than after the gate below, which records
// an allowed attempt against the address as if an account followed.
if (
bonusCode &&
!(
await checkSignupBonus(this.clients.event, bonusCode, {
ip: clientIp,
fingerprint,
})
).valid
) {
throw bonusCodeInvalidError();
}
// Extension-level validation gate. Abuse-prevention extensions
// inspect the incoming signup and can:
// - block it outright via `event.allow = false`
@@ -963,18 +1014,37 @@ export class AuthController extends PuterController {
const force_email_confirmation = Boolean(
validateEvent.requires_email_confirmation,
);
const force_phone_verification =
let force_phone_verification =
Boolean(validateEvent.requires_phone_verification) ||
// Test/QA switch: force the SMS gate on every signup regardless of
// reputation (see config.always_require_phone_verification).
Boolean(this.config.always_require_phone_verification);
const force_card_verification = Boolean(
let force_card_verification = Boolean(
validateEvent.requires_card_verification ||
// Test/QA switch: force the card gate on every signup regardless of
// reputation (see config.always_require_card_verification).
this.config.always_require_card_verification,
);
if (bonusCode) {
const verdict = await validateSignupBonus(
this.clients.event,
bonusCode,
{
email: body.email,
clean_email: cleanEmail(body.email),
ip: clientIp,
fingerprint,
reputation: validateEvent.reputation,
requires_phone_verification: force_phone_verification,
requires_card_verification: force_card_verification,
},
);
if (!verdict.accepted) throw bonusCodeInvalidError();
force_phone_verification = verdict.requiresPhoneVerification;
force_card_verification = verdict.requiresCardVerification;
}
// Prepare shared fields
const user_uuid = uuidv4();
const email_confirm_code = String(crypto.randomInt(100000, 1000000));
@@ -1195,6 +1265,7 @@ export class AuthController extends PuterController {
// have to agree or per-IP counters are written under one
// key and read under another.
ip: clientIp,
...(bonusCode ? { bonus_code: bonusCode } : {}),
} as never,
{},
);
@@ -1216,6 +1287,64 @@ export class AuthController extends PuterController {
await this.#completeLogin(req, res, user!);
}
/**
* Preview a signup bonus code before the account exists, so the signup form
* can say what it grants. Answers only valid/invalid: an extension owns the
* codes, and with none installed every code is invalid.
*/
@Post('/signup/bonus-code/check', {
rateLimit: [
{ scope: 'signup-bonus-check', limit: 20, window: 15 * 60_000 },
{
scope: 'signup-bonus-check-ip',
limit: 60,
window: 15 * 60_000,
key: 'ip',
},
],
})
async handleSignupBonusCheck(req: Request, res: Response): Promise<void> {
const raw = req.body?.bonusCode;
if (typeof raw !== 'string')
throw new HttpError(400, 'bonusCode must be a string.', {
legacyCode: 'bad_request',
});
const code = normalizeBonusCode(raw);
if (!code) {
res.json({ valid: false });
return;
}
const fingerprint =
typeof req.body?.fingerprint === 'string' &&
req.body.fingerprint.length <= FINGERPRINT_MAX_LENGTH
? req.body.fingerprint
: null;
const checkEvent = await checkSignupBonus(this.clients.event, code, {
ip: (req.ip || req.socket?.remoteAddress || null) as string | null,
fingerprint,
});
if (checkEvent.valid !== true || !checkEvent.display) {
res.json({
valid: false,
...(checkEvent.reason ? { reason: checkEvent.reason } : {}),
});
return;
}
res.json({
valid: true,
display: {
title: String(checkEvent.display.title),
description: String(checkEvent.display.description),
},
requirements: {
phone: checkEvent.requirements?.phone === true,
card: checkEvent.requirements?.card === true,
},
});
}
// -- Logout ------------------------------------------------------
@Post('/logout', {
@@ -1621,18 +1750,7 @@ export class AuthController extends PuterController {
* is listening on a stock build, which reads as "no card gate".
*/
private cardFallbackDeps(): CardFallbackDeps {
return {
smsConfigured: () => Boolean(this.clients.prelude?.isConfigured()),
probeCardVerification: async () => {
const statusEvent = { enabled: null as boolean | null };
await this.clients.event?.emitAndWait(
'puter.card-verification.status',
statusEvent,
{},
);
return statusEvent.enabled;
},
};
return cardFallbackDepsFrom(this.clients);
}
@Post('/send-confirm-phone', {
@@ -1898,7 +2016,12 @@ export class AuthController extends PuterController {
throw new HttpError(404, 'User not found.', {
legacyCode: 'not_found',
});
if (!user.requires_phone_verification) {
// Both halves, as for the card: the flag being clear only says nobody
// asked. A route requiring a verified phone sends never-flagged users
// here with no number on file, and answering "already verified" on
// the flag alone would bounce them between a dialog that reports
// success and a route that keeps refusing.
if (hasVerifiedPhone(user)) {
res.json({ phone_verified: true, original_client_socket_id });
return;
}
@@ -2024,7 +2147,7 @@ export class AuthController extends PuterController {
// Phone normally comes first, but the fallback lets a phone-gated user
// in once they've exhausted SMS attempts.
const fallbackEligible = await this.isCardFallbackEligible(user);
if (cardAlreadyVerified(user) && !fallbackEligible) {
if (hasVerifiedCard(user) && !fallbackEligible) {
res.json({ card_verified: true });
return;
}
@@ -2039,6 +2162,7 @@ export class AuthController extends PuterController {
// extension always sets it (true/false) before doing any work.
const setupEvent = {
user_id: user.id,
email: user.email!,
user_uid: user.uuid,
ip: (req.ip || req.socket?.remoteAddress || null) as string | null,
device_fingerprint: req.deviceFingerprint ?? null,
@@ -2144,7 +2268,7 @@ export class AuthController extends PuterController {
});
// Same fallback exception as setup: card may come before phone.
const fallbackEligible = await this.isCardFallbackEligible(user);
if (cardAlreadyVerified(user) && !fallbackEligible) {
if (hasVerifiedCard(user) && !fallbackEligible) {
res.json({ card_verified: true });
return;
}
@@ -2158,6 +2282,7 @@ export class AuthController extends PuterController {
const confirmEvent = {
user_id: user.id,
user_uid: user.uuid,
email: user.email!,
setup_intent_id,
enabled: null as boolean | null,
verified: false,
@@ -2213,8 +2338,11 @@ export class AuthController extends PuterController {
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
});
// Awaited like `user.phone-verified`, so whatever a listener grants on
// verification is in place before the client refreshes. emitAndWait
// swallows listener errors, so confirm never fails on one.
try {
this.clients.event?.emit(
await this.clients.event?.emitAndWait(
'user.card-verified' as never,
{
user_id: user.id,
@@ -2227,7 +2355,7 @@ export class AuthController extends PuterController {
{},
);
} catch {
// ignore — event is a side-channel signal, not load-bearing
// ignore — listeners are best-effort
}
// Notify other tabs/devices for this user so they refresh + drop the gate.
try {
@@ -2293,6 +2421,14 @@ export class AuthController extends PuterController {
return;
}
// A seat's address is admin-supplied and never verified, so whoever
// holds that inbox could take the seat over. Its recovery channel is
// the team admin's password reset, not this one.
if (await this.stores.team.getOrgSeat(user.id)) {
res.json({ message: genericMessage });
return;
}
const pass_recovery_token = uuidv4();
await this.stores.user.update(user.id, { pass_recovery_token });
@@ -2551,6 +2687,17 @@ export class AuthController extends PuterController {
}
async handleChangeUsername(req: Request, res: Response): Promise<void> {
// A provisioned account's name belongs to the team that made it: the
// console lists its members by username and the audit log records them
// by username, so a self-service rename would desync both.
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
throw new HttpError(
403,
'Your team set this username. Ask a team admin to change it.',
{ legacyCode: 'forbidden' },
);
}
const { new_username } = req.body ?? {};
if (!new_username || typeof new_username !== 'string') {
throw new HttpError(400, '`new_username` is required', {
@@ -2581,6 +2728,20 @@ export class AuthController extends PuterController {
legacyCode: 'username_already_in_use',
});
}
// Before the username is written, not after: the rename below is what
// keeps the account's files reachable, and it can't run onto a taken
// path.
if (
await this.stores.fsEntry.findHomePathConflict(
new_username,
req.actor!.user.id!,
{ includeDescendants: true },
)
) {
throw new HttpError(400, 'This username is not available.', {
legacyCode: 'username_already_in_use',
});
}
await this.stores.user.update(req.actor!.user.id!, {
username: new_username,
@@ -2617,6 +2778,16 @@ export class AuthController extends PuterController {
}
async handleChangeEmail(req: Request, res: Response): Promise<void> {
// The address is where admin-issued credentials and team notices go;
// same reasoning as the username and deletion guards above.
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
throw new HttpError(
403,
'Your team set this address. Ask a team admin to change it.',
{ legacyCode: 'forbidden' },
);
}
const { new_email } = req.body ?? {};
if (!new_email || typeof new_email !== 'string') {
throw new HttpError(400, '`new_email` is required', {
@@ -2895,6 +3066,15 @@ export class AuthController extends PuterController {
legacyCode: 'username_already_in_use',
});
}
if (
await this.stores.fsEntry.findHomePathConflict(username, user.id, {
includeDescendants: true,
})
) {
throw new HttpError(400, 'This username is not available.', {
legacyCode: 'username_already_in_use',
});
}
// Match raw + canonical to catch gmail-alias collisions, and
// reject on ANY confirmed account (OIDC accounts have
// password=null but are real) — not just password-holders.
@@ -3251,8 +3431,8 @@ export class AuthController extends PuterController {
/**
* Whether a granted `fs:` path permission needs its target created before
* the grant can resolve, and if so where and what kind. Null when there is
* nothing to create: `create` was not requested, the entry does not name an
* `fs:` path, it is a `manage:` grant, or the path already exists.
* nothing to create: `create` is `false`, the entry does not name an `fs:`
* path, it is a `manage:` grant, or the path already exists.
*
* Side-effect-free, like `#prepareAppDataGrant` above — a pre-grant hook
* run in the same validation loop, so a bad entry elsewhere in the list
@@ -3308,11 +3488,11 @@ export class AuthController extends PuterController {
// They are deliberately not rolled back if a later phase fails, so a
// failed grant can leave empty directories inside the user's home.
return plan.kind === 'dir'
? await this.services.fs.mkdir(userId, {
? await this.services.fs.mkdir(userId!, {
path: plan.path,
createMissingParents: true,
})
: await this.services.fs.touch(userId, {
: await this.services.fs.touch(userId!, {
path: plan.path,
createMissingParents: true,
});
@@ -3331,7 +3511,7 @@ export class AuthController extends PuterController {
const userId = actor.user!.id;
for (const entry of [...created].reverse()) {
try {
await this.services.fs.remove(userId, {
await this.services.fs.remove(userId!, {
entry,
recursive: false,
});
@@ -3366,7 +3546,7 @@ export class AuthController extends PuterController {
legacyCode: 'bad_request',
});
}
// 400 on a bad value, before anything else runs.
// 400 on a bad value, before anything else runs. Absent means create.
const create = parseCreateFlag(req.body?.create);
// Validate every entry before writing or creating anything, so a bad
@@ -3519,27 +3699,66 @@ export class AuthController extends PuterController {
// -- Permission checks -------------------------------------------
/**
* The caller's account acting as `appIdentifier`, resolved uid-or-name like
* the grant handlers.
*/
async #appUnderUserActor(
actor: Actor,
appIdentifier: unknown,
): Promise<Actor> {
this.#validateAppPermissionParams({ app_uid: appIdentifier });
// Sessions only: for an app, this would be a window onto its neighbours' grants.
if (!isPlainUserActor(actor)) {
throw new HttpError(403, 'actor must be a user', {
legacyCode: 'forbidden',
});
}
const app = await this.stores.app.resolveApp(appIdentifier as string);
if (!app) {
throw new HttpError(404, `App ${appIdentifier} does not exist`, {
legacyCode: 'not_found',
});
}
return makeActor({
user: actor.user,
app: { id: app.id, uid: app.uid },
});
}
@Post('/auth/check-permissions', {
subdomain: 'api',
requireAuth: true,
rateLimit: AUTH_CHECK_LIMIT,
})
async handleCheckPermissions(req: Request, res: Response): Promise<void> {
const { permissions } = req.body ?? {};
const { permissions, app_uid } = req.body ?? {};
if (!Array.isArray(permissions)) {
throw new HttpError(400, 'Missing or invalid `permissions` array', {
legacyCode: 'bad_request',
});
}
// Present but empty must not fall through to checking the user: on their own file every `fs:` scope answers `true`.
if (
app_uid !== undefined &&
(typeof app_uid !== 'string' || !app_uid)
) {
throw new HttpError(400, 'Invalid `app_uid`', {
legacyCode: 'bad_request',
});
}
// `app_uid` asks what an app of mine holds, not what I hold.
const actor = app_uid
? await this.#appUnderUserActor(req.actor!, app_uid)
: req.actor!;
const unique = [...new Set(permissions)] as string[];
const result: Record<string, boolean> = {};
let granted: Map<string, boolean>;
try {
granted = await this.services.permission.checkMany(
req.actor!,
unique,
);
granted = await this.services.permission.checkMany(actor, unique);
} catch {
granted = new Map<string, boolean>();
}
@@ -3823,11 +4042,18 @@ export class AuthController extends PuterController {
if (!app && resolvedFromOrigin) {
// Hosted-subdomain origins get the site owner stamped as the
// app's creator at bootstrap; external origins stay unowned.
// Canonical origin only, so alternate hosts share one row.
const canonicalOrigin =
this.services.auth.canonicalizeOrigin(origin);
const ownerUserId =
await this.services.auth.subdomainOwnerIdFromOrigin(origin);
app = await this.stores.app.createFromOrigin(app_uid, origin, {
ownerUserId,
});
await this.services.auth.subdomainOwnerIdFromOrigin(
canonicalOrigin,
);
app = await this.stores.app.createFromOrigin(
app_uid,
canonicalOrigin,
{ ownerUserId },
);
// An origin's uid is a deterministic uuidv5, so a deleted app
// reappears here under the identical uid. Withdraw any cross-app
// data grants left pointing at it before this new row can inherit
@@ -3855,7 +4081,7 @@ export class AuthController extends PuterController {
this.services.permission.grantUserAppPermission(
req.actor!,
app_uid,
'flag:app-is-authenticated',
APP_AUTHENTICATED_FLAG,
{},
{},
);
@@ -3939,13 +4165,15 @@ export class AuthController extends PuterController {
legacyCode: 'bad_request',
});
// Check if the app is authenticated for this user
const authenticated = await this.services.permission
.check(
req.actor!,
`service:${app_uid}:ii:flag:app-is-authenticated`,
)
.catch(() => false);
// The exact row, not a scan: a `service:`-shaped check falls open on the `service` root.
const app = await this.stores.app.resolveApp(app_uid);
const userId = req.actor!.user?.id;
const authenticated =
!!app?.id &&
!!userId &&
(await this.stores.permission
.hasUserAppPerm(userId, app.id, APP_AUTHENTICATED_FLAG)
.catch(() => false));
const result: {
app_uid: string;
@@ -3953,9 +4181,10 @@ export class AuthController extends PuterController {
token?: string;
} = { app_uid, authenticated };
if (authenticated) {
// The resolved uid: the token carries it as the app's identity.
result.token = await this.services.auth.getUserAppToken(
req.actor!,
app_uid,
app!.uid,
);
}
res.json(result);
@@ -4028,10 +4257,36 @@ export class AuthController extends PuterController {
res.json({ token });
}
/**
* Revokes an access token given as its JWT. Unlike
* `/auth/revoke-access-token`, apps may call this; the service limits an
* app to tokens it issued and refuses personal API tokens.
*/
@Post('/auth/revoke-own-access-token', {
subdomain: 'api',
requireAuth: true,
rateLimit: ACCESS_TOKEN_REVOKE_LIMIT,
})
async handleRevokeOwnAccessToken(
req: Request,
res: Response,
): Promise<void> {
const { token } = req.body ?? {};
if (!token || typeof token !== 'string') {
throw new HttpError(400, 'Missing `token`', {
legacyCode: 'bad_request',
});
}
await this.services.auth.revokeOwnAccessToken(req.actor!, token);
res.json({ ok: 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.
// mandatory: a leaked access token must not be able to revoke a
// personal API token, or revoke by raw uuid — those stay web-session-only
// here; `/auth/revoke-own-access-token` covers revoking scoped tokens by
// JWT and already refuses PATs itself.
async handleRevokeAccessToken(req: Request, res: Response): Promise<void> {
let { tokenOrUuid } = req.body ?? {};
if (!tokenOrUuid || typeof tokenOrUuid !== 'string') {
@@ -4374,6 +4629,34 @@ export class AuthController extends PuterController {
async handleDeleteOwnUser(req: Request, res: Response): Promise<void> {
const userId = req.actor!.user.id!;
// The team owns the account and is billed for it; only they may close
// it, through the console that keeps the audit trail.
if (await this.stores.team.getOrgSeat(userId)) {
throw new HttpError(
403,
'Your team owns this account. Ask a team admin to remove it.',
{ legacyCode: 'forbidden' },
);
}
// The other direction: deleting an owner sets `owner_user_id` NULL and
// cascades their membership, leaving a team no one can administer —
// not even an admin, since every team route resolves by membership.
if (await this.stores.team.countOwned(userId)) {
throw new HttpError(
403,
'Delete the teams you own before closing this account.',
{ legacyCode: 'forbidden' },
);
}
// A deleted team's seats are suspended, not gone, and only its owner
// can retire them.
if (await this.stores.team.countOwnedTeamsWithAccounts(userId)) {
throw new HttpError(
403,
'Close the accounts your teams created before closing this one.',
{ legacyCode: 'forbidden' },
);
}
res.clearCookie(this.config.cookie_name ?? 'puter_token');
res.clearCookie('puter_token_v2');
res.clearCookie('puter_revalidation');
@@ -4704,8 +4987,8 @@ export class AuthController extends PuterController {
};
try {
await this.clients.event?.emitAndWait(
'email.validate' as never,
validateEvent as never,
'email.validate',
validateEvent,
{},
);
} catch (e) {
@@ -4816,6 +5099,7 @@ export class AuthController extends PuterController {
phone?: string | null;
requires_phone_verification?: number | boolean;
requires_card_verification?: number | boolean;
requires_password_change?: number | boolean;
},
): Promise<void> {
const meta = {
@@ -4854,6 +5138,20 @@ export class AuthController extends PuterController {
console.warn('[auth] taskbar_items resolution failed:', e);
}
// Same shape as whoami: no-reload logins store this payload as
// window.user verbatim, and every seat restriction keys on `team`.
let team: { uid: string; name: string | null } | undefined;
if (this.config.teams_enabled === true) {
try {
const seat = await this.stores.team.getOrgSeat(user.id);
if (seat) {
team = { uid: seat.team_uid, name: seat.team_name ?? null };
}
} catch (e) {
console.warn('[auth] team lookup failed:', e);
}
}
// Response body gets the GUI token (client never sees session token)
res.json({
proceed: true,
@@ -4868,7 +5166,9 @@ export class AuthController extends PuterController {
phone: user.phone,
requires_phone_verification: user.requires_phone_verification,
requires_card_verification: user.requires_card_verification,
requires_password_change: user.requires_password_change,
is_temp: user.password === null && user.email === null,
...(team ? { team } : {}),
taskbar_items,
},
});
@@ -47,8 +47,7 @@ export class BroadcastController extends PuterController {
@Post('/webhook', { subdomain: '*' })
async webhook(req: Request, res: Response): Promise<void> {
const broadcast = this.services.broadcast as unknown as
| BroadcastService
| undefined;
BroadcastService | undefined;
if (!broadcast) {
res.status(503).json({
error: { message: 'Broadcast service not registered' },
@@ -80,8 +79,7 @@ export class BroadcastController extends PuterController {
@Post('/events', { subdomain: '*' })
async events(req: Request, res: Response): Promise<void> {
const broadcast = this.services.broadcast as unknown as
| BroadcastService
| undefined;
BroadcastService | undefined;
if (!broadcast) {
res.status(503).json({
error: { message: 'Broadcast service not registered' },
@@ -106,6 +104,7 @@ export class BroadcastController extends PuterController {
const reply = await this.services.eventForward.receive(
(req.body ?? {}) as ForwardBatch,
verified.peerId ?? '',
);
res.status(200).json({ ok: true, ...reply });
}
@@ -179,6 +179,28 @@ const throwing = (payload: unknown) => () => {
// -- Upstream status extraction --------------------------------------
describe('DriverController upstream error translation', () => {
it('maps an upstream 402 to sanitized credit exhaustion', async () => {
const { err } = await callWith(
throwing(
Object.assign(
new Error(
'<html>Insufficient credits. Add more using https://openrouter.ai/settings/credits (request id: req-secret)</html>',
),
{ status: 402 },
),
),
);
expect(err).toMatchObject({
statusCode: 503,
legacyCode: 'upstream_credits_exhausted',
message: 'AI provider out of credits',
fields: { upstreamStatus: 402 },
});
expect(JSON.stringify(err)).not.toContain('http');
expect(JSON.stringify(err)).not.toMatch(/request id|<html>/i);
});
it('maps an upstream 429 to a Puter 429 with upstream_rate_limited', async () => {
const { err } = await callWith(
throwing(
@@ -31,7 +31,11 @@ import {
} from '../../core/http/middleware/rateLimit.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import type { DriverMeta } from '../../drivers/meta.js';
import { isUpstreamTimeoutError } from '../../drivers/util/upstreamErrors.js';
import {
isCreditExhaustion,
isUpstreamTimeoutError,
sanitizeUpstreamMessage,
} from '../../drivers/util/upstreamErrors.js';
import {
isDriverStreamResult,
resolveCallableMethods,
@@ -120,6 +124,18 @@ const translateProviderError = (err: unknown): unknown => {
cause?: unknown;
};
const status = extractUpstreamStatus(e);
const msg = sanitizeUpstreamMessage(
e.error?.message ?? e.message ?? 'Upstream provider error',
);
const upstreamCode = e.error?.code ?? e.code;
const fields = { upstreamStatus: status, upstreamCode };
if (isCreditExhaustion(status, upstreamCode, msg)) {
return new HttpError(503, 'AI provider out of credits', {
legacyCode: 'upstream_credits_exhausted',
fields,
});
}
if (typeof status !== 'number') {
if (isUpstreamTimeoutError(e)) {
const cause = e.cause as { code?: string } | undefined;
@@ -132,10 +148,6 @@ const translateProviderError = (err: unknown): unknown => {
return err;
}
const msg = e.error?.message ?? e.message ?? 'Upstream provider error';
const upstreamCode = e.error?.code ?? e.code;
const fields = { upstreamStatus: status, upstreamCode };
if (status === 429) {
return new HttpError(429, msg, {
legacyCode: 'upstream_rate_limited',
@@ -287,12 +287,14 @@ export class EventsController extends PuterController {
// -- Events workers ------------------------------------------------
//
// The billable artifact a published handler set implies. Account-scoped
// like the kv-handle surface above: this is the owner's own view of what
// it is paying for, so — unlike the handler routes — an app token cannot
// act here on its owner's behalf.
// The billable artifact a published handler set implies. An account
// session or full-access token sees every app it owns; an app sees only
// its own worker, the same scope publish and destroy use.
/** GET /events/workers — the caller's own events workers, one per app. */
/**
* GET /events/workers — an account's events workers, one per app, or an
* app's own worker (0 or 1 item).
*/
@Get('/workers', {
subdomain: 'api',
requireAuth: true,
@@ -27,6 +27,7 @@ import { describe, expect, it } from 'vitest';
import {
DEFAULT_FREE_SUBSCRIPTION,
DEFAULT_TEMP_SUBSCRIPTION,
ORG_SEAT_FREE_SUBSCRIPTION,
} from '../../services/metering/consts.js';
import {
EVENTS_BROADCAST_DELIVERY_LIMIT,
@@ -35,6 +36,8 @@ import {
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
EVENTS_KV_HANDLES_PER_APP,
EVENTS_KV_HANDLES_PER_USER,
EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT,
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT,
EVENTS_SINGLE_DELIVERY_LIMIT,
EVENTS_WORKER_INVOCATION_LIMIT,
limitFor,
@@ -58,6 +61,23 @@ describe('the tiered subscription quotas', () => {
expect(tier.bySubscription[DEFAULT_TEMP_SUBSCRIPTION]).toBe(0);
});
it('holds a free plan nobody enumerated to the free cap, not the paid one', () => {
// A team seat resolves to `org_seat_free`, which no tier names. Falling
// through to `limit` would give it more than an ordinary free account.
for (const tier of tiers.map(([, t]) => t)) {
expect(limitFor(tier, ORG_SEAT_FREE_SUBSCRIPTION)).toBe(
tier.bySubscription[DEFAULT_FREE_SUBSCRIPTION],
);
}
});
it('still holds an unresolved or paid plan to the base', () => {
for (const tier of tiers.map(([, t]) => t)) {
expect(limitFor(tier, null)).toBe(tier.limit);
expect(limitFor(tier, 'some-paid-tier')).toBe(tier.limit);
}
});
it('keeps what one app may take below what the account may hold', () => {
for (const plan of [
null,
@@ -101,6 +121,34 @@ describe('the tiered subscription quotas', () => {
});
describe('the delivery budgets', () => {
it('sets KV fan-out and filter work by the namespace owner tier', () => {
for (const [plan, matched, evaluated] of [
[null, 512, 2048],
[DEFAULT_FREE_SUBSCRIPTION, 128, 512],
[DEFAULT_TEMP_SUBSCRIPTION, 128, 512],
[ORG_SEAT_FREE_SUBSCRIPTION, 128, 512],
] as const) {
expect(
limitFor(EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT, plan),
).toBe(matched);
expect(
limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan),
).toBe(evaluated);
expect(evaluated).toBeGreaterThanOrEqual(matched);
}
});
it('pins the share-handle quotas by plan', () => {
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(512);
expect(EVENTS_KV_HANDLES_PER_APP.limit).toBe(512);
expect(
limitFor(EVENTS_KV_HANDLES_PER_USER, DEFAULT_FREE_SUBSCRIPTION),
).toBe(200);
expect(
limitFor(EVENTS_KV_HANDLES_PER_APP, DEFAULT_FREE_SUBSCRIPTION),
).toBe(128);
});
it('keeps `single` well under broadcast — each one costs far more', () => {
expect(EVENTS_SINGLE_DELIVERY_LIMIT.limit).toBeLessThan(
EVENTS_BROADCAST_DELIVERY_LIMIT.limit,
+40 -16
View File
@@ -21,6 +21,7 @@ import type { RouteRateLimit } from '../../core/http/types';
import {
DEFAULT_FREE_SUBSCRIPTION,
DEFAULT_TEMP_SUBSCRIPTION,
FREE_SUBSCRIPTION_IDS,
} from '../../services/metering/consts.js';
// -- Shared event limits ---------------------------------------------
@@ -43,10 +44,9 @@ const userWindow = (
): RouteRateLimit => ({ scope, limit, window, key: 'user' });
/**
* A cap that varies by plan, in the shape route gates already declare theirs
* in: the base is what a subscribed account sees, and `bySubscription` carves
* the free tiers out beneath it. A plan nobody enumerated falls through to the
* base, so a new one is generous rather than accidentally throttled.
* A plan-varying cap in route-gate shape: `limit` is what a subscribed account
* gets, `bySubscription` carves out the free tiers, unlisted plans get the
* base.
*/
export interface TieredLimit {
limit: number;
@@ -61,14 +61,18 @@ const tiered = (paid: number, free: number, temp: number): TieredLimit => ({
},
});
/** The cap one plan sees. An unresolved plan is held to the base. */
/** The cap one plan sees; an unlisted free plan takes the free one, not `limit`. */
export const limitFor = (
tier: TieredLimit,
subscriptionId: string | null,
): number =>
(subscriptionId === null
? undefined
: tier.bySubscription[subscriptionId]) ?? tier.limit;
): number => {
if (subscriptionId === null) return tier.limit;
const own = tier.bySubscription[subscriptionId];
if (typeof own === 'number') return own;
return FREE_SUBSCRIPTION_IDS.has(subscriptionId)
? (tier.bySubscription[DEFAULT_FREE_SUBSCRIPTION] ?? tier.limit)
: tier.limit;
};
/**
* What a plan-tiered quota resolves to: what an account may hold, and what one
@@ -163,16 +167,15 @@ export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60);
* grow forever. A temporary account holds none: the grant outlives the session
* that made it, and no account is left to take it back.
*/
export const EVENTS_KV_HANDLES_PER_USER = tiered(500, 200, 0);
export const EVENTS_KV_HANDLES_PER_USER = tiered(512, 200, 0);
/**
* Live share handles one app may hold out for one account.
*
* Below the per-account cap so that one app cannot spend an account's whole
* budget: a handle names one namespace, so without this a second app on the
* same account would be left with nothing.
* Separately bounded from the account cap so one namespace cannot consume the
* whole budget on free accounts.
*/
export const EVENTS_KV_HANDLES_PER_APP = tiered(100, 50, 0);
export const EVENTS_KV_HANDLES_PER_APP = tiered(512, 128, 0);
// -- Handler surface -------------------------------------------------
@@ -252,12 +255,33 @@ export const EVENTS_WORKER_LIST_LIMIT = userWindow('events:workers:list', 120);
// -- Dispatch fan-out ------------------------------------------------
/**
* Subscriptions one event may deliver to before dispatch stops and reports a
* gap. The amplification ceiling: without it, one write costs as many
* Subscriptions one non-KV event may deliver to before dispatch stops and
* reports a gap. The amplification ceiling: without it, one write costs as many
* deliveries as an account cared to register.
*/
export const EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT = 50;
/**
* Key-value subscriptions one mutation may deliver to. A namespace owner's plan
* sets this ceiling; the listeners' plans do not affect it.
*/
export const EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT = tiered(512, 128, 128);
/**
* Filter evaluations a key-value mutation may spend. This leaves room for
* non-matching rows while retaining a finite bound on dispatch work.
*/
export const EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT = tiered(2048, 512, 512);
/** Omit all inline KV values when the matched audience exceeds this count. */
export const EVENTS_KV_VALUE_OMIT_MATCHED_SUBSCRIPTIONS = 128;
/**
* Largest inline KV value in serialized bytes. Larger values are omitted;
* deliveries retain the key metadata.
*/
export const EVENTS_KV_VALUE_MAX_BYTES = 16 * 1024;
/**
* Broadcast deliveries per minute, per subscription.
*
@@ -54,12 +54,7 @@ import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
/** Why a deploy could not happen, for the callers that answer differently. */
export type EventsDeployOutcome =
| 'deployed'
| 'stale'
| 'no-handlers'
| 'no-owner'
| 'throttled'
| 'failed';
'deployed' | 'stale' | 'no-handlers' | 'no-owner' | 'throttled' | 'failed';
interface DeployLayers {
config: IConfig;
@@ -238,7 +233,7 @@ export class EventsWorkerDeployer {
const user = await stores.user.getById(ownerUserId);
if (!user?.id || !user.uuid || !user.username || user.suspended)
return null;
return { user } as Actor & {
return { user, effectiveApp: null } as Actor & {
user: { id: number; uuid: string; username: string };
};
}
@@ -354,3 +354,134 @@ describe('GET /fs/readdir over HTTP', () => {
});
});
});
describe('POST /fs/completeWrite over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
it('refuses completion of a session whose object never arrived', async () => {
const { username, token } = env.users.user;
const started = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
fileMetadata: {
path: `/${username}/Documents/http-nobytes-${Date.now()}.bin`,
size: 4,
},
}),
});
expect(started.status).toBe(200);
const { sessionId } = (await started.json()) as { sessionId: string };
// No PUT to the presigned URL.
const response = await fetch(
new URL('/fs/completeWrite', env.apiOrigin),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ uploadId: sessionId }),
},
);
expect(response.status).toBe(400);
const body = (await response.json()) as {
code?: string;
message?: string;
};
expect(body.code).toBe('bad_request');
expect(body.message).toBe('Upload content was not received');
});
});
describe('POST /fs/startWrite storage quota over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv({
is_storage_limited: true,
storage_capacity: 10 * 1024,
} as never);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
it('rejects the excess of a parallel burst of startWrite calls', async () => {
const { username, token } = env.users.user;
const user = await env.server.stores.user.getByUsername(username);
const fs = env.server.services.fs as unknown as {
getUsersStorageAllowance: (
userId: number,
) => Promise<{ curr: number; max: number }>;
};
const { curr, max } = await fs.getUsersStorageAllowance(user!.id);
const size = Math.floor((max - curr) / 3) + 1;
const responses = await Promise.all(
Array.from({ length: 8 }, (_, i) =>
fetch(new URL('/fs/startWrite', env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
fileMetadata: {
path: `/${username}/Documents/http-quota-burst-${i}.bin`,
size,
},
}),
}),
),
);
const ok = responses.filter((response) => response.status === 200);
const rejected = responses.filter(
(response) => response.status === 413,
);
expect(ok).toHaveLength(2);
expect(rejected).toHaveLength(6);
for (const response of rejected) {
const body = (await response.json()) as { code?: string };
expect(body.code).toBe('storage_limit_reached');
}
});
// A raw JSON body carries `1e400` as a numeral, not a computed value, so
// `JSON.stringify` can't produce it (it turns `Infinity` into `null`) —
// the wire text is built by hand to exercise what the server's own
// `JSON.parse` does with it.
it('rejects a declared size of 1e400 (parses to Infinity) with 400', async () => {
const { username, token } = env.users.user;
const body = `{"fileMetadata":{"path":"/${username}/Documents/http-huge.bin","size":1e400}}`;
const response = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body,
});
expect(response.status).toBe(400);
const responseBody = (await response.json()) as { code?: string };
expect(responseBody.code).toBe('bad_request');
});
});
@@ -23,9 +23,11 @@ import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { makeActor, type Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { SHARE_LIST_LIMIT } from '../share/limits.js';
import type { FSController } from './FSController.js';
import type {
ClientSignedWriteResponse,
@@ -340,9 +342,16 @@ describe('FSController.completeBatchWrites', () => {
.body as ClientSignedWriteResponse[];
expect(startResponses).toHaveLength(2);
// 2) Complete via the controller. Single-mode completion only
// needs the session row → it doesn't read the S3 object back,
// so we can skip the actual upload step in this test.
// 2) PUT the declared bytes through each presigned URL, then
// complete via the controller.
await fetch(startResponses[0]!.url!, {
method: 'PUT',
body: '12345',
});
await fetch(startResponses[1]!.url!, {
method: 'PUT',
body: '1234567',
});
const { res, captured } = makeRes();
const completeBody: CompleteWriteRequest[] = startResponses.map(
(r) => ({ uploadId: r.sessionId }),
@@ -414,6 +423,7 @@ describe('FSController.completeBatchWrites', () => {
);
const [firstResponse] = firstStart.captured
.body as ClientSignedWriteResponse[];
await fetch(firstResponse!.url!, { method: 'PUT', body: 'x' });
const firstComplete = makeRes();
await withActor(actor, () =>
controller.completeBatchWrites(
@@ -545,6 +555,7 @@ describe('FSController.completeBatchWrites', () => {
);
const [firstResponse] = firstStart.captured
.body as ClientSignedWriteResponse[];
await fetch(firstResponse!.url!, { method: 'PUT', body: 'x' });
await withActor(actor, () =>
controller.completeBatchWrites(
makeReq<CompleteWriteRequest[]>({
@@ -798,6 +809,40 @@ describe('FSController.statEntry', () => {
statusCode: 401,
});
});
it('draws return_shares from the share-listing budget', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const path = `/${username}/Documents/share-budget`;
await withActor(actor, () =>
controller.mkdirEntry(makeReq({ body: { path }, actor }), makeRes().res),
);
// Spend the whole share:list bucket, as /share/shares' gate would.
const chargeReq = makeReq({ body: {}, actor });
for (let i = 0; i < SHARE_LIST_LIMIT.limit; i++) {
await consumeRouteRateLimit(chargeReq, SHARE_LIST_LIMIT);
}
await expect(
withActor(actor, () =>
controller.statEntry(
makeReq({ body: { path, return_shares: true }, actor }),
makeRes().res,
),
),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'too_many_requests',
});
// A plain stat spends only its own budget, so it still admits.
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.statEntry(makeReq({ body: { path }, actor }), res),
);
expect((captured.body as { name: string }).name).toBe('share-budget');
});
});
// ── /readdir (readdirEntries) ───────────────────────────────────────
@@ -1822,6 +1867,44 @@ describe('FSController.readdirEntries recursive', () => {
).toEqual(['l1a', 'l1a/l2a', 'l1a/l2a/l3a', 'l1a/l2a/l3a/l4a', 'l1b']);
});
it('sorts a recursive listing, paging in the same order', async () => {
const { actor, base } = await makeTree();
// A recursive `name` sort is path order, so descending walks the
// subtree backwards.
const descending = (await readdir(actor, {
path: base,
recursive: true,
depth: 10,
sortBy: 'name',
sortOrder: 'desc',
})) as { items: Array<{ path: string }> };
expect(
descending.items.map((e) => e.path.slice(base.length + 1)),
).toEqual(['l1b', 'l1a/l2a/l3a/l4a', 'l1a/l2a/l3a', 'l1a/l2a', 'l1a']);
const seen: string[] = [];
let cursor: string | null | undefined = null;
do {
const page = (await readdir(actor, {
path: base,
recursive: true,
depth: 10,
sortOrder: 'desc',
limit: 2,
cursor,
})) as { items: Array<{ path: string }>; cursor?: string };
seen.push(...page.items.map((e) => e.path.slice(base.length + 1)));
cursor = page.cursor;
} while (cursor);
expect(seen).toEqual([
'l1b',
'l1a/l2a/l3a/l4a',
'l1a/l2a/l3a',
'l1a/l2a',
'l1a',
]);
});
it('counts the subtree with includeTotal', async () => {
const { actor, base } = await makeTree();
const page = (await readdir(actor, {
@@ -2657,6 +2740,7 @@ describe('FSController associatedAppId entitlement gate', () => {
),
);
const [started] = startRes.captured.body as ClientSignedWriteResponse[];
await fetch(started.url!, { method: 'PUT', body: 'abc' });
const completeRes = makeRes();
await withActor(actor, () =>
controller.completeBatchWrites(
+64 -5
View File
@@ -47,6 +47,8 @@ import {
} from '../../util/concurrency.js';
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
import { listClientShares } from '../share/clientShare.js';
import { SHARE_LIST_LIMIT } from '../share/limits.js';
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
import { PuterController } from '../types.js';
import { STORAGE_OP_COSTS } from '../../services/metering/costs.js';
import {
@@ -328,6 +330,9 @@ export class FSController extends PuterController {
req.body,
);
this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData);
await this.#assertUploadSessionWriteAccess(req, userId, [
requestBody.uploadId,
]);
const response = await this.services.fs.completeUrlWrite(
userId,
@@ -373,6 +378,11 @@ export class FSController extends PuterController {
for (const requestBody of requests) {
this.#assertNoInlineSignedThumbnailData(requestBody.thumbnailData);
}
await this.#assertUploadSessionWriteAccess(
req,
userId,
requests.map((requestBody) => requestBody.uploadId),
);
const response = await this.services.fs.batchCompleteUrlWrite(
userId,
requests,
@@ -433,6 +443,9 @@ export class FSController extends PuterController {
res: Response<ClientSignMultipartPartsResponse>,
) {
const userId = this.#getActorUserId(req);
await this.#assertUploadSessionWriteAccess(req, userId, [
req.body?.uploadId,
]);
const response = await this.services.fs.signMultipartParts(
userId,
req.body,
@@ -991,6 +1004,17 @@ export class FSController extends PuterController {
await this.#assertAccess(actor, entry.path, 'see');
const wantsSize = this.#toBoolean(body.return_size);
const wantsShares = this.#toBoolean(body.return_shares);
// `return_shares` is the share-listing route's work, so it spends from
// the share-listing budget too, not just `fs:stat`'s.
if (
wantsShares &&
!(await consumeRouteRateLimit(req, SHARE_LIST_LIMIT))
) {
throw new HttpError(429, 'Too many requests.', {
legacyCode: 'too_many_requests',
});
}
const [subtreeSize, suggestedApps, shareFlags, shares] =
await Promise.all([
entry.isDir && wantsSize
@@ -998,7 +1022,7 @@ export class FSController extends PuterController {
: undefined,
this.services.suggestedApps.getSuggestedApps(entry),
this.services.share.shareFlags(actor, [entry]),
this.#toBoolean(body.return_shares)
wantsShares
? listClientShares(
this.services.share,
this.clients.event,
@@ -1140,8 +1164,8 @@ export class FSController extends PuterController {
Object.prototype.hasOwnProperty.call(body, 'cursor') ||
includeTotal;
// Undocumented: `recursive` lists descendants (prefix scan) up to
// `depth` levels below the target. Always paginated; sorts by path.
// `recursive` lists descendants (prefix scan) up to `depth` levels
// below the target. Always paginated.
const recursive = this.#toBoolean(body.recursive) === true;
if (this.#isRootPathRef(body)) {
@@ -1230,6 +1254,8 @@ export class FSController extends PuterController {
? body.cursor
: undefined,
maxDepth,
sortBy,
sortOrder,
},
);
await this.#attachSuggestedApps(page.entries);
@@ -2234,8 +2260,7 @@ export class FSController extends PuterController {
// the ActorUser type. Access via the escape hatch until a proper
// storage-quota mechanism is in place.
const actorUser = req.actor?.user as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const candidates = [
this.#toStorageCapacityCandidate(actorUser?.free_storage),
@@ -2509,6 +2534,40 @@ export class FSController extends PuterController {
);
}
/**
* An upload session stays usable for as long as it lives, so the access
* `startWrite` checked has to be re-checked against the session's recorded
* target every time the caller signs more parts or completes the upload —
* otherwise a revoked sharee still lands bytes in the owner's tree.
*/
async #assertUploadSessionWriteAccess(
req: Request,
userId: number,
uploadIds: Array<string | undefined>,
): Promise<void> {
// A malformed id is left to the service, which owns that error shape.
const knownUploadIds = uploadIds.filter(
(uploadId): uploadId is string =>
typeof uploadId === 'string' && uploadId.length > 0,
);
if (knownUploadIds.length === 0) {
return;
}
const sessions = await this.services.fs.getUploadSessions(
userId,
knownUploadIds,
);
await this.#assertBatchWriteAccess(
req,
sessions.map((session) => ({
path: session.targetPath,
size: session.size,
overwrite: Boolean(session.overwriteTargetUid),
})),
{ pathAlreadyNormalized: true },
);
}
#toEventGuiMetadata(
guiMetadata: WriteGuiMetadata | undefined,
includeOriginalClientSocketId = true,
@@ -1024,6 +1024,7 @@ describe('FSController.completeWrite', () => {
const { actor, username } = await makeUser();
const target = `/${username}/Documents/complete-single.txt`;
const started = await startSignedWrite(actor, target, 4);
await fetch(started.url!, { method: 'PUT', body: 'wxyz' });
const { res, captured } = makeRes();
await withActor(actor, () =>
@@ -1073,6 +1074,7 @@ describe('FSController.completeWrite', () => {
`/${username}/Documents/complete-thumb.txt`,
2,
);
await fetch(started.url!, { method: 'PUT', body: 'yz' });
const { res } = makeRes();
await withActor(actor, () =>
controller.completeWrite(
@@ -1921,3 +1923,215 @@ describe('FSController.batchWrites (multipart)', () => {
).toBeNull();
});
});
// -- upload sessions outliving their share ----------------------------
//
// An upload session is authorized once, at `/fs/startWrite`. Resuming or
// completing one has to re-check that the caller still has write access to the
// session's target, or a revoked sharee lands bytes in the owner's tree.
describe('FSController upload session access re-checks', () => {
const shareWritableFolder = async (folderName: string) => {
const owner = await makeUser();
const recipient = await makeUser();
const folder = `/${owner.username}/Documents/${folderName}`;
await withActor(owner.actor, () =>
controller.startWrite(
makeReq<SignedWriteRequest>({
body: {
fileMetadata: {
path: folder,
size: 0,
createMissingParents: true,
},
directory: true,
},
actor: owner.actor,
}),
makeRes().res,
),
);
const entry = await server.stores.fsEntry.getEntryByPath(folder);
// Read stays granted so the denial is a plain 403 rather than the
// existence-masking 404 an invisible path gets.
for (const mode of ['read', 'write']) {
await server.services.permission.grantUserUserPermission(
owner.actor,
recipient.username,
`fs:${entry!.uuid}:${mode}`,
{},
);
}
const revokeWrite = () =>
server.services.permission.revokeUserUserPermission(
owner.actor,
recipient.username,
`fs:${entry!.uuid}:write`,
);
return { owner, recipient, folder, revokeWrite };
};
const startUpload = async (
actor: Actor,
path: string,
extra: Partial<SignedWriteRequest> = {},
) => {
const { res, captured } = makeRes();
await withActor(actor, () =>
controller.startWrite(
makeReq<SignedWriteRequest>({
body: { fileMetadata: { path, size: 8 }, ...extra },
actor,
}),
res,
),
);
return captured.body as ClientSignedWriteResponse;
};
it('refuses to sign more parts once the share is revoked', async () => {
const { recipient, folder, revokeWrite } =
await shareWritableFolder('revoked-parts');
const started = await startUpload(
recipient.actor,
`${folder}/upload.bin`,
{ uploadMode: 'multipart' },
);
await revokeWrite();
const { res } = makeRes();
await expect(
withActor(recipient.actor, () =>
controller.signMultipartParts(
makeReq<SignMultipartPartsRequest>({
body: {
uploadId: started.sessionId,
partNumbers: [1],
},
actor: recipient.actor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('refuses to complete a session once the share is revoked', async () => {
const { recipient, folder, revokeWrite } =
await shareWritableFolder('revoked-complete');
const target = `${folder}/upload.bin`;
const started = await startUpload(recipient.actor, target);
await revokeWrite();
const { res } = makeRes();
await expect(
withActor(recipient.actor, () =>
controller.completeWrite(
makeReq<CompleteWriteRequest>({
body: { uploadId: started.sessionId },
actor: recipient.actor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 403 });
expect(await server.stores.fsEntry.getEntryByPath(target)).toBeNull();
});
it('refuses the batch completion once the share is revoked', async () => {
const { recipient, folder, revokeWrite } =
await shareWritableFolder('revoked-batch');
const targets = [`${folder}/batch-a.bin`, `${folder}/batch-b.bin`];
const started = [
await startUpload(recipient.actor, targets[0]!),
await startUpload(recipient.actor, targets[1]!),
];
await revokeWrite();
const { res } = makeRes();
await expect(
withActor(recipient.actor, () =>
controller.completeBatchWrites(
makeReq<CompleteWriteRequest[]>({
body: started.map((s) => ({ uploadId: s.sessionId })),
actor: recipient.actor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 403 });
for (const target of targets) {
expect(
await server.stores.fsEntry.getEntryByPath(target),
).toBeNull();
}
});
it('still signs parts and completes while the share stands', async () => {
const { recipient, folder } = await shareWritableFolder('kept-share');
const multipart = await startUpload(
recipient.actor,
`${folder}/kept-parts.bin`,
{ uploadMode: 'multipart' },
);
const signed = makeRes();
await withActor(recipient.actor, () =>
controller.signMultipartParts(
makeReq<SignMultipartPartsRequest>({
body: { uploadId: multipart.sessionId, partNumbers: [1] },
actor: recipient.actor,
}),
signed.res,
),
);
expect(
(signed.captured.body as { multipartPartUrls: unknown[] })
.multipartPartUrls,
).toHaveLength(1);
const target = `${folder}/kept-complete.bin`;
const single = await startUpload(recipient.actor, target);
await fetch(single.url!, { method: 'PUT', body: '12345678' });
await withActor(recipient.actor, () =>
controller.completeWrite(
makeReq<CompleteWriteRequest>({
body: { uploadId: single.sessionId },
actor: recipient.actor,
}),
makeRes().res,
),
);
expect(
await server.stores.fsEntry.getEntryByPath(target),
).not.toBeNull();
});
it('completes a batch while the share stands', async () => {
const { recipient, folder } = await shareWritableFolder('kept-batch');
const targets = [
`${folder}/kept-batch-a.bin`,
`${folder}/kept-batch-b.bin`,
];
const started = [
await startUpload(recipient.actor, targets[0]!),
await startUpload(recipient.actor, targets[1]!),
];
for (const s of started) {
await fetch(s.url!, { method: 'PUT', body: '12345678' });
}
await withActor(recipient.actor, () =>
controller.completeBatchWrites(
makeReq<CompleteWriteRequest[]>({
body: started.map((s) => ({ uploadId: s.sessionId })),
actor: recipient.actor,
}),
makeRes().res,
),
);
for (const target of targets) {
expect(
await server.stores.fsEntry.getEntryByPath(target),
).not.toBeNull();
}
});
});
@@ -23,11 +23,14 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { makeActor, type Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import type { HttpError } from '../../core/http/HttpError.js';
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { signFile } from '../../util/fileSigning.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { SHARE_LIST_LIMIT } from '../share/limits.js';
import type { LegacyFSController } from './LegacyFSController.js';
// ── Test harness ────────────────────────────────────────────────────
@@ -426,6 +429,40 @@ describe('LegacyFSController.stat', () => {
statusCode: 401,
});
});
it('draws return_shares from the share-listing budget', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const path = `/${username}/Documents/share-budget`;
await withActor(actor, () =>
controller.mkdir(makeReq({ body: { path }, actor }), makeRes().res),
);
// Spend the whole share:list bucket, as /share/shares' gate would.
const chargeReq = makeReq({ body: {}, actor });
for (let i = 0; i < SHARE_LIST_LIMIT.limit; i++) {
await consumeRouteRateLimit(chargeReq, SHARE_LIST_LIMIT);
}
await expect(
withActor(actor, () =>
controller.stat(
makeReq({ body: { path, return_shares: true }, actor }),
makeRes().res,
),
),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'too_many_requests',
});
// A plain stat spends only its own budget, so it still admits.
const plain = makeRes();
await withActor(actor, () =>
controller.stat(makeReq({ body: { path }, actor }), plain.res),
);
expect(plain.captured.body).toMatchObject({ name: 'share-budget' });
});
});
describe('LegacyFSController.delete', () => {
@@ -2312,6 +2349,72 @@ describe('LegacyFSController.checkAppAcl', () => {
const body = captured.body as { allowed: boolean };
expect(typeof body.allowed).toBe('boolean');
});
it("answers another user's entry the same as a missing one", async () => {
const { actor: victim } = await makeUser();
const victimPath = `/${victim.user!.username!}/Documents/secret.txt`;
await withActor(victim, () =>
controller.touch(
makeReq({
body: { path: victimPath, set_modified_to_now: true },
actor: victim,
}),
makeRes().res,
),
);
const victimEntry =
await server.stores.fsEntry.getEntryByPath(victimPath);
const { actor } = await makeUser();
const app = await (
server.stores.app.create as unknown as (
fields: Record<string, unknown>,
opts: { ownerUserId: number },
) => Promise<{ uid: string }>
)(
{
name: `cacl-${uuidv4()}`,
title: 'ACL test app',
index_url: 'https://example.test/cacl.html',
},
{ ownerUserId: actor.user!.id! },
);
const errorFor = async (subject: unknown) => {
try {
await withActor(actor, () =>
controller.checkAppAcl(
makeReq({
body: { subject, app: app.uid, mode: 'read' },
actor,
}),
makeRes().res,
),
);
return null;
} catch (e) {
const err = e as HttpError;
return {
statusCode: err.statusCode,
message: err.message,
legacyCode: err.legacyCode,
};
}
};
const missing = await errorFor({
path: `/${victim.user!.username!}/Documents/nope.txt`,
});
expect(missing).toEqual({
statusCode: 404,
message: 'Subject does not exist',
legacyCode: 'subject_does_not_exist',
});
expect(await errorFor({ path: victimPath })).toEqual(missing);
expect(await errorFor({ uid: victimEntry!.uuid })).toEqual(missing);
expect(await errorFor({ id: victimEntry!.id })).toEqual(missing);
expect(await errorFor({ uid: uuidv4() })).toEqual(missing);
});
});
// ── down (validation paths) ─────────────────────────────────────────
@@ -24,6 +24,7 @@ import { posix as pathPosix } from 'node:path';
import {
assertResolvedActor,
isAccessTokenActor,
isAppActor,
makeActor,
} from '../../core/actor.js';
import { Context } from '../../core/context.js';
@@ -50,6 +51,8 @@ import {
} from '../../util/hostedAppBacking.js';
import { applyInlineContentSecurity } from '../../util/inlineContentSecurity.js';
import { listClientShares } from '../share/clientShare.js';
import { SHARE_LIST_LIMIT } from '../share/limits.js';
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
import { PuterController } from '../types.js';
import {
FS_BATCH_CONCURRENT,
@@ -487,6 +490,13 @@ export class LegacyFSController extends PuterController {
// callers working. `return_owner` is a no-op — `owner` is always set.
if (getBoolean(body, 'return_versions')) shaped.versions = [];
if (getBoolean(body, 'return_shares')) {
// Share-listing work spends the share-listing budget, not just
// `fs:stat`'s.
if (!(await consumeRouteRateLimit(req, SHARE_LIST_LIMIT))) {
throw new HttpError(429, 'Too many requests.', {
legacyCode: 'too_many_requests',
});
}
shaped.shares = await listClientShares(
this.services.share,
this.clients.event,
@@ -859,9 +869,7 @@ export class LegacyFSController extends PuterController {
// Trash, and `null`/`{}` when restoring. See
// `src/gui/src/helpers.js` → `window.move_items`.
newMetadata: (body.new_metadata ?? undefined) as
| Record<string, unknown>
| null
| undefined,
Record<string, unknown> | null | undefined,
});
const oldPath = source.path;
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
@@ -1291,15 +1299,14 @@ export class LegacyFSController extends PuterController {
legacyCode: 'bad_request',
});
const isApp = Boolean((actor as { app?: unknown }).app);
const actingApp = actor.effectiveApp;
const signingCfg = signingConfigFromAppConfig(this.config);
// Apps can only sign inside their AppData root.
let appDataRoot: string | null = null;
if (isApp) {
const username = (actor as { user?: { username?: string } }).user
?.username;
const appUid = (actor as { app?: { uid?: string } }).app?.uid;
if (actingApp) {
const username = actor.user?.username;
const appUid = actingApp.uid;
if (!username || !appUid)
throw new HttpError(403, 'Forbidden', {
legacyCode: 'forbidden',
@@ -1308,8 +1315,7 @@ export class LegacyFSController extends PuterController {
}
type SignedOrEmpty =
| (SignedFile & { path?: string })
| Record<string, never>;
(SignedFile & { path?: string }) | Record<string, never>;
const result: { signatures: SignedOrEmpty[]; token?: string } = {
signatures: [],
};
@@ -1795,7 +1801,7 @@ export class LegacyFSController extends PuterController {
*/
openItem = async (req: Request, res: Response): Promise<void> => {
const actor = this.#requireActor(req);
if ((actor as { app?: unknown }).app) {
if (actor.effectiveApp) {
throw new HttpError(
403,
'This endpoint is only available to user sessions',
@@ -1889,8 +1895,10 @@ export class LegacyFSController extends PuterController {
legacyCode: 'bad_request',
});
const actorApp = (actor as { app?: { uid?: string } }).app;
if (!actorApp?.uid || actorApp.uid !== appUid) {
// The app acting directly, not one that merely issued the credential:
// claiming a root dir is the app itself asking, and a token is not it.
const callerApp = isAppActor(actor) ? actor.effectiveApp : null;
if (callerApp?.uid !== appUid) {
throw new HttpError(
403,
'Only the app itself may request its root dir',
@@ -1924,25 +1932,40 @@ export class LegacyFSController extends PuterController {
* access to a subject FS entry.
*/
checkAppAcl = async (req: Request, res: Response): Promise<void> => {
this.#requireActor(req);
const actor = this.#requireActor(req);
const body = asRecord(req.body);
const subjectRef = body.subject;
const appRef = body.app;
const mode = (getString(body, 'mode') ?? 'read') as
| 'see'
| 'list'
| 'read'
| 'write';
'see' | 'list' | 'read' | 'write';
if (!subjectRef || !appRef)
throw new HttpError(400, '`subject` and `app` are required', {
legacyCode: 'bad_request',
});
const subject = await resolveV1Selector(
this.stores.fsEntry,
subjectRef,
);
// A missing subject and one the caller can't see get the same 404,
// so this can't be used to probe for other users' entries.
let subject: import('../../stores/fs/FSEntry.js').FSEntry | null = null;
try {
subject = await resolveV1Selector(this.stores.fsEntry, subjectRef);
} catch (err) {
if (!(err instanceof HttpError && err.statusCode === 404))
throw err;
}
let ancestors: Promise<Array<{ uid: string; path: string }>> | null =
null;
const descriptor = subject && {
path: subject.path,
resolveAncestors: () =>
(ancestors ??= this.services.fs.getAncestorChain(subject.path)),
};
const acl = this.services.acl as ACLService;
if (!descriptor || !(await acl.check(actor, descriptor, 'see')))
throw new HttpError(404, 'Subject does not exist', {
legacyCode: 'subject_does_not_exist',
});
let app: { uid: string } | null = null;
if (typeof appRef === 'string') {
app =
@@ -1956,19 +1979,10 @@ export class LegacyFSController extends PuterController {
// Build an actor-under-user shape for the check.
const actorForApp = makeActor({
user: req.actor!.user,
user: actor.user,
app: { uid: (app as { uid: string }).uid },
});
const descriptor = {
path: subject.path,
resolveAncestors: () =>
this.services.fs.getAncestorChain(subject.path),
};
const allowed = await (this.services.acl as ACLService).check(
actorForApp,
descriptor,
mode,
);
const allowed = await acl.check(actorForApp, descriptor, mode);
res.json({ allowed });
};
+12 -32
View File
@@ -135,24 +135,11 @@ export interface WriteResponse {
}
/**
* An `FSEntry` as it is safe to hand to a client. Built by an allowlist, so the
* `fsentries` primary key (`id`) and its `parentId`/`associatedAppId`
* references, the storage columns (`bucket`, `bucketRegion`), the owning
* `userId`, and the `publicToken`/`fileRequestToken` capability tokens never
* reach the wire. Entries are addressed by `uuid`.
*
* `shortcutTo` is the one numeric row reference that stays: the v1 contract has
* always exposed it as `shortcut_to` and the desktop resolves shortcuts through
* it, so dropping it would break them.
*
* The `?: never` members below are guards, not fields: they make a raw
* `FSEntry` fail to typecheck wherever a `ClientFSEntry` is expected. Without
* them this type is just a structural subset of `FSEntry`, so an unsanitized
* row would be silently assignable and the distinction would buy nothing.
*
* Checked by `tsc -p tsconfig.json` (the strict config). Note that
* `tsconfig.build.json` sets `noCheck: true` — it only transpiles, so it will
* not catch a violation here.
* An `FSEntry` safe to send to a client. Built by allowlist so row ids, storage
* columns and capability tokens never reach the wire; `shortcutTo` stays
* because the v1 contract exposes it. The `?: never` members make a raw
* `FSEntry` fail to typecheck where a `ClientFSEntry` is expected; only the
* strict `tsconfig.json` catches that, since the build config sets `noCheck`.
*/
export interface ClientFSEntry {
uuid: string;
@@ -189,17 +176,12 @@ export interface ClientFSEntry {
fileRequestToken?: never;
}
// Wire counterparts of the write responses: what the controller sends after
// sanitizing, kept distinct so the compiler can tell the two apart.
/**
* Wire counterparts of the write responses. The bare `…Response` types describe
* what the service produces internally (a real `FSEntry`); these describe what
* the controller sends after sanitizing. Keeping them distinct is what makes
* "did this response get sanitized?" a question the compiler answers.
*/
/**
* The presigned-upload envelope minus the storage internals. A client uploads
* to the presigned `url` / `multipartPartUrls`, which already carry everything
* S3 needs, so `bucket`, `bucketRegion`, and `objectKey` are ours to keep —
* they name where a user's bytes physically live.
* The presigned-upload envelope minus storage internals; the presigned URLs
* already carry what the store needs.
*/
export type ClientSignedWriteResponse = Omit<
SignedWriteResponse,
@@ -221,10 +203,8 @@ export type ClientWriteResponse = Omit<WriteResponse, 'fsEntry'> & {
};
/**
* A directory-listing entry: a sanitized entry plus the three fields a client
* cannot derive on its own — the MIME `type`, a _signed_ `thumbnail` URL (the
* stored value is an S3 key, which is useless to a client), and the resolved
* `associatedApp`.
* A sanitized entry plus what a client cannot derive: MIME `type`, a signed
* `thumbnail` URL, the resolved `associatedApp`.
*/
export type ClientReaddirEntry = ClientFSEntry & {
type: string | null;
+2
View File
@@ -40,11 +40,13 @@ import { WebDAVController } from './webdav/WebDAVController.js';
import { WispController } from './wisp/WispController.js';
import type { IPuterControllerRegistry } from './types.js';
import { PeerController } from './peer/PeerController.js';
import { ProfileController } from './profile/ProfileController.js';
export const puterControllers = {
staticAssets: StaticAssetsController,
staticPages: StaticPagesController,
auth: AuthController,
profile: ProfileController,
apps: AppController,
appFeedback: AppFeedbackController,
desktop: DesktopController,
@@ -58,6 +58,10 @@ let router: PuterRouter;
// override in and out (same pattern as AuthController.test.ts).
type SignupValidateOverride = (data: Record<string, unknown>) => void;
let signupValidateOverride: SignupValidateOverride | null = null;
// Stand-in for the extension that honors signup bonus codes.
let bonusValidateOverride: SignupValidateOverride | null = null;
let bonusCheckOverride: SignupValidateOverride | null = null;
const heardSignupSuccess: Array<Record<string, unknown>> = [];
beforeAll(async () => {
server = await setupTestServer({
@@ -90,6 +94,24 @@ beforeAll(async () => {
}
},
);
server.clients.event.on(
'puter.signup-bonus.check',
(_k: unknown, data: unknown) => {
bonusCheckOverride?.(data as Record<string, unknown>);
},
);
server.clients.event.on(
'puter.signup-bonus.validate',
(_k: unknown, data: unknown) => {
bonusValidateOverride?.(data as Record<string, unknown>);
},
);
server.clients.event.on(
'puter.signup.success',
(_k: unknown, data: unknown) => {
heardSignupSuccess.push(data as Record<string, unknown>);
},
);
});
afterAll(async () => {
@@ -99,6 +121,8 @@ afterAll(async () => {
afterEach(() => {
vi.restoreAllMocks();
signupValidateOverride = null;
bonusValidateOverride = null;
bonusCheckOverride = null;
});
interface CapturedResponse {
@@ -955,6 +979,17 @@ describe('OIDCController login callback', () => {
expect(captured.cookies).toHaveLength(1);
expect(captured.redirectUrl).toContain('embedded_in_popup=true');
expect(captured.redirectUrl).toContain('oidc_login=true');
// The proof must bind to the account that just completed OIDC so the
// popup can refuse a proof replayed in another signed-in browser.
const openerState = new URL(captured.redirectUrl!).searchParams.get(
'opener_state',
);
const proof = oidc().verifyPopupReturn(openerState!);
expect(proof?.oidc_login).toBe(true);
const user = await server.stores.user.getByEmail(email);
expect(user?.uuid).toBeTruthy();
expect(proof?.user_uuid).toBe(user!.uuid);
});
it('uses popup-style error URL (msg_id + opener_origin) when the popup-state user is suspended', async () => {
@@ -1846,9 +1881,23 @@ describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
opener_origin: 'https://opener.test',
msg_id: '77',
oidc_login: true,
user_uuid: null,
});
});
it('hands back the account a proof is bound to', async () => {
// The popup compares this against its current user to reject a proof
// replayed from another account's login.
const proof = server.services.oidc.signPopupReturn({
opener_origin: 'https://opener.test',
msg_id: '77',
oidc_login: true,
user_uuid: 'user-A',
});
const captured = await redeem(proof);
expect(captured.body).toMatchObject({ user_uuid: 'user-A' });
});
it('rejects a proof signed with someone else’s key', async () => {
// The whole point: only the server can mint one of these.
const forged = jwt.sign(
@@ -1940,3 +1989,143 @@ describe('OIDCController rate limits', () => {
expect(rateLimitOf('get', '/auth/oidc/providers').limit).toBe(1_200);
});
});
// -- Signup bonus codes ----------------------------------------------
describe('OIDC signup bonus codes', () => {
const rand = () => Math.random().toString(36).slice(2, 8);
const startState = async (query: Record<string, unknown>) => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/:provider/start',
makeReq({ params: { provider: 'custom' }, query }),
res,
);
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
'state',
);
return oidc().verifyState(state!) as Record<string, unknown>;
};
const stubIdp = (sub: string, email: string) => {
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
access_token: 'access',
id_token: 'id',
} as never);
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
sub,
email,
email_verified: true,
} as never);
};
const openCheck = (data: Record<string, unknown>) => {
data.valid = true;
data.display = { title: 'T', description: 'D' };
};
const signupCallback = async (bonusCode: string) => {
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
bonus_code: bonusCode,
});
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/signup',
makeReq({ query: { code: 'c', state } }),
res,
);
return captured;
};
it('carries a canonical code in the signed state', async () => {
const decoded = await startState({
flow: 'signup',
bonusCode: 'Startup3M-ABCD1234',
});
expect(decoded.bonus_code).toBe('startup3mabcd1234');
});
it('drops a malformed code at start', async () => {
const decoded = await startState({ flow: 'signup', bonusCode: 'a:b' });
expect(decoded).not.toHaveProperty('bonus_code');
});
it('creates the account with the requirements an accepted code raises', async () => {
const email = `bonus-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
bonusCheckOverride = openCheck;
let seen: Record<string, unknown> | null = null;
bonusValidateOverride = (data) => {
seen = { ...data };
data.accepted = true;
data.requires_phone_verification = true;
};
const captured = await signupCallback('startup3mabcd1234');
expect(captured.cookies).toHaveLength(1);
expect(seen).toMatchObject({
code: 'startup3mabcd1234',
source: 'oidc',
email,
});
const user = await server.stores.user.findEmailOwner(email, {
force: true,
});
expect(Boolean(user!.requires_phone_verification)).toBe(true);
const deadline = Date.now() + 2000;
let success: Record<string, unknown> | undefined;
while (!success && Date.now() < deadline) {
success = heardSignupSuccess.find((e) => e.email === email);
if (!success) await new Promise((r) => setTimeout(r, 10));
}
expect(success?.bonus_code).toBe('startup3mabcd1234');
});
it('refuses a dead code before the validate hook runs', async () => {
const email = `bonus-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
let validated = false;
signupValidateOverride = (data) => {
if (data.email === email) validated = true;
};
const captured = await signupCallback('startup3mabcd1234');
expect(captured.redirectUrl).toContain('message=bonus_code_invalid');
expect(validated).toBe(false);
});
it('refuses the account when nothing accepts the code, without echoing it', async () => {
const email = `bonus-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
const captured = await signupCallback('startup3mabcd1234');
expect(captured.redirectUrl).toContain('message=bonus_code_invalid');
expect(captured.redirectUrl).not.toContain('bonusCode=');
expect(captured.cookies).toHaveLength(0);
expect(
await server.stores.user.findEmailOwner(email, { force: true }),
).toBeFalsy();
});
it('keeps the code on the retry redirect when something else failed', async () => {
const email = `bonus-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
bonusCheckOverride = openCheck;
signupValidateOverride = (data) => {
if (data.email === email) data.allow = false;
};
const captured = await signupCallback('startup3mabcd1234');
expect(captured.redirectUrl).toContain('message=signup_blocked');
expect(captured.redirectUrl).toContain('bonusCode=startup3mabcd1234');
});
});
+48 -5
View File
@@ -23,6 +23,7 @@ import { HttpError } from '../../core/http/HttpError.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterController } from '../types.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { normalizeBonusCode } from '../../util/signupBonus.js';
import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js';
import {
SHARE_DEEP_LINK_ITEMS_LIMIT,
@@ -50,6 +51,7 @@ const ALLOWED_ERRORS = [
'account_suspended',
'unauthorized',
'signup_blocked',
'bonus_code_invalid',
] as const;
/**
@@ -69,6 +71,23 @@ function resolutionErrorCode(code: string | undefined): string {
: 'signup_blocked';
}
/** What a new account created by an OIDC callback inherits from its flow. */
interface OIDCSignupOptions {
referrer?: string | null;
bonusCode?: string | null;
}
function signupOptionsFromState(
stateDecoded: Record<string, unknown>,
): OIDCSignupOptions {
return {
referrer: (stateDecoded.referrer as string) ?? null,
// Normalized when the state was signed; re-checked since this is the
// value signup acts on.
bonusCode: normalizeBonusCode(stateDecoded.bonus_code),
};
}
// GUI pages an OIDC flow may return to: the root (where a share email lands),
// /desktop, /dashboard, and direct app landings (/app/<name> and its
// desktop-booted twin /desktop/app/<name>, mirroring APP_NAME_REGEX in
@@ -222,6 +241,13 @@ function buildErrorRedirectUrl(
if (requestCode) {
params.set('request_code', requestCode);
}
// A retry from the error page keeps the bonus code, unless it's what failed.
if (
typeof stateDecoded?.bonus_code === 'string' &&
clamped !== 'bonus_code_invalid'
) {
params.set('bonusCode', stateDecoded.bonus_code);
}
for (const path of sharedPaths) {
params.append(SHARE_DEEP_LINK_PARAM, path);
}
@@ -302,6 +328,7 @@ export class OIDCController extends PuterController {
opener_origin: decoded.opener_origin ?? null,
msg_id: decoded.msg_id ?? null,
oidc_login: decoded.oidc_login === true,
user_uuid: decoded.user_uuid ?? null,
});
},
);
@@ -422,6 +449,15 @@ export class OIDCController extends PuterController {
redirect_uri: appRedirectUri,
};
if (referrer) statePayload.referrer = referrer ?? openerOrigin;
// Login can create an account too, so both flows carry it. A
// malformed code is dropped here and never reaches signup.
const rawBonusCode = Array.isArray(req.query.bonusCode)
? req.query.bonusCode[0]
: req.query.bonusCode;
const bonusCode = normalizeBonusCode(rawBonusCode);
if (bonusCode && (flow === 'login' || flow === 'signup')) {
statePayload.bonus_code = bonusCode;
}
if (embeddedInPopup && msgId) {
statePayload.embedded_in_popup = true;
statePayload.msg_id = msgId;
@@ -506,7 +542,7 @@ export class OIDCController extends PuterController {
const resolved = await this.#resolveOrCreateOIDCUser(
provider,
userinfo,
(stateDecoded.referrer as string) ?? null,
signupOptionsFromState(stateDecoded),
);
if ('error' in resolved) {
console.warn(
@@ -573,7 +609,7 @@ export class OIDCController extends PuterController {
const resolved = await this.#resolveOrCreateOIDCUser(
provider,
userinfo,
(stateDecoded.referrer as string) ?? null,
signupOptionsFromState(stateDecoded),
);
if ('error' in resolved) {
console.warn(
@@ -741,7 +777,7 @@ if (window.opener) {
async #resolveOrCreateOIDCUser(
provider: string,
userinfo: { sub: string; email?: unknown; [k: string]: unknown },
referrer?: string | null,
signup: OIDCSignupOptions = {},
attempt = 0,
): Promise<
| { error: string; code?: string; requestCode?: string }
@@ -791,7 +827,8 @@ if (window.opener) {
const outcome = await this.services.oidc.createUserFromOIDC(
provider,
userinfo as { sub: string; email?: string },
referrer,
signup.referrer ?? null,
{ bonusCode: signup.bonusCode ?? null },
);
// A concurrent callback (a second tab, a provider retry) created the
// account between step 2 and the insert. Nothing went wrong for the
@@ -801,7 +838,7 @@ if (window.opener) {
return this.#resolveOrCreateOIDCUser(
provider,
userinfo,
referrer,
signup,
attempt + 1,
);
}
@@ -924,6 +961,11 @@ if (window.opener) {
// identity to mint a token for, so it needs the integrity this
// state already carries — re-signed here, at the one point where
// the round trip is known to have actually happened.
//
// `user_uuid` binds the proof to the account that just completed
// OIDC. Without it a proof from one login could be replayed in
// another signed-in browser to skip its account picker; the popup
// only honors `oidc_login` when this matches its current user.
target = appendQueryParam(
target,
'opener_state',
@@ -931,6 +973,7 @@ if (window.opener) {
opener_origin: stateDecoded.opener_origin ?? null,
msg_id: stateDecoded.msg_id ?? null,
oidc_login: true,
user_uuid: user.uuid,
}),
);
}
@@ -1,6 +1,7 @@
import type { Request, Response } from 'express';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { makeActor } from '../../core/actor.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
@@ -382,10 +383,10 @@ describe('PeerController TURN', () => {
json: async () => ({ iceServers: [] }),
} as never);
try {
const appActor = {
const appActor = makeActor({
...userActor,
app: { uid: 'app-66666666-7777-8888-9999-aaaaaaaaaaaa' },
};
});
await generateTurn(makeReq({ actor: appActor }), makeRes().res);
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
const { customIdentifier } = JSON.parse(init.body as string) as {
@@ -667,10 +668,12 @@ describe('PeerController guest TURN', () => {
});
it('carries the app segment for an app-under-user host', () => {
const grant = mintGrant({
...hostActor,
app: { uid: 'app-66666666-7777-8888-9999-aaaaaaaaaaaa' },
});
const grant = mintGrant(
makeActor({
...hostActor,
app: { uid: 'app-66666666-7777-8888-9999-aaaaaaaaaaaa' },
}),
);
const verified = verifyGuestGrant({
grant,
@@ -98,8 +98,9 @@ const base64urlToUuid = (encoded: string): string | null => {
*/
const actorToTurnIdentifier = (actor: Actor): string => {
const userPart = uuidToBase64url(actor.user.uuid);
if (!actor.app) return userPart;
return `${userPart}:${uuidToBase64url(actor.app.uid)}`;
const appUid = actor.effectiveApp?.uid;
if (!appUid) return userPart;
return `${userPart}:${uuidToBase64url(appUid)}`;
};
/**
+3 -1
View File
@@ -17,7 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { toMicroCents } from '../../services/metering/utils';
// Microcents per byte of TURN egress ($0.05/GB).
export const PEER_COSTS = {
'turn:egress-bytes': 0.005,
'turn:egress-bytes': toMicroCents(0.1 / 1000 ** 3),
} as const;
@@ -0,0 +1,152 @@
/*
* 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
const PICTURE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=';
describe('profile routes over HTTP', () => {
let env: PuterTestEnv;
let userUuid = '';
/** The plan `env.users.user` is on; see the resolver below. */
let plan: string | null = null;
beforeAll(async () => {
env = await setupPuterTestEnv();
userUuid = (await env.server.stores.user.getByUsername(
env.users.user.username,
))!.uuid;
env.server.services.metering.registerPolicy({
id: 'business',
monthUsageAllowance: 45 * 1_000_000 * 100,
monthlyStorageAllowance: 1024 ** 3,
});
env.server.services.metering.registerSubscriptionResolver((actor) =>
actor.user?.uuid === userUuid ? plan : null,
);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const withSubscription = async <T>(
id: string | null,
fn: () => Promise<T>,
): Promise<T> => {
plan = id;
env.server.services.metering.invalidateActorSubscription(userUuid);
try {
return await fn();
} finally {
plan = null;
env.server.services.metering.invalidateActorSubscription(userUuid);
}
};
const get = (query: string, token?: string) =>
fetch(new URL(`/profile${query}`, env.apiOrigin), {
headers: token ? { authorization: `Bearer ${token}` } : {},
});
const post = (body: unknown, token: string) =>
fetch(new URL('/profile', env.apiOrigin), {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
it('lets a user write and read their own profile on any plan', async () => {
const write = await post(
{ picture: PICTURE, displayName: 'Test' },
env.users.user.token,
);
expect(write.status).toBe(200);
expect(await write.json()).toEqual({
picture: PICTURE,
displayName: 'Test',
bio: null,
});
const own = await get('', env.users.user.token);
expect(own.status).toBe(200);
expect(await own.json()).toMatchObject({ picture: PICTURE });
const byName = await get(
`?username=${env.users.user.username}`,
env.users.user.token,
);
expect(byName.status).toBe(200);
expect(await byName.json()).toMatchObject({ displayName: 'Test' });
});
it('hides a free user from anonymous and third-party readers as a 404', async () => {
const anonymous = await get(`?username=${env.users.user.username}`);
expect(anonymous.status).toBe(404);
const other = await get(
`?username=${env.users.user.username}`,
env.users.other.token,
);
expect(other.status).toBe(404);
// Same shape as an account that does not exist.
const missing = await get('?username=nobody-here');
expect(missing.status).toBe(404);
});
it('serves a paid user to anyone, and hides them again when the plan ends', async () => {
await withSubscription('business', async () => {
const anonymous = await get(`?username=${env.users.user.username}`);
expect(anonymous.status).toBe(200);
expect(await anonymous.json()).toMatchObject({ picture: PICTURE });
const other = await get(
`?username=${env.users.user.username}`,
env.users.other.token,
);
expect(other.status).toBe(200);
});
expect((await get(`?username=${env.users.user.username}`)).status).toBe(
404,
);
});
it('requires a username or a signed-in caller, and validates the username shape', async () => {
expect((await get('')).status).toBe(401);
expect((await get('?username=not%20valid')).status).toBe(400);
});
it('rejects a bad patch with a stable code and refuses app and anonymous writers', async () => {
const bad = await post({ name: 'x' }, env.users.user.token);
expect(bad.status).toBe(400);
expect(await bad.json()).toMatchObject({
code: 'profile_field_not_allowed',
});
const anonymous = await fetch(new URL('/profile', env.apiOrigin), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ bio: 'x' }),
});
expect(anonymous.status).toBe(401);
});
});
@@ -0,0 +1,103 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Request, Response } from 'express';
import { Controller, Get, Post } from '../../core/http/decorators.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { ProfileService } from '../../services/profile/ProfileService.js';
import { PuterController } from '../types.js';
// Shape only; the store decides whether the account exists.
const USERNAME_PATTERN = /^[a-z0-9_-]{1,64}$/i;
const notFound = () =>
new HttpError(404, 'Profile not found', { legacyCode: 'not_found' });
/**
* User profiles. Reads are open so an app can show another user's profile, but
* a profile other than the caller's own is served only while its owner is on a
* paid plan (`ProfileService.isPubliclyVisible`); a hidden one 404s like a
* missing account, so the response says nothing about the plan.
*/
@Controller('/profile')
export class ProfileController extends PuterController {
/**
* GET /profile?username=<name> — a user's profile. Without `username`, the
* signed-in user's own.
*/
@Get('/', {
subdomain: 'api',
rateLimit: {
scope: 'profile-read',
limit: 120,
window: 60_000,
key: 'ip',
},
})
async get(req: Request, res: Response): Promise<void> {
const service = this.services.profile as ProfileService;
const caller = req.actor?.user;
const username = req.query.username;
let target;
if (username === undefined) {
if (!caller?.uuid) {
throw new HttpError(401, 'Sign in, or pass a username', {
legacyCode: 'unauthorized',
});
}
target = await this.stores.user.getByUuid(caller.uuid);
} else {
if (
typeof username !== 'string' ||
!USERNAME_PATTERN.test(username)
) {
throw new HttpError(400, 'Invalid username', {
legacyCode: 'bad_request',
});
}
target = await this.stores.user.getByUsername(username);
}
if (!target) throw notFound();
const isOwner = caller?.uuid === target.uuid;
if (!isOwner && !(await service.isPubliclyVisible(target))) {
throw notFound();
}
res.json(await service.getProfile(target));
}
/** POST /profile — update the signed-in user's own profile. */
@Post('/', {
subdomain: 'api',
requireUserActor: true,
rateLimit: {
scope: 'profile-write',
limit: 30,
window: 60_000,
key: 'user',
},
})
async update(req: Request, res: Response): Promise<void> {
const service = this.services.profile as ProfileService;
const user = await this.stores.user.getByUuid(req.actor!.user.uuid!);
if (!user) throw notFound();
res.json(await service.updateProfile(user, req.body));
}
}
@@ -26,6 +26,7 @@ import {
type PuterTestEnv,
} from '../../testUtil.js';
import { ShareController } from './ShareController.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
/**
* Route-level coverage for the sharing endpoints. The service unit tests drive
@@ -1069,4 +1070,156 @@ describe('share endpoints over HTTP', () => {
expect(route.options?.allowFullAccessToken).toBeUndefined();
}
});
describe('an invited address over the wire', () => {
/** An app of the owner's, holding `mode` on one file, with a token. */
const appHolding = async (
owner: { username: string },
uid: string,
permission: (fileUid: string) => string,
) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const actor = makeActor({ user: user! });
const app = await env.server.stores.app.create(
{
name: `peek-app-${crypto.randomUUID().slice(0, 8)}`,
title: 'Peek app',
index_url: `https://peek-${crypto.randomUUID()}.test/`,
},
{ ownerUserId: user!.id },
);
await runWithContext({ actor }, () =>
env.server.services.permission.grantUserAppPermission(
actor,
app.uid,
permission(uid),
),
);
const token = await env.server.services.auth.getUserAppToken(
actor,
app.uid,
);
return { ...app, token };
};
const invitedFile = async (owner: { username: string; token: string }) => {
const file = await makeFile(owner);
const email = `wire-${crypto.randomUUID().slice(0, 8)}@test.local`;
const res = await post('/share', owner.token, {
recipients: [email],
items: [{ uid: file.uid }],
mode: 'read',
});
expect(res.status).toBe(200);
return { file, email };
};
it('reaches the owner but not an app the file was handed to', async () => {
const owner = env.users.user;
const { file, email } = await invitedFile(owner);
// The owner asked, so the owner is told.
const mine = await get('/share/shares', owner.token, {
uid: file.uid,
});
expect(mine.status).toBe(200);
expect(await mine.text()).toContain(email);
// The app is answered, but bounded to the rows it issued — and it
// issued none, so the owner's invite is not among them.
const app = await appHolding(owner, file.uid, (u) => `fs:${u}:read`);
const peeked = await get('/share/shares', app.token, {
uid: file.uid,
});
expect(peeked.status).toBe(200);
const peekedBody = await peeked.text();
expect(peekedBody).not.toContain(email);
expect(JSON.parse(peekedBody).items).toEqual([]);
// `return_shares` is the same listing on an access-token surface.
const stat = await post('/fs/stat', app.token, {
uid: file.uid,
return_shares: true,
});
expect(stat.status).toBe(200);
const body = await stat.text();
expect(body).not.toContain(email);
expect(JSON.parse(body).shares).toEqual([]);
});
// The ticket's second route in: `ShareController` admits no access
// token, but `/fs/stat` does, and `return_shares` is the same listing.
it('withholds the listing from a list-scoped access token', async () => {
const owner = env.users.user;
const { file, email } = await invitedFile(owner);
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const actor = makeActor({ user: user! });
const scoped = await runWithContext({ actor }, () =>
env.server.services.auth.createAccessToken(actor, [
[`fs:${file.uid}:list`],
]),
);
const stat = await post('/fs/stat', scoped, {
uid: file.uid,
return_shares: true,
});
expect(stat.status).toBe(200);
const body = await stat.text();
expect(body).not.toContain(email);
expect(JSON.parse(body).shares).toEqual([]);
});
// Withholding here would buy nothing: `shared-by-me` hands the same
// addresses to the same credential.
it('tells a full-access token what it could read from shared-by-me anyway', async () => {
const owner = env.users.user;
const { file, email } = await invitedFile(owner);
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const actor = makeActor({ user: user! });
const pat = await runWithContext({ actor }, () =>
env.server.services.auth.createAccessToken(actor, [
[FULL_API_ACCESS],
]),
);
const outbound = await get('/share/shared-by-me', pat, {});
expect(outbound.status).toBe(200);
expect(await outbound.text()).toContain(email);
const listed = await get('/share/shares', pat, { uid: file.uid });
expect(listed.status).toBe(200);
expect(await listed.text()).toContain(email);
});
it('answers an app that was handed the item to manage', async () => {
const owner = env.users.user;
const { file, email } = await invitedFile(owner);
const app = await appHolding(
owner,
file.uid,
(u) => `manage:fs:${u}`,
);
const res = await get('/share/shares', app.token, {
uid: file.uid,
});
expect(res.status).toBe(200);
const text = await res.text();
// `manage` buys an app authority over the item, not sight of who
// else the owner invited to it.
expect(
JSON.parse(text).items.some(
(i: { pending?: boolean }) => i.pending,
),
).toBe(false);
expect(text).not.toContain(email);
});
});
});
@@ -31,28 +31,7 @@ import { toClientShare } from './clientShare.js';
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
import { normalizeLimit } from '../../util/pagination.js';
import { PuterController } from '../types.js';
/**
* Two windows: a burst ceiling, and a daily one so a slow drip can't add up to
* a mail-merge. Neither bounds _shares_ — one request carries many — which is
* what `ShareService`'s per-day quota is for.
*/
const SHARE_LIMIT = [
{ scope: 'share:mutate', limit: 60, window: 60_000, key: 'user' as const },
{
scope: 'share:mutate-daily',
limit: 500,
window: 24 * 60 * 60_000,
key: 'user' as const,
},
];
const SHARE_LIST_LIMIT = {
scope: 'share:list',
limit: 600,
window: 60_000,
key: 'user' as const,
};
import { SHARE_LIMIT, SHARE_LIST_LIMIT } from './limits.js';
/** Distinct (holder, item) pairs run together; see the note on grouping below. */
const SHARE_CONCURRENCY = 8;
@@ -104,10 +83,15 @@ export class ShareController extends PuterController {
* POST /share — grant `mode` on one or more items to one or more
* recipients. Partial success is the contract: each pair reports its own
* outcome and the envelope summarizes.
*
* Handing out access is the one share surface that reaches other people, so
* it asks for a verified phone or card first; withdrawing and listing never
* do — a caller must always be able to see and undo what it shared.
*/
@Post('', {
subdomain: 'api',
requireVerified: true,
requireAnyVerified: ['phone', 'card'],
rateLimit: SHARE_LIMIT,
})
async createShares(req: Request, res: Response): Promise<void> {
@@ -644,6 +628,7 @@ export class ShareController extends PuterController {
/** Echoes back the identifier the caller named, so results are matchable. */
#recipientLabel(recipient: ShareRecipient): string {
if (recipient.anyone) return 'anyone';
return (
recipient.email ??
recipient.username ??
@@ -670,6 +655,12 @@ export class ShareController extends PuterController {
}
if (entry && typeof entry === 'object') {
const rec = entry as Record<string, unknown>;
// Object form only, and the literal `true`: nothing typed into
// a people field can become "everyone".
if (rec.anyone === true) {
out.push({ anyone: true });
continue;
}
const email =
typeof rec.email === 'string' ? rec.email.trim() : '';
const username =
@@ -0,0 +1,182 @@
/*
* 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 { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
createTestUser,
setupPuterTestEnv,
type PuterTestEnv,
type TestUserCredentials,
} from '../../testUtil.js';
import type { IConfig } from '../../types.js';
import { resetCardVerificationStatusCache } from '../../util/cardFallback.js';
/**
* Handing out a share asks for a verified phone or card. Its own env, with an
* SMS provider configured: the sibling share tests run without one, where the
* gate is inert by design.
*/
describe('share verification gate over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv({
prelude: { apiKey: 'test-key' },
} as IConfig);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = (path: string, token: string, body: unknown) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const get = (path: string, token: string) =>
fetch(new URL(path, env.apiOrigin), {
headers: { authorization: `Bearer ${token}` },
});
/** A fresh account, optionally with a verification artifact on its row. */
const makeUser = async (patch: Record<string, unknown> = {}) => {
const username = `svg${Math.random().toString(36).slice(2, 9)}`;
const creds = await createTestUser(env.server, {
username,
password: 'puter-test-user-password',
});
if (Object.keys(patch).length > 0) {
const row = await env.server.stores.user.getByUsername(username);
await env.server.stores.user.update(row!.id, patch);
}
return creds;
};
const makeFile = async (owner: { username: string }) => {
const uid = crypto.randomUUID();
const name = `share-gate-${uid.slice(0, 8)}.txt`;
const path = `/${owner.username}/${name}`;
const user = await env.server.stores.user.getByUsername(owner.username);
await env.server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) VALUES (?, ?, ?, ?, 0, ?)',
[uid, name, path, user!.id, Math.floor(Date.now() / 1000)],
);
return { uid, path, name };
};
const share = (owner: TestUserCredentials, uid: string) =>
post('/share', owner.token, {
recipients: [env.users.other.username],
items: [{ uid }],
mode: 'read',
});
it('turns an unverified owner away, leading with the phone flow', async () => {
const owner = await makeUser();
const file = await makeFile(owner);
const res = await share(owner, file.uid);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({
code: 'phone_verification_required',
factors: ['phone'],
});
});
it('lets an owner with a verified number share', async () => {
const owner = await makeUser({ phone: '+14155550123' });
const file = await makeFile(owner);
const res = await share(owner, file.uid);
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ status: 'success' });
});
it('lets a paying owner share without either factor', async () => {
const owner = await makeUser();
const row = await env.server.stores.user.getByUsername(owner.username);
const metering = env.server.services.metering;
const real = metering.getActorSubscription.bind(metering);
const spy = vi
.spyOn(metering, 'getActorSubscription')
.mockImplementation(async (actor) =>
actor.user?.uuid === row!.uuid
? ({ id: 'business' } as never)
: real(actor),
);
try {
const file = await makeFile(owner);
expect((await share(owner, file.uid)).status).toBe(200);
} finally {
spy.mockRestore();
metering.invalidateActorSubscription(row!.uuid);
}
});
it('accepts a verified card once an extension reports the card gate on', async () => {
const cardGateOn = (
_key: string,
data: { enabled: boolean | null },
) => {
data.enabled = true;
};
resetCardVerificationStatusCache();
env.server.clients.event.on(
'puter.card-verification.status',
cardGateOn,
);
try {
const carded = await makeUser({ card_fingerprint: 'fp_test' });
const file = await makeFile(carded);
expect((await share(carded, file.uid)).status).toBe(200);
// ...and an owner with neither is now offered both.
const bare = await makeUser();
const bareFile = await makeFile(bare);
const res = await share(bare, bareFile.uid);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({
code: 'phone_verification_required',
factors: ['phone', 'card'],
});
} finally {
env.server.clients.event.off(
'puter.card-verification.status',
cardGateOn,
);
resetCardVerificationStatusCache();
}
});
it('never gates withdrawing or listing shares', async () => {
const owner = await makeUser();
const file = await makeFile(owner);
const revoke = await post('/share/revoke', owner.token, {
recipients: [env.users.other.username],
items: [{ uid: file.uid }],
});
expect(revoke.status).toBe(200);
const list = await get('/share/shared-by-me', owner.token);
expect(list.status).toBe(200);
});
});
+9 -1
View File
@@ -54,9 +54,17 @@ export async function toClientShare(
...(share.type === undefined ? {} : { type: share.type }),
...(thumbnail === undefined ? {} : { thumbnail }),
...(share.owner === undefined ? {} : { owner: share.owner.username }),
// Withheld from a listing whose caller it isn't for, hence the omission.
...(share.pending
? { pending: true, recipient_email: share.recipientEmail }
? {
pending: true,
...(share.recipientEmail === undefined
? {}
: { recipient_email: share.recipientEmail }),
}
: {}),
// A link share: no holder of any kind, this is what says so.
...(share.anyone ? { anyone: true } : {}),
// Set on a share call only, so a listing stays silent about it.
...(share.isNew === undefined ? {} : { is_new: share.isNew }),
uid_entry: share.entryUid,
+51
View File
@@ -0,0 +1,51 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { RouteRateLimit } from '../../core/http/types';
// Shared like `fs/limits.ts` is shared: the scope only ties counters
// together if every caller passes the same spec.
/**
* Two windows: a burst ceiling, and a daily one so a slow drip can't add up to
* a mail-merge. Neither bounds _shares_ — one request carries many — which is
* what `ShareService`'s per-day quota is for.
*/
export const SHARE_LIMIT: RouteRateLimit[] = [
{ scope: 'share:mutate', limit: 60, window: 60_000, key: 'user' },
{
scope: 'share:mutate-daily',
limit: 500,
window: 24 * 60 * 60_000,
key: 'user',
},
];
/**
* One bucket for every share-listing read. The `/share` routes gate on it, and
* `stat` charges it imperatively when `return_shares` asks for the same listing
* work — otherwise the flag runs share listings under `fs:stat`'s far more
* generous budget.
*/
export const SHARE_LIST_LIMIT: RouteRateLimit = {
scope: 'share:list',
limit: 600,
window: 60_000,
key: 'user',
};
@@ -18,6 +18,7 @@
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { makeActor } from '../../core/actor.js';
import type { IConfig } from '../../types';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
@@ -53,6 +54,47 @@ describe('team endpoints over HTTP', () => {
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
/** An app token acting for `owner`, minted the way the dashboard does. */
const makeAppToken = async (owner: { username: string }) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const actor = makeActor({ user: user! });
const app = await env.server.stores.app.create(
{
name: `team-http-app-${crypto.randomUUID()}`,
title: 'Team app',
index_url: `https://team-${crypto.randomUUID()}.test/`,
},
{ ownerUserId: actor.user.id! },
);
return env.server.services.auth.getUserAppToken(actor, app.uid);
};
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
const makeScopedToken = async (owner: {
username: string;
token: string;
}) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const anchor = `/${owner.username}/${crypto.randomUUID()}`;
await env.server.services.fs.mkdir(user!.id, {
path: anchor,
createMissingParents: true,
});
const entry = await env.server.stores.fsEntry.getEntryByPath(anchor);
const { actor } = await env.server.services.auth.authenticate(
owner.token,
);
return env.server.services.auth.createAccessToken(
actor!,
[[`fs:${entry!.uid}:list`]],
{ label: 'teams-scope' },
);
};
/** A team owned by `user`, with one provisioned member. */
const makeTeam = async () => {
const res = await call('POST', '/teams', env.users.user.token, {
@@ -199,6 +241,15 @@ describe('team endpoints over HTTP', () => {
expect(after.status).toBe(200);
const body = (await after.json()) as { items: { username: string }[] };
expect(body.items.some((m) => m.username === env.users.user.username)).toBe(true);
// A scoped token was minted for one thing; a roster is not it.
const scoped = await makeScopedToken(env.users.user);
const narrow = await call(
'GET',
`/teams/${team.uid}/directory`,
typeof scoped === 'string' ? scoped : scoped.token,
);
expect(narrow.status).toBe(403);
});
it('refuses the directory to someone outside the team', async () => {
@@ -215,6 +266,188 @@ describe('team endpoints over HTTP', () => {
expect(res.status).toBe(404);
});
// -- app actors, gated by the same opt-in as the directory --------
//
// Built through the service, not the wire: these routes share a
// rate-limit budget with the mutation tests above, and every HTTP
// create/update here would eat into that same window.
/** A team owned by `user`, created and toggled through the service. */
const makeServiceTeam = async () => {
const owner = (await env.server.stores.user.getByUsername(
env.users.user.username,
))!;
const team = await env.server.services.team.createTeam(owner.id, {
name: 'Acme',
handle: randomHandle(),
});
return { team, ownerId: owner.id };
};
it('omits a team from an app token until the directory opts in', async () => {
const { team, ownerId } = await makeServiceTeam();
const appToken = await makeAppToken(env.users.user);
const before = await call('GET', '/teams', appToken);
expect(before.status).toBe(200);
const beforeBody = (await before.json()) as { items: { uid: string }[] };
expect(beforeBody.items.some((t) => t.uid === team.uid)).toBe(false);
await env.server.services.team.updateTeam(team.uid, ownerId, {
directoryEnabled: true,
});
const after = await call('GET', '/teams', appToken);
expect(after.status).toBe(200);
const afterBody = (await after.json()) as { items: { uid: string }[] };
expect(afterBody.items.some((t) => t.uid === team.uid)).toBe(true);
});
it('still lists a team to its owner session with the directory off', async () => {
const { team } = await makeServiceTeam();
const res = await call('GET', '/teams', env.users.user.token);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: { uid: string }[] };
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
});
it('lists a team to a full-access API token with the directory off', async () => {
const { team } = await makeServiceTeam();
const res = await call('GET', '/teams', env.users.user.apiToken);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: { uid: string }[] };
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
});
it('404s /members to an app token until the directory opts in, then gives usernames only', async () => {
const { team, ownerId } = await makeServiceTeam();
const username = `svc_${Math.random().toString(36).slice(2, 9)}`;
await env.server.services.team.provisionAccount(team.uid, ownerId, {
username,
});
const appToken = await makeAppToken(env.users.user);
const before = await call(
'GET',
`/teams/${team.uid}/members`,
appToken,
);
expect(before.status).toBe(404);
expect(await before.json()).toMatchObject({ code: 'team_not_found' });
await env.server.services.team.updateTeam(team.uid, ownerId, {
directoryEnabled: true,
});
const after = await call(
'GET',
`/teams/${team.uid}/members`,
appToken,
);
expect(after.status).toBe(200);
const body = (await after.json()) as {
items: Record<string, unknown>[];
};
expect(body.items.length).toBeGreaterThan(0);
// An app gets username only — no org_owned, created_at, or uuid.
for (const m of body.items) expect(Object.keys(m)).toEqual(['username']);
});
it('still refuses an app token on GET /teams/:uid', async () => {
const { team } = await makeServiceTeam();
const appToken = await makeAppToken(env.users.user);
const res = await call('GET', `/teams/${team.uid}`, appToken);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'forbidden' });
});
it('excludes a never-activated or suspended seat from an app`s view of /members', async () => {
const { team, ownerId } = await makeServiceTeam();
const neverActivated = `svc_na_${Math.random().toString(36).slice(2, 9)}`;
await env.server.services.team.provisionAccount(team.uid, ownerId, {
username: neverActivated,
});
const suspendedUsername = `svc_su_${Math.random().toString(36).slice(2, 9)}`;
const suspended = await env.server.services.team.provisionAccount(
team.uid,
ownerId,
{ username: suspendedUsername },
);
await env.server.services.team.disableMember(
team.uid,
ownerId,
suspended.userId,
);
await env.server.services.team.updateTeam(team.uid, ownerId, {
directoryEnabled: true,
});
const appToken = await makeAppToken(env.users.user);
const appView = await call(
'GET',
`/teams/${team.uid}/members`,
appToken,
);
expect(appView.status).toBe(200);
const appItems = (
(await appView.json()) as { items: Record<string, unknown>[] }
).items;
for (const m of appItems) expect(Object.keys(m)).toEqual(['username']);
const appUsernames = appItems.map((m) => m.username);
expect(appUsernames).not.toContain(neverActivated);
expect(appUsernames).not.toContain(suspendedUsername);
// The owner's own seat is active, so it still shows.
expect(appUsernames).toContain(env.users.user.username);
// The owner's own session sees every seat regardless of status.
const ownerView = await call(
'GET',
`/teams/${team.uid}/members`,
env.users.user.token,
);
expect(ownerView.status).toBe(200);
const ownerUsernames = (
(await ownerView.json()) as { items: { username: string }[] }
).items.map((m) => m.username);
expect(ownerUsernames).toContain(neverActivated);
expect(ownerUsernames).toContain(suspendedUsername);
});
it('gives a full-access API token the owner`s uuid on /members', async () => {
const { team } = await makeServiceTeam();
const res = await call(
'GET',
`/teams/${team.uid}/members`,
env.users.user.apiToken,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: { username: string; uuid?: string }[];
};
const owner = body.items.find(
(m) => m.username === env.users.user.username,
);
expect(owner?.uuid).toEqual(expect.any(String));
});
it('refuses a scoped access token on GET /teams and /members', async () => {
const { team } = await makeServiceTeam();
const scoped = await makeScopedToken(env.users.user);
const teamsRes = await call('GET', '/teams', scoped);
expect(teamsRes.status).toBe(403);
expect(await teamsRes.json()).toMatchObject({ code: 'forbidden' });
const membersRes = await call(
'GET',
`/teams/${team.uid}/members`,
scoped,
);
expect(membersRes.status).toBe(403);
expect(await membersRes.json()).toMatchObject({ code: 'forbidden' });
});
it('lists members with org_owned distinguishing the owner', async () => {
const { team, memberUsername } = await makeTeam();
@@ -235,6 +468,52 @@ describe('team endpoints over HTTP', () => {
expect(member?.org_owned).toBe(true);
});
it('gives seat uuids to the owner and to nobody else', async () => {
// Built through the service, not the wire: provisioning shares a
// rate-limit budget with the seat tests below, and one more HTTP
// provision here 429s them.
const owner = (await env.server.stores.user.getByUsername(
env.users.user.username,
))!;
const slug0 = Math.random().toString(36).slice(2, 9);
const team = await env.server.services.team.createTeam(owner.id, {
name: 'Acme',
handle: `uuidt-${slug0}`,
});
const memberUsername = `seat_${slug0}`;
await env.server.services.team.provisionAccount(team.uid, owner.id, {
username: memberUsername,
});
const owned = (await (
await call('GET', `/teams/${team.uid}/members`, env.users.user.token)
).json()) as { items: { username: string; uuid?: string }[] };
const seat = owned.items.find((m) => m.username === memberUsername);
// Billing keys a seat's plan on this, so the owner cannot act without it.
expect(seat?.uuid).toEqual(expect.any(String));
// A throwaway member, not the shared fixture: joining a team is
// permanent and would follow `other` into every later test.
const slug = Math.random().toString(36).slice(2, 9);
const joiner = await env.server.stores.user.create({
username: `joiner_${slug}`,
uuid: crypto.randomUUID(),
password: 'hashed',
email: `joiner_${slug}@test.local`,
});
await env.server.stores.team.addMember(team.uid, joiner.id, {
orgOwned: false,
});
const { token } =
await env.server.services.auth.createSessionToken(joiner);
const seen = (await (
await call('GET', `/teams/${team.uid}/members`, token)
).json()) as { items: { uuid?: string }[] };
expect(seen.items.length).toBeGreaterThan(0);
for (const m of seen.items) expect(m.uuid).toBeUndefined();
});
// -- provisioning over the wire -----------------------------------
it('never returns the activation link to the administrator', async () => {
+73 -16
View File
@@ -25,6 +25,7 @@ import {
Post,
Put,
} from '../../core/http/decorators.js';
import { isAccountContext } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { TeamRow } from '../../stores/team/TeamStore.js';
import { PuterController } from '../types.js';
@@ -70,7 +71,9 @@ const toClientTeam = (team: TeamRow, isOwner: boolean) => ({
@Controller('/teams')
export class TeamController extends PuterController {
// `requireUserActor` installs the auth gates; reads need it too.
// Most routes gate with `requireUserActor`; `listTeams` and `listMembers`
// use `requireAuth` so an app can read a team whose owner opted into the
// directory (see `directory_enabled`).
/** Off means `/teams` 404s and no team route is registered at all. */
isEnabled(): boolean {
@@ -85,6 +88,7 @@ export class TeamController extends PuterController {
})
async createTeam(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
await this.#requireTeamsAvailable(req, userId);
const body = this.#body(req);
const team = await this.services.team.createTeam(userId, {
@@ -99,15 +103,21 @@ export class TeamController extends PuterController {
@Get('', {
subdomain: 'api',
requireUserActor: true,
requireAuth: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listTeams(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
await this.#requireTeamsAvailable(req, userId);
const teams = await this.stores.team.listTeamsForUser(userId);
// An app only sees a team whose owner opened the directory to apps;
// a team that hasn't is indistinguishable from no team at all.
const visible = isAccountContext(req.actor)
? teams
: teams.filter((t) => this.services.team.isDirectoryOpen(t));
res.json({
items: teams.map((t) =>
items: visible.map((t) =>
toClientTeam(t, t.owner_user_id === userId),
),
});
@@ -176,16 +186,23 @@ export class TeamController extends PuterController {
@Get('/:uid/members', {
subdomain: 'api',
requireUserActor: true,
requireAuth: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listMembers(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
await this.services.team.requireMembership(
const team = await this.services.team.requireMembership(
this.#param(req, 'uid'),
userId,
);
const accountContext = isAccountContext(req.actor);
// Same consent as the directory: an app only reads a team that opted in.
if (!accountContext) this.services.team.assertDirectoryOpen(team);
// uuid only when the team owner calls with their own session or API
// token; an app needing a stable id uses `listDirectory()` instead.
const showUuid = accountContext && team.owner_user_id === userId;
const page = await this.stores.team.listMembers(
this.#param(req, 'uid'),
@@ -195,22 +212,30 @@ export class TeamController extends PuterController {
typeof req.query.cursor === 'string'
? req.query.cursor
: undefined,
// An app's view matches the directory's: active seats only.
activeOnly: !accountContext,
},
);
// An app only ever gets a username; org_owned/created_at are account-only.
res.json({
items: page.items.map((m) => ({
username: m.username,
org_owned: Number(m.org_owned) === 1,
created_at: m.created_at,
})),
items: page.items.map((m) =>
accountContext
? {
username: m.username,
org_owned: Number(m.org_owned) === 1,
created_at: m.created_at,
...(showUuid ? { uuid: m.uuid } : {}),
}
: { username: m.username },
),
...(page.cursor ? { cursor: page.cursor } : {}),
});
}
/**
* The only team route that admits an app actor. It discloses nothing a
* colleague cannot already read through `/members`, and the team has to
* have opted in, so an app cannot enumerate a team by default.
* Admits an app actor, like `listTeams` and `listMembers`, once the team
* has opted in. Discloses nothing a colleague cannot already read through
* `/members`.
*/
@Get('/:uid/directory', {
subdomain: 'api',
@@ -220,6 +245,15 @@ export class TeamController extends PuterController {
async listDirectory(req: Request, res: Response): Promise<void> {
// The membership is the person's, never the app's.
const userId = this.#requireUserId(req);
// A scoped token holds only what it was minted for; a roster is not that.
const token = req.actor?.accessToken;
if (token && token.fullAccess !== true) {
throw new HttpError(
403,
'This endpoint is not available to scoped access tokens',
{ legacyCode: 'forbidden' },
);
}
const page = await this.services.team.listDirectory(
this.#param(req, 'uid'),
userId,
@@ -249,7 +283,7 @@ export class TeamController extends PuterController {
const body = this.#body(req);
const result = await this.services.team.provisionAccount(uid, userId, {
username: this.#requireString(body.username, 'username'),
email: this.#requireString(body.email, 'email'),
email: this.#optionalString(body.email, 'email'),
});
// Shown once; the admin delivers it out of band.
res.json({
@@ -349,8 +383,8 @@ export class TeamController extends PuterController {
async deleteMember(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
// Authority first (anti-oracle); deleted team included, see the service.
await this.services.team.requireOwnedTeam(uid, userId);
const target = await this.#requireTargetUserId(req);
await this.services.team.deleteMember(uid, userId, target);
@@ -406,6 +440,18 @@ export class TeamController extends PuterController {
};
}
/**
* The domain-allowlist gate, on the two routes that enter the feature. Same
* 404 as a teams-off deployment; other routes bound by membership.
*/
async #requireTeamsAvailable(req: Request, userId: number): Promise<void> {
const email = (
req.actor as { user?: { email?: string | null } } | undefined
)?.user?.email;
if (await this.services.team.teamsAvailableTo(userId, email)) return;
throw new HttpError(404, 'Not found', { legacyCode: 'not_found' });
}
#requireUserId(req: Request): number {
const id = (req.actor as { user?: { id?: number } } | undefined)?.user
?.id;
@@ -426,6 +472,17 @@ export class TeamController extends PuterController {
return (req.body ?? {}) as Record<string, unknown>;
}
/** Absent or empty means "not given"; a wrong type is still a 400. */
#optionalString(value: unknown, field: string): string | null {
if (value === undefined || value === null) return null;
if (typeof value !== 'string') {
throw new HttpError(400, `${field} must be a string`, {
legacyCode: 'bad_request',
});
}
return value.trim() === '' ? null : value;
}
#requireString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new HttpError(400, `${field} is required`, {
+5 -23
View File
@@ -32,24 +32,8 @@ import type {
WithControllerRegistration,
} from '../types';
/**
* Extension-augmentable controller registry. Extensions add their own
* controller instance types via TypeScript declaration merging:
*
* declare module '@heyputer/backend/controllers/types' {
* interface IExtensionControllerInstances {
* myController: MyController;
* }
* }
*
* Augmentations flow into the `extension.import('controller')` proxy.
*/
/** Extension-augmentable controller registry; see `IExtensionClientInstances`. */
export interface IExtensionControllerInstances {
/**
* Open index signature so reads of extension-only controller keys return
* `unknown` instead of a type error. Concrete declaration-merged keys
* override this for that name.
*/
[key: string]: unknown;
}
@@ -64,11 +48,8 @@ export type IPuterController<
) => T;
/**
* Base class for v2 controllers. `registerRoutes(router)` receives a
* `PuterRouter` (not an express app) — see `core/http/PuterRouter.ts`.
* Controllers either override `registerRoutes` imperatively or lean on the
* `@Controller` / `@Post` / etc. decorators, which install a default
* `registerRoutes` walker on the prototype.
* Base class for controllers. Override `registerRoutes(router)` or use the
* `@Controller` / `@Post` decorators, which install a default one.
*/
export const PuterController =
class PuterController implements WithControllerRegistration {
@@ -92,7 +73,8 @@ export const PuterController =
public onServerShutdown() {
return;
}
public getReportedCosts(): // eslint-disable-next-line @typescript-eslint/no-explicit-any
public getReportedCosts():
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Promise<Record<string, any>[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>[] {
+41 -44
View File
@@ -25,58 +25,43 @@ export interface ActorApp {
}
/**
* Access-token wrapper. When set, this actor is acting _through_ an access
* token issued by `issuer`. The token's row in `access_token_permissions` gates
* which permissions of the issuer it can exercise.
* Set when the actor acts through an access token issued by `issuer`; the
* token's `access_token_permissions` rows gate which of the issuer's
* permissions it may exercise.
*/
export interface ActorAccessToken {
uid: string;
issuer: Actor;
authorized?: Actor | null;
/**
* Full-API-access ("personal access token") flag, set from the signed
* `full_access` JWT claim. Such a token may exercise everything its issuing
* user can do via the API and is admitted past `requireNonAccessTokenGate`
* — but is still rejected by `requireUserActor` / web-session gates, so it
* can never manage the account. Normal (scoped) access tokens leave this
* false and remain blocked from non-`allowAccessToken` routes.
* Personal access token (signed `full_access` claim): full API reach, but
* still rejected by `requireUserActor`, so never account management.
*/
fullAccess?: boolean;
}
export interface Actor {
user: Partial<UserRow>;
/**
* The app this actor carries _directly_, empty on an access token an app
* issued. Answers "is this an app acting as itself" — `isAppActor` is
* usually the clearer way to ask — and never "which app is acting", which
* reads as "no app" for a token and falls open.
*/
app?: ActorApp | null;
/**
* The app this actor ultimately acts as: its own `app`, or failing that the
* app of whoever issued its access token.
*
* Read this — not `app` — in any gate asking "which app is doing this?". An
* access-token actor carries no `app` of its own, so `app` alone reads as
* "no app" even for a token an app minted, and a gate keyed off it fails
* open exactly where it must not.
*
* `null` and `undefined` are not the same thing here:
*
* - `null` — resolved, and this actor is not acting as any app.
* - Absent — never resolved, because the actor skipped `makeActor`.
*
* A gate must not read the second as the first: that is the fail-open this
* field exists to prevent. Optional only so an actor literal that predates
* the field still compiles; `assertResolvedActor` is what keeps the request
* path honest, and `makeActor` is the one place the derivation lives.
* The app this actor ultimately acts as: its own `app`, else the app of its
* access token's issuer. Gates asking "which app" must read this, not
* `app`. `null` means resolved to no app; `undefined` means the actor
* skipped `makeActor` and must not be read as "no app".
*/
effectiveApp?: ActorApp | null;
/** True for the system actor; skips metering / quota tracking. */
system?: boolean;
accessToken?: ActorAccessToken | null;
/**
* Session reference when authenticated via a session token (user actors) or
* an app-under-user token that carries a session. Absent for system,
* raw-app, and pure access-token actors. Used for session introspection and
* targeted logout. `kind` mirrors the session row's kind (e.g. 'web',
* 'app', 'worker') so callers can gate on how the credential was minted
* without an extra session lookup.
* Set when authenticated by a session token or an app-under-user token
* carrying one. `kind` mirrors the session row (`web`, `app`, `worker`).
*/
session?: { uid: string; kind?: string | null } | null;
}
@@ -92,11 +77,8 @@ export const SYSTEM_ACTOR: Actor = {
};
/**
* Build an actor, deriving `effectiveApp` from its own app and, failing that,
* from the app of whoever issued its access token.
*
* The issuer was itself built here, so its chain is already collapsed — one hop
* is enough, and no caller has to walk anything.
* Build an actor with `effectiveApp` derived; the issuer is already collapsed,
* so one hop suffices.
*/
export const makeActor = (actor: Omit<Actor, 'effectiveApp'>): Actor => ({
...actor,
@@ -104,13 +86,8 @@ export const makeActor = (actor: Omit<Actor, 'effectiveApp'>): Actor => ({
});
/**
* Fail closed on an actor whose `effectiveApp` was never derived.
*
* Every actor on the request path is built by `AuthService` through
* `makeActor`, so this cannot fire in production — which is the point. It turns
* a future actor literal that skips the builder into a loud 500 at the edge
* rather than a silent bypass deep inside a gate that read `undefined` as "no
* app". Call it once, where the request actor is established.
* Fail closed on an actor that skipped `makeActor`, at the edge rather than
* inside a gate reading `undefined` as "no app".
*/
export const assertResolvedActor = (actor: Actor): Actor => {
if (actor.effectiveApp === undefined) {
@@ -130,12 +107,32 @@ export const isAppActor = (actor: Actor | undefined | null): boolean => {
return !!actor?.app && !isAccessTokenActor(actor);
};
/**
* The account acting as itself, through nothing: no app and no token of any
* kind. Narrower than `isAccountContext`, which also admits a full-access token
* — use this where the distinction is "a browser session" rather than "the
* account's own reach".
*/
export const isPlainUserActor = (
actor: Pick<Actor, 'app' | 'accessToken'> | undefined | null,
): boolean => !!actor && !actor.app && !actor.accessToken;
export const isAccessTokenActor = (
actor: Actor | undefined | null,
): boolean => {
return !!actor?.accessToken;
};
/**
* Whether the actor holds the account's own reach: a plain session or a
* full-access token. Use this, not `effectiveApp === null`, which would also
* admit scoped access tokens. Unresolved actors answer no.
*/
export const isAccountContext = (actor: Actor | undefined | null): boolean => {
if (!actor || actor.effectiveApp !== null) return false;
return !actor.accessToken || actor.accessToken.fullAccess === true;
};
/**
* Stable identifier for an actor. Used as a cache key (e.g., permission scan
* cache) and for cycle detection.
+6 -17
View File
@@ -59,23 +59,14 @@ const getOrInitRoutes = (proto: DecoratedPrototype): CollectedRoute[] => {
// -- @Controller -----------------------------------------------------
/**
* Class decorator.
*
* - Stores the controller's path `prefix` on the prototype so `PuterServer` can
* construct a correctly-prefixed `PuterRouter` for this controller.
* - Installs a default `registerRoutes(router)` on the prototype that walks
* routes collected by method decorators (if this class hasn't defined its own
* `registerRoutes`). This means a purely-decorated controller needs no body —
* the decorators do all the wiring.
*
* Controllers that define their own `registerRoutes` are untouched; they can
* still use `@Post` etc. and walk `prototype[ROUTES_METADATA_KEY]` manually if
* they want to combine the styles.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyCtor = new (...args: any[]) => any;
/**
* Class decorator. Stores `prefix` on the prototype for `PuterServer`, and
* installs a default `registerRoutes` that walks decorator-collected routes
* unless the class defines its own.
*/
export function Controller(prefix: string = '') {
return <T extends AnyCtor>(
value: T,
@@ -84,9 +75,7 @@ export function Controller(prefix: string = '') {
const proto = value.prototype as DecoratedPrototype;
proto[PREFIX_METADATA_KEY] = prefix;
// Only install the default walker if the class itself hasn't
// defined registerRoutes. We check *own* properties (not inherited)
// so a PuterController base-class default doesn't block us.
// Own property only, so a base-class default doesn't block the walker.
const hasOwnRegister = Object.prototype.hasOwnProperty.call(
proto,
'registerRoutes',
+2 -4
View File
@@ -61,10 +61,8 @@ declare global {
reason: '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.
* Short-lived JWT proving the bearer was `auth_id`; the GUI
* echoes it on the next login to rebind.
*/
reauth_token?: string;
};
@@ -20,6 +20,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { Actor } from '../../actor';
import { PERIOD_ESCAPE } from '../../../services/metering/consts.js';
import type { MeteringService } from '../../../services/metering/MeteringService.js';
import type { UsageByType } from '../../../services/metering/types';
import { setupPuterTestEnv, type PuterTestEnv } from '../../../testUtil.js';
@@ -31,12 +32,24 @@ import { setupPuterTestEnv, type PuterTestEnv } from '../../../testUtil.js';
*/
describe('egress metering over HTTP', () => {
let env: PuterTestEnv;
let originalCacheMs: number;
beforeAll(async () => {
env = await setupPuterTestEnv();
// A sharded month caches getActorCurrentMonthUsageDetails's detail
// read for USAGE_DETAIL_CACHE_MS — these tests read before and after
// one request a moment apart, well inside that window, so the cache
// would mask the very update they're checking for.
const ctor = env.server.services.metering
.constructor as typeof MeteringService;
originalCacheMs = ctor.USAGE_DETAIL_CACHE_MS;
ctor.USAGE_DETAIL_CACHE_MS = 0;
}, 120_000);
afterAll(async () => {
(
env.server.services.metering.constructor as typeof MeteringService
).USAGE_DETAIL_CACHE_MS = originalCacheMs;
await env?.shutdown();
});
+177 -13
View File
@@ -18,7 +18,8 @@
*/
import type { Request, Response } from 'express';
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import { resetCardVerificationStatusCache } from '../../../util/cardFallback';
import { makeActor, type Actor } from '../../actor';
import { HttpError, isHttpError } from '../HttpError';
import {
@@ -27,6 +28,8 @@ import {
allowedAppIdsGate,
assertNotUserSession,
noUserSessionGate,
requireAnyVerifiedGate,
type AnyVerifiedDeps,
requireAuthGate,
requireCardVerifiedGate,
requirePhoneVerifiedGate,
@@ -88,6 +91,23 @@ const runGate = (
return captured;
};
/** `runGate` for a gate that answers asynchronously. */
const runGateAsync = (
gate: (
req: Request,
res: Response,
next: (arg?: unknown) => void,
) => unknown,
req: Partial<Request>,
): Promise<NextArg> => {
if (req.actor) req = { ...req, actor: reviveActor(req.actor) };
return new Promise((resolve) =>
gate(req as Request, {} as Response, (arg?: unknown) =>
resolve(arg as NextArg),
),
);
};
const expectHttpError = (got: NextArg, status: number, legacyCode?: string) => {
expect(isHttpError(got)).toBe(true);
const err = got as HttpError;
@@ -815,11 +835,10 @@ describe('allowedAppIdsGate', () => {
expect(got).toBeUndefined();
});
it('passes any access token issued by an app, allowed or not', () => {
// The check reads `actor.app`, which an access-token actor never
// carries — its app lives on `effectiveApp`, one hop through the
// issuer. Both directions pass, which is why an appId-gated route
// cannot treat this gate as proof of app identity.
it('judges an access token by the app that issued it', () => {
// An access-token actor carries no app of its own — its app is one hop
// down the chain, through the issuer — so reading the direct `app`
// would let a token from any app through an appId-gated route.
const gate = allowedAppIdsGate(['app-allowed']);
const tokenIssuedBy = (appUid: string) => ({
actor: {
@@ -834,7 +853,9 @@ describe('allowedAppIdsGate', () => {
},
});
expect(runGate(gate, tokenIssuedBy('app-allowed'))).toBeUndefined();
expect(runGate(gate, tokenIssuedBy('app-other'))).toBeUndefined();
expect(runGate(gate, tokenIssuedBy('app-other'))).toBeInstanceOf(
HttpError,
);
});
it('passes when the actor.app.uid is in the allow-list', () => {
@@ -947,22 +968,31 @@ describe('requirePhoneVerifiedGate', () => {
});
describe('requireCardVerifiedGate', () => {
it('passes a user with a verified card on file', () => {
const got = runGate(requireCardVerifiedGate(), {
const plan = (paid: boolean) => ({ hasPaidPlan: async () => paid });
it('passes a user with a verified card on file', async () => {
const got = await runGateAsync(requireCardVerifiedGate(plan(false)), {
actor: { user: { uuid: 'u-1', card_fingerprint: 'fp_1' } },
});
expect(got).toBeUndefined();
});
it('rejects a user who never verified a card', () => {
const got = runGate(requireCardVerifiedGate(), {
it('passes a paying account as card-verified', async () => {
const got = await runGateAsync(requireCardVerifiedGate(plan(true)), {
actor: { user: { uuid: 'u-1' } },
});
expect(got).toBeUndefined();
});
it('rejects a user who never verified a card', async () => {
const got = await runGateAsync(requireCardVerifiedGate(plan(false)), {
actor: { user: { uuid: 'u-1' } },
});
expectHttpError(got, 403, 'card_verification_required');
});
it('rejects a user still carrying the card gate', () => {
const got = runGate(requireCardVerifiedGate(), {
it('rejects a user still carrying the card gate', async () => {
const got = await runGateAsync(requireCardVerifiedGate(plan(false)), {
actor: {
user: {
uuid: 'u-1',
@@ -974,3 +1004,137 @@ describe('requireCardVerifiedGate', () => {
expectHttpError(got, 403, 'card_verification_required');
});
});
// ── requireAnyVerifiedGate ──────────────────────────────────────────
describe('requireAnyVerifiedGate', () => {
// The card probe is memoized module-wide; every case starts cold.
beforeEach(() => resetCardVerificationStatusCache());
const deps = (
sms: boolean,
card: boolean | null,
paid = false,
): AnyVerifiedDeps => ({
smsConfigured: () => sms,
probeCardVerification: async () => card,
hasPaidPlan: async () => paid,
});
const both = ['phone', 'card'] as const;
it('is inert where neither factor can be verified', async () => {
const got = await runGateAsync(
requireAnyVerifiedGate(both, deps(false, null)),
{ actor: { user: { uuid: 'u-1' } } },
);
expect(got).toBeUndefined();
});
it('passes a verified number whatever the deployment can do today', async () => {
const got = await runGateAsync(
requireAnyVerifiedGate(both, deps(false, null)),
{ actor: { user: { uuid: 'u-1', phone: '+15550000000' } } },
);
expect(got).toBeUndefined();
});
it('passes a verified card', async () => {
const got = await runGateAsync(
requireAnyVerifiedGate(both, deps(true, true)),
{ actor: { user: { uuid: 'u-1', card_fingerprint: 'fp_1' } } },
);
expect(got).toBeUndefined();
});
it('takes a paid plan as the card, verified by other means', async () => {
const got = await runGateAsync(
requireAnyVerifiedGate(both, deps(true, true, true)),
{ actor: { user: { uuid: 'u-1' } } },
);
expect(got).toBeUndefined();
});
it('does not let a paid plan stand in for a phone', async () => {
const got = await runGateAsync(
requireAnyVerifiedGate(['phone'], deps(true, true, true)),
{ actor: { user: { uuid: 'u-1' } } },
);
expectHttpError(got, 403, 'phone_verification_required');
});
it('asks about the plan only once the row itself has not answered', async () => {
let asked = 0;
const counting: AnyVerifiedDeps = {
...deps(true, true),
hasPaidPlan: async () => {
asked++;
return true;
},
};
await runGateAsync(requireAnyVerifiedGate(both, counting), {
actor: { user: { uuid: 'u-1', phone: '+15550000000' } },
});
expect(asked).toBe(0);
await runGateAsync(requireAnyVerifiedGate(both, counting), {
actor: { user: { uuid: 'u-1' } },
});
expect(asked).toBe(1);
});
it('leads with the phone flow and names both factors when both are verifiable', async () => {
const got = await runGateAsync(
requireAnyVerifiedGate(both, deps(true, true)),
{ actor: { user: { uuid: 'u-1' } } },
);
expectHttpError(got, 403, 'phone_verification_required');
expect((got as HttpError).fields).toEqual({
factors: ['phone', 'card'],
});
});
it('asks only for the factors the deployment can verify', async () => {
const got = await runGateAsync(
requireAnyVerifiedGate(both, deps(false, true)),
{ actor: { user: { uuid: 'u-1' } } },
);
expectHttpError(got, 403, 'card_verification_required');
expect((got as HttpError).fields).toEqual({ factors: ['card'] });
});
it('takes the lead factor from the route order', async () => {
const got = await runGateAsync(
requireAnyVerifiedGate(['card', 'phone'], deps(true, true)),
{ actor: { user: { uuid: 'u-1' } } },
);
expectHttpError(got, 403, 'card_verification_required');
expect((got as HttpError).fields).toEqual({
factors: ['card', 'phone'],
});
});
it('does not count a factor still mid-verification', async () => {
const got = await runGateAsync(
requireAnyVerifiedGate(both, deps(true, null)),
{
actor: {
user: {
uuid: 'u-1',
phone: '+15550000000',
requires_phone_verification: true,
},
},
},
);
expectHttpError(got, 403, 'phone_verification_required');
expect((got as HttpError).fields).toEqual({ factors: ['phone'] });
});
it('rejects when there is no actor at all', async () => {
const got = await runGateAsync(
requireAnyVerifiedGate(both, deps(true, null)),
{},
);
expectHttpError(got, 403, 'phone_verification_required');
});
});
+157 -195
View File
@@ -18,8 +18,13 @@
*/
import type { Request, RequestHandler } from 'express';
import type { Actor } from '../../actor';
import {
isCardVerificationEnabled,
type CardFallbackDeps,
} from '../../../util/cardFallback';
import { isAppActor, isPlainUserActor, type Actor } from '../../actor';
import { HttpError } from '../HttpError';
import type { AccountGateUser, VerificationFactor } from '../types';
import { assertVerifiedEmail } from '../verifiedEmail';
// Make sure the `Express.Request.actor` augmentation is in scope.
@@ -70,14 +75,7 @@ export const subdomainGate = (allowed: string | string[]): RequestHandler => {
};
};
/**
* Reject anonymous requests with 401. Also reject authenticated-but-suspended
* users with 403 — `actor.user.suspended` is populated by `AuthService` from
* `UserStore`, so the gate doesn't need its own DB hit.
*
* Implied by `requireUserActor`, `adminOnly`, and `allowedAppIds`; the
* materializer ensures only one copy ends up in the chain.
*/
/** 401 for anonymous requests, 403 for suspended accounts. */
export const requireAuthGate = (): RequestHandler => {
return (req, _res, next) => {
if (req.appBlocked) {
@@ -105,17 +103,10 @@ export const requireAuthGate = (): RequestHandler => {
};
/**
* Reject app-under-user and access-token actors with 403. Use on endpoints that
* should only be exercised by a human session — settings changes, admin-style
* actions on the user's own account.
*
* `allowFullAccess` (set per-route via the `allowFullAccessToken` route option)
* relaxes ONLY the access-token half: a full-access ("personal access token")
* actor is admitted, because it represents the user's own full API reach.
* Third-party apps are ALWAYS rejected, and scoped access tokens are always
* rejected. This opt-in is for user-resource / inference endpoints (AI proxy,
* etc.) that use this gate purely to keep apps out — NEVER for account or
* security management, which must stay closed to every access token.
* Reject app-under-user and access-token actors with 403. `allowFullAccess`
* (route option `allowFullAccessToken`) admits full-access personal access
* tokens only; apps and scoped tokens are always rejected. Never set it on
* account or security management routes.
*/
export const requireUserActorGate = (
opts: { allowFullAccess?: boolean } = {},
@@ -127,10 +118,7 @@ export const requireUserActorGate = (
next(rejectAuth(req));
return;
}
// Third-party apps are never allowed through this gate.
const appBlocked = !!actor.app;
// Access tokens are blocked unless the route opted in AND this is a
// full-access PAT (the user's own credential). Scoped tokens: blocked.
const appBlocked = isAppActor(actor);
const tokenBlocked =
!!actor.accessToken &&
!(opts.allowFullAccess && actor.accessToken.fullAccess);
@@ -149,30 +137,18 @@ export const requireUserActorGate = (
};
/**
* Reject bare user-session actors — the "root" credential a browser session (or
* `/login`) holds, with no app and no access token in play. Use on API surfaces
* that must only be driven by a delegated credential: an app or worker token,
* or an API token minted from the dashboard. The point is that a
* leaked-or-copied session token (full account control) shouldn't double as an
* AI/API credential; users are pushed to mint a revocable token instead.
*
* This gate only rejects the bare-session shape. Which delegated credentials
* are acceptable is decided by the gates it composes with (`requireUserActor`
*
* - `allowFullAccessToken` to also keep apps out, `requireNonAccessTokenGate` for
* scoped tokens, etc.).
* Reject bare account-session actors (no app, no access token) so a session
* token never doubles as an API credential. Only rejects that shape; which
* delegated credentials are acceptable is decided by the gates it composes
* with.
*/
export const assertNotUserSession = (
actor: Pick<Actor, 'app' | 'accessToken' | 'session'> | null | undefined,
): void => {
if (!actor) return; // anonymous requests are the auth gate's problem
if (actor.app || actor.accessToken) return;
// User-scoped workers (deployed with no app binding) authenticate with
// a session-TYPE token whose session row is `kind='worker'` — a managed,
// revocable deployment credential, not a browser sign-in. Workers are
// never treated as root tokens: this gate is an annoyance for
// sign-up-and-scrape abuse, and someone who deploys a worker to reach
// an API has already left that path.
if (!isPlainUserActor(actor)) return;
// App-less workers hold a session-type token with `kind='worker'`: a
// revocable deployment credential, not a browser sign-in.
if (actor.session?.kind === 'worker') return;
throw new HttpError(
403,
@@ -208,12 +184,8 @@ export const requireNonAccessTokenGate = (): RequestHandler => {
next(rejectAuth(req));
return;
}
// Full-access ("personal access token") access tokens are admitted here:
// they carry the user's full API reach by design. They remain blocked
// from account management because those routes also use
// `requireUserActorGate`, which rejects ALL access tokens. Normal
// (scoped) access tokens stay blocked from non-`allowAccessToken`
// routes.
// Full-access tokens pass; `requireUserActorGate` still keeps them
// off account management.
if (actor.accessToken && !actor.accessToken.fullAccess) {
next(
new HttpError(
@@ -232,33 +204,17 @@ export const requireNonAccessTokenGate = (): RequestHandler => {
export const DEFAULT_ADMIN_USERNAMES = ['admin', 'system'] as const;
/**
* Reject unless `actor.user.username` matches `admin`, `system`, or one of the
* supplied extras. Extras are _additional_ allowed users on top of the built-in
* pair, not a replacement for it.
*
* Also requires a _root token_ — an actor with no app anywhere in its token
* chain (see `Actor.effectiveApp`) — so a third-party app an admin has
* authorized can't reach admin endpoints on the admin's behalf. The one
* exception is `appGated`: on a route that is also appId-gated
* (`allowedAppIds`), a direct app-under-user actor is deferred to
* `allowedAppIdsGate`, so the net effect there is "a root token OR a token
* scoped to an allowed app". Access tokens issued through an app are rejected
* even then — `allowedAppIdsGate` only sees top-level `actor.app` and would
* otherwise wave them through.
*
* Implies `requireAuth`. Does _not_ imply `requireUserActor` — a root token
* still includes an admin's full-access personal access token, not only browser
* sessions; combine with `requireUserActor` explicitly if a route must be
* restricted to browser sessions.
* Reject unless the username is `admin`, `system`, or one of `extras`, and the
* actor carries no app anywhere in its token chain (`effectiveApp`). With
* `appGated` a direct app-under-user actor is deferred to `allowedAppIdsGate`
* instead; app-issued access tokens are rejected even then, since that gate
* cannot see them. Does not imply `requireUserActor`.
*/
export const adminOnlyGate = (
extras: readonly string[] = [],
opts: { appGated?: boolean } = {},
): RequestHandler => {
// Match the case-insensitivity guarantee of the username column
// (MySQL: ascii_general_ci; SQLite: idx_user_username_nocase). Comparing
// raw-case here would let a stored `Admin` bypass the lowercase allowlist
// on any backend that lets case-collision rows exist.
// Match the username column's case-insensitive collation.
const allowList = new Set<string>(
[...DEFAULT_ADMIN_USERNAMES, ...extras].map((u) => u.toLowerCase()),
);
@@ -272,13 +228,8 @@ export const adminOnlyGate = (
);
return;
}
// Root-token requirement: reject actors carrying an app anywhere in
// their token chain — app-under-user, or an access token issued
// through an app. A direct app-under-user actor is deferred to
// `allowedAppIdsGate` when the route is appId-gated; chain-only apps
// are rejected even then, since that gate can't see them.
const chainApp = req.actor?.effectiveApp ?? null;
if (chainApp && !(opts.appGated && req.actor?.app?.uid)) {
if (chainApp && !(opts.appGated && isAppActor(req.actor))) {
next(
new HttpError(403, 'Only admins may request this resource', {
legacyCode: 'forbidden',
@@ -291,12 +242,9 @@ export const adminOnlyGate = (
};
/**
* Reject unless the authenticated user has a confirmed email. Gated behind
* `strict_email_verification_required` config so self-hosted deployments
* without email delivery don't brick their own filesystem routes.
*
* Reads `req.actor?.user?.email_confirmed`, which is present on both user-only
* and app-under-user actors, so it works for either shape.
* Reject unless the user's email is confirmed. Inert unless
* `strict_email_verification_required` is set, so deployments without email
* delivery are not locked out.
*/
export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => {
return (req, _res, next) => {
@@ -311,24 +259,9 @@ export const requireVerifiedGate = (strictFlag: boolean): RequestHandler => {
};
/**
* Reject authenticated users whose account is still pending any signup-time
* verification — email confirmation, SMS phone verification, or credit-card
* verification. The abuse harness sets the phone/card flags on low-reputation
* signups (in place of a hard block), and this gate is what actually keeps
* those accounts out of the product until the flag clears: the flags live on
* `req.actor.user`, so every authenticated route enforces them, not just the
* GUI modal.
*
* Runs on every authenticated route by default; routes that set
* `allowUnconfirmed: true` opt out (the verification endpoints themselves, plus
* essential flows like whoami / logout / save-account so a pending account can
* still reach the screens that clear the gate).
*
* Returns 403 with a per-gate legacy code (`email_confirmation_required` /
* `phone_verification_required` / `card_verification_required` /
* `password_change_required`) so clients can show the right prompt instead of a
* generic error. There is no state where a user should be allowed in with one
* verification pending, so any pending gate rejects.
* Reject accounts still pending a signup-time verification (email, phone, card,
* password change). Default-on for authenticated routes; the flows that clear
* these flags opt out with `allowUnconfirmed: true`.
*/
export const requireVerifiedAccount = (): RequestHandler => {
return (req, _res, next) => {
@@ -343,31 +276,12 @@ export const requireVerifiedAccount = (): RequestHandler => {
};
/**
* The pending-verification check, factored out of {@link requireVerifiedAccount}
* so auth paths that build their own actor outside the route-option machinery
* can enforce the exact same gate. The WebDAV controller is the motivating
* case: it dispatches every method off a single `router.use`, so
* `requireVerifiedAccount` is never wired into its chain — it has to call this
* directly. Keeping one implementation is the point: a verification gate added
* here is picked up by every caller, so the paths can't drift (which is how
* WebDAV came to bypass the phone/card gate to begin with).
*
* Throws 403 with a per-gate legacy code (`email_confirmation_required` /
* `phone_verification_required` / `card_verification_required` /
* `password_change_required`) so clients can show the right prompt instead of a
* generic error. There is no state where a user should be let in with any
* verification pending, so the first pending gate rejects.
* The check behind {@link requireVerifiedAccount}, for auth paths that build
* their own actor outside the route-option chain (e.g. WebDAV). Throws 403 with
* a per-gate legacy code so clients can show the right prompt.
*/
export const assertVerifiedAccount = (
user:
| {
requires_email_confirmation?: unknown;
email_confirmed?: unknown;
requires_phone_verification?: unknown;
requires_card_verification?: unknown;
requires_password_change?: unknown;
}
| undefined,
user: AccountGateUser | undefined,
): void => {
if (user?.requires_email_confirmation && !user?.email_confirmed) {
throw new HttpError(403, 'Please confirm your email to continue', {
@@ -403,32 +317,27 @@ export const assertVerifiedAccount = (
// -- Per-verification gates ------------------------------------------
//
// `requireVerifiedAccount` above is the default-on gate: it turns away an
// account that is *pending* a verification the abuse harness asked of it, and
// says nothing about accounts that were never asked. The two gates below are
// the opt-in counterparts of `requireVerified` (email) for the other two
// factors: they require the verification to have actually happened, whether or
// not this account was ever flagged for it.
//
// Neither factor has a boolean "verified" column — the pending flag is cleared
// on success and the proof is the artifact left behind. `phone` is written only
// once a code has been confirmed, and `card_fingerprint` only once a card has
// been checked (both are also what `UserAccountService` reads as the
// phone-verified / card-verified signals). Pending flag clear AND artifact
// present is therefore the honest reading of "verified", and an account still
// mid-flow fails on the flag rather than slipping through on a stale artifact.
// Opt-in gates requiring a factor to have actually been verified, unlike
// `requireVerifiedAccount` which only rejects accounts still pending one.
// There is no "verified" column: the proof is the artifact (`phone`,
// `card_fingerprint`) plus a cleared pending flag, so an account mid-flow
// fails on the flag rather than passing on a stale artifact.
/**
* Reject unless the user has a phone number verified on file. 403
* `phone_verification_required`, the same code the pending-verification gate
* uses, so a client that already prompts for the SMS flow prompts here too.
*/
export const hasVerifiedPhone = (user: AccountGateUser | undefined): boolean =>
Boolean(user?.phone) && !user?.requires_phone_verification;
export const hasVerifiedCard = (user: AccountGateUser | undefined): boolean =>
Boolean(user?.card_fingerprint) && !user?.requires_card_verification;
const PHONE_REQUIRED_MESSAGE = 'Please verify your phone number to continue';
const CARD_REQUIRED_MESSAGE = 'Please verify your card to continue';
/** 403 `phone_verification_required` unless a verified phone is on file. */
export const assertPhoneVerified = (
user:
{ phone?: unknown; requires_phone_verification?: unknown } | undefined,
user: AccountGateUser | undefined,
): void => {
if (user?.phone && !user?.requires_phone_verification) return;
throw new HttpError(403, 'Please verify your phone number to continue', {
if (hasVerifiedPhone(user)) return;
throw new HttpError(403, PHONE_REQUIRED_MESSAGE, {
legacyCode: 'phone_verification_required',
});
};
@@ -446,38 +355,107 @@ export const requirePhoneVerifiedGate = (): RequestHandler => {
};
};
/**
* Reject unless the user has a card verified on file. 403
* `card_verification_required`, matching the pending-verification gate so the
* client shows the card flow it already has.
*/
export const assertCardVerified = (
user:
| { card_fingerprint?: unknown; requires_card_verification?: unknown }
| undefined,
): void => {
if (user?.card_fingerprint && !user?.requires_card_verification) return;
throw new HttpError(403, 'Please verify your card to continue', {
legacyCode: 'card_verification_required',
});
};
export interface AnyVerifiedDeps extends CardFallbackDeps {
/** A paid plan implies a card on file, so it counts as card-verified. */
hasPaidPlan: (actor: Actor) => Promise<boolean>;
}
/** Route-option form of {@link assertCardVerified} (`requireCardVerified`). */
export const requireCardVerifiedGate = (): RequestHandler => {
const hasCardEvidence = async (
actor: Actor | undefined,
deps: Pick<AnyVerifiedDeps, 'hasPaidPlan'>,
): Promise<boolean> =>
hasVerifiedCard(actor?.user) ||
(actor !== undefined && (await deps.hasPaidPlan(actor)));
/**
* 403 `card_verification_required` unless a card is on file or the plan is
* paid.
*/
export const requireCardVerifiedGate = (
deps: Pick<AnyVerifiedDeps, 'hasPaidPlan'>,
): RequestHandler => {
return (req, _res, next) => {
try {
assertCardVerified(req.actor?.user);
} catch (err) {
next(err);
return;
}
next();
hasCardEvidence(req.actor, deps).then((verified) => {
if (verified) {
next();
return;
}
next(
new HttpError(403, CARD_REQUIRED_MESSAGE, {
legacyCode: 'card_verification_required',
}),
);
}, next);
};
};
export const assertNotSuspended = (
user: { suspended?: unknown } | undefined,
): void => {
const isFactorVerified = (
factor: VerificationFactor,
actor: Actor | undefined,
deps: Pick<AnyVerifiedDeps, 'hasPaidPlan'>,
): Promise<boolean> =>
factor === 'phone'
? Promise.resolve(hasVerifiedPhone(actor?.user))
: hasCardEvidence(actor, deps);
/** Whether this deployment can run the factor's verification flow at all. */
const isFactorVerifiable = async (
factor: VerificationFactor,
deps: CardFallbackDeps,
): Promise<boolean> =>
factor === 'phone'
? deps.smsConfigured()
: (await isCardVerificationEnabled(deps)) === true;
/**
* Reject unless at least one of `factors` is verified. Only factors this
* deployment can verify are asked for, so with none verifiable the gate is
* inert. The 403 carries the first verifiable factor's code and lists every
* verifiable one in `factors`.
*/
export const assertAnyVerified = async (
actor: Actor | undefined,
factors: readonly VerificationFactor[],
deps: AnyVerifiedDeps,
): Promise<void> => {
// In the route's order, so a verified phone never costs the plan lookup.
for (const factor of factors) {
if (await isFactorVerified(factor, actor, deps)) return;
}
const verifiable: VerificationFactor[] = [];
for (const factor of factors) {
if (verifiable.includes(factor)) continue;
if (await isFactorVerifiable(factor, deps)) verifiable.push(factor);
}
if (verifiable.length === 0) return;
const [lead] = verifiable;
throw new HttpError(
403,
lead === 'phone' ? PHONE_REQUIRED_MESSAGE : CARD_REQUIRED_MESSAGE,
{
legacyCode:
lead === 'phone'
? 'phone_verification_required'
: 'card_verification_required',
fields: { factors: verifiable },
},
);
};
/** Route-option form of {@link assertAnyVerified} (`requireAnyVerified`). */
export const requireAnyVerifiedGate = (
factors: readonly VerificationFactor[],
deps: AnyVerifiedDeps,
): RequestHandler => {
return (req, _res, next) => {
assertAnyVerified(req.actor, factors, deps).then(
() => next(),
(err) => next(err),
);
};
};
export const assertNotSuspended = (user: AccountGateUser | undefined): void => {
if (user?.suspended) {
throw new HttpError(403, 'Account suspended', {
legacyCode: 'forbidden',
@@ -486,33 +464,17 @@ export const assertNotSuspended = (
};
/**
* Narrow WHICH apps may reach a route: an app-under-user actor whose
* `actor.app.uid` is missing from the allowList is rejected with 403.
*
* It is NOT an app gate, despite the name. Every actor with no `app` of its own
* passes through — browser sessions, full-access personal access tokens, worker
* sessions, and access tokens issued BY an app, since the check reads
* `actor.app` and never `actor.effectiveApp`, leaving an app in the token chain
* invisible here. Routes depend on that pass-through: `adminOnly` +
* `allowedAppIds` means "a root token OR a token scoped to an allowed app", and
* the admin and dev-account surfaces are called both ways.
*
* So use it to keep other apps out, never as proof that an app is present. The
* default-on `requireNonAccessTokenGate` is what keeps app-issued tokens off
* these routes; a route setting `allowAccessToken` loses that cover and has to
* check `effectiveApp` itself.
*
* Implies `requireAuth`. Pairing it with `requireUserActor` leaves the
* allowList dead — that gate rejects every app, and everything it admits passes
* here.
* Reject actors acting as an app that is not allow-listed — the app they carry
* directly, or the one that issued their access token. Actors with no app
* anywhere in the chain pass. Use it to keep other apps out, never as proof an
* app is present.
*/
export const allowedAppIdsGate = (
allowedAppUids: readonly string[],
): RequestHandler => {
const allowList = new Set(allowedAppUids);
return (req, _res, next) => {
// Deliberately `app`, not `effectiveApp`: see the note above.
const appUid = req.actor?.app?.uid;
const appUid = req.actor?.effectiveApp?.uid;
if (appUid && !allowList.has(appUid)) {
next(
new HttpError(403, 'This app may not request this resource', {
@@ -20,6 +20,7 @@
import type { Request } from 'express';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { makeActor } from '../../actor';
import type { AuthService } from '../../../services/auth/AuthService';
import { PuterServer } from '../../../server';
import { setupTestServer } from '../../../testUtil';
@@ -678,6 +679,27 @@ describe('resolveOwnedAppForHostedSite', () => {
expect(out).toBeNull();
});
it('prefers the private app over an older public bootstrap stub on an alternate host', async () => {
const owner = await makeUser();
await server.stores.app.createFromOrigin(
`app-${uuidv4()}`,
'http://beans.host.puter.localhost',
{ ownerUserId: owner.id },
);
const app = await createPrivateApp(
owner.id,
'http://beans.app.puter.localhost/',
);
const out = await resolveOwnedAppForHostedSite({
req: reqOf('beans.app.puter.localhost'),
site: { user_id: owner.id },
db: server.clients.db,
config: baseConfig(),
});
expect(out?.uid).toBe(app.uid);
expect(Boolean(out?.is_private)).toBe(true);
});
it('returns null when the site has no owner', async () => {
const out = await resolveOwnedAppForHostedSite({
req: reqOf('beans.site.puter.localhost'),
@@ -780,7 +802,7 @@ describe('resolvePrivateIdentity', () => {
expect(out.userUid).toBeUndefined();
});
it("ignores req.actor whose actor.app.uid doesn't match the target app", async () => {
it("ignores req.actor whose app doesn't match the target app", async () => {
// Cross-app token confusion guard: an app-under-user actor whose
// issuing app is NOT the private host's app must not establish
// identity here — even though the underlying user may have legit
@@ -791,11 +813,11 @@ describe('resolvePrivateIdentity', () => {
const out = await resolvePrivateIdentity({
req: reqOf({
cookies: {},
actor: {
actor: makeActor({
user: { uuid: user.uuid },
app: { uid: `app-attacker-${uuidv4()}` },
session: { uid: 'sess-1' },
},
}),
}),
authService,
sessionCookieName: 'puter_auth_token',
@@ -805,6 +827,77 @@ describe('resolvePrivateIdentity', () => {
expect(out.userUid).toBeUndefined();
});
it('ignores an app-issued access token minted for a different app', async () => {
// An access token carries its issuing app one hop down the chain, on
// `effectiveApp`, with no `app` of its own. Reading `app` here would
// take it for a portable session token and hand app A's token an
// identity on app B's private host.
const user = await makeUser();
const issuer = makeActor({
user: { uuid: user.uuid },
app: { uid: `app-attacker-${uuidv4()}` },
});
const out = await resolvePrivateIdentity({
req: reqOf({
cookies: {},
actor: makeActor({
user: { uuid: user.uuid },
accessToken: { uid: `tok-${uuidv4()}`, issuer },
session: { uid: 'sess-1' },
}),
}),
authService,
sessionCookieName: 'puter_auth_token',
expectedAppUid: `app-target-${uuidv4()}`,
});
expect(out.source).toBe('none');
expect(out.userUid).toBeUndefined();
});
it('accepts an app-issued access token minted for the target app', async () => {
const user = await makeUser();
const targetUid = `app-target-${uuidv4()}`;
const issuer = makeActor({
user: { uuid: user.uuid },
app: { uid: targetUid },
});
const out = await resolvePrivateIdentity({
req: reqOf({
cookies: {},
actor: makeActor({
user: { uuid: user.uuid },
accessToken: { uid: `tok-${uuidv4()}`, issuer },
session: { uid: 'sess-1' },
}),
}),
authService,
sessionCookieName: 'puter_auth_token',
expectedAppUid: targetUid,
});
expect(out.source).toBe('session-cookie');
expect(out.userUid).toBe(user.uuid);
});
it('still accepts a user-issued access token, which is portable', async () => {
const user = await makeUser();
const issuer = makeActor({ user: { uuid: user.uuid } });
const out = await resolvePrivateIdentity({
req: reqOf({
cookies: {},
actor: makeActor({
user: { uuid: user.uuid },
accessToken: { uid: `tok-${uuidv4()}`, issuer },
session: { uid: 'sess-1' },
}),
}),
authService,
sessionCookieName: 'puter_auth_token',
expectedAppUid: `app-target-${uuidv4()}`,
});
expect(out.source).toBe('session-cookie');
expect(out.userUid).toBe(user.uuid);
});
it("ignores a bootstrap query-token whose actor.app.uid doesn't match the target app", async () => {
// Same guard for the `?puter.auth.token=` bootstrap path. A token
// minted via getUserAppToken for the attacker's app carries
@@ -249,8 +249,10 @@ export async function resolveOwnedAppForHostedSite(opts: {
? `AND \`is_private\` = ${opts.db.booleanLiteral(true)} `
: '';
const placeholders = uniqueCandidates.map(() => '?').join(', ');
// Ambiguity fails closed: a private row wins; `id` keeps it deterministic.
const orderClause = `ORDER BY CASE WHEN \`is_private\` = ${opts.db.booleanLiteral(true)} THEN 0 ELSE 1 END, \`id\``;
const rows = await opts.db.read(
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) LIMIT 2`,
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) ${orderClause} LIMIT 2`,
[opts.site.user_id, ...uniqueCandidates],
);
if (rows.length === 0) return null;
@@ -451,16 +453,21 @@ export async function resolvePrivateIdentity(opts: {
* match the host the request is being made against. A token minted for app A —
* e.g. when the visitor authorized a third-party app — must not be honored as
* identity on app B's private host, even when the underlying user happens to
* have entitlement to B. User-only actors (no `actor.app`) are unaffected: a
* plain session token is portable by design.
* have entitlement to B. User-only actors are unaffected: a plain session token
* is portable by design.
*
* "No app anywhere in the chain" is the portable case, so read `effectiveApp` —
* an app-issued access token has no `app` of its own and would otherwise pass
* as a plain session on any private host.
*/
function actorMatchesExpectedApp(
actor: Actor,
expectedAppUid: string | undefined,
): boolean {
if (!expectedAppUid) return true;
if (!actor.app?.uid) return true;
return actor.app.uid === expectedAppUid;
const app = actor.effectiveApp;
if (!app) return true;
return app.uid === expectedAppUid;
}
/**
@@ -24,6 +24,7 @@ import { v4 as uuidv4 } from 'uuid';
import { PuterServer } from '../../../server';
import { setupTestServer } from '../../../testUtil';
import type { IConfig } from '../../../types';
import type { Actor } from '../../actor';
import { generateDefaultFsentries } from '../../../util/userProvisioning';
import { createPuterSiteMiddleware } from './puterSite';
@@ -875,6 +876,63 @@ describe('createPuterSiteMiddleware — file serving', () => {
// FSService so we exercise parsing, path resolution, and the loop-
// prevention guard together.
describe('createPuterSiteMiddleware — site.access.check', () => {
const serveUnder = async (listener?: (data: unknown) => void) => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
const sub = `gate-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry!.id,
});
await writeFile(
owner.id,
`${homePath}/secret.txt`,
Buffer.from('shh'),
'text/plain',
);
const seen: unknown[] = [];
server.clients.event.on('site.access.check', (_key, data) => {
if (data.subdomain !== sub) return;
seen.push(data);
listener?.(data);
});
const { out } = await runMiddleware(
buildMiddleware(),
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/secret.txt',
}),
);
await new Promise<void>((resolve) => setImmediate(resolve));
return { out, seen, sub };
};
it('asks listeners before streaming and serves when nobody objects', async () => {
const { out, seen, sub } = await serveUnder();
expect(out.statusCode).toBe(200);
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({
subdomain: sub,
host: `${sub}.site.puter.localhost`,
requestPath: '/secret.txt',
entry: { name: 'secret.txt' },
result: { allowed: true },
});
});
it('returns the plain file 404 when a listener withholds the entry', async () => {
const { out } = await serveUnder((data) => {
(data as { result: { allowed: boolean } }).result.allowed = false;
});
expect(out.statusCode).toBe(404);
expect(String(out.body)).toContain('Not Found');
expect(out.headers['Content-Type']).toBeUndefined();
});
});
describe('createPuterSiteMiddleware — .puter_site_config', () => {
const setupSiteWithConfig = async (config: unknown) => {
const owner = await makeUserWithHome();
@@ -1369,3 +1427,233 @@ describe('createPuterSiteMiddleware — __workers/ is never served', () => {
expect(piped!.equals(shell)).toBe(true);
});
});
describe('createPuterSiteMiddleware — hosting CSP', () => {
const POLICY = "script-src * 'unsafe-inline'; object-src 'none'";
// Serves one file at the site root and returns the captured response.
const serve = async (
filename: string,
contentType: string,
configOverride?: Partial<IConfig>,
) => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
const sub = `csp-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry!.id,
});
await writeFile(
owner.id,
`${homePath}/${filename}`,
Buffer.from('<p>hi</p>'),
contentType,
);
const mw = buildMiddleware(configOverride);
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: `/${filename}`,
}),
res,
vi.fn(),
);
await new Promise<void>((resolve) => setImmediate(resolve));
return out;
};
it('sends no CSP header when hosting_csp is unset', async () => {
// The default. Hosted sites are third-party apps, so an unconfigured
// deployment must not start enforcing a policy on them.
const out = await serve('index.html', 'text/html');
expect(out.statusCode).toBe(200);
expect(out.headers['Content-Security-Policy']).toBeUndefined();
expect(
out.headers['Content-Security-Policy-Report-Only'],
).toBeUndefined();
});
it('enforces hosting_csp on an active document when configured', async () => {
const out = await serve('index.html', 'text/html', {
hosting_csp: POLICY,
});
expect(out.statusCode).toBe(200);
expect(out.headers['Content-Security-Policy']).toBe(POLICY);
expect(
out.headers['Content-Security-Policy-Report-Only'],
).toBeUndefined();
});
it('sends the policy as Report-Only when hosting_csp_report_only is set', async () => {
const out = await serve('index.html', 'text/html', {
hosting_csp: POLICY,
hosting_csp_report_only: true,
});
expect(out.statusCode).toBe(200);
expect(out.headers['Content-Security-Policy-Report-Only']).toBe(POLICY);
expect(out.headers['Content-Security-Policy']).toBeUndefined();
});
it('leaves non-active documents alone', async () => {
// A CSP does nothing for a stylesheet or an image, and every header
// set here ships to apps we don't control.
const out = await serve('style.css', 'text/css', {
hosting_csp: POLICY,
});
expect(out.statusCode).toBe(200);
expect(out.headers['Content-Security-Policy']).toBeUndefined();
});
});
// -- Sites rooted in someone else's directory ------------------------
//
// Hosting serves everything under the site root with the ACL bypassed, so a
// site published from a shared folder rides on the `manage` grant that
// authorized it. These pin that the grant is re-read on every request, and
// that the ordinary owner-rooted site pays nothing for it.
const actorFor = (user: {
id: number;
uuid: string;
username: string;
}): Actor =>
({
user: { id: user.id, uuid: user.uuid, username: user.username },
effectiveApp: null,
}) as Actor;
const descriptorFor = (path: string) => ({
path,
resolveAncestors: () => server.services.fs.getAncestorChain(path),
});
const serveSite = async (sub: string) => {
const mw = buildMiddleware();
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/index.html',
}),
res,
vi.fn(),
);
// Allow the piped stream to flush.
await new Promise<void>((resolve) => setImmediate(resolve));
return out;
};
describe('createPuterSiteMiddleware — delegated site roots', () => {
// Owner shares a directory at `manage`; the delegate points a subdomain
// at it, which is exactly what `SubdomainDriver` permits.
const publishSharedDir = async () => {
const owner = await makeUserWithHome();
const delegate = await makeUserWithHome();
const dirPath = `/${owner.username}/Documents`;
const dirEntry = (await server.stores.fsEntry.getEntryByPath(dirPath))!;
await writeFile(
owner.id,
`${dirPath}/index.html`,
Buffer.from('<html>shared</html>'),
'text/html',
);
await server.services.acl.setUserUser(
actorFor(owner),
actorFor(delegate),
descriptorFor(dirPath),
'manage',
);
const sub = `shared-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: delegate.id,
subdomain: sub,
rootDirId: dirEntry.id,
});
return { owner, delegate, dirEntry, sub };
};
it('serves a site whose publisher still holds `manage` on the root', async () => {
const { sub } = await publishSharedDir();
const out = await serveSite(sub);
expect(out.statusCode).toBe(200);
expect((out.body as Buffer).toString()).toBe('<html>shared</html>');
});
it("stops serving once the owner withdraws the publisher's `manage` grant", async () => {
const { owner, delegate, dirEntry, sub } = await publishSharedDir();
expect((await serveSite(sub)).statusCode).toBe(200);
await server.services.permission.revokeUserUserPermission(
actorFor(owner),
delegate.username,
`manage:fs:${dirEntry.uuid}`,
);
const out = await serveSite(sub);
expect(out.statusCode).toBe(404);
expect(out.contentType).toBe('text/html; charset=UTF-8');
expect(String(out.body)).toContain('404');
});
it('stops serving once the owner unshares the directory', async () => {
const { owner, delegate, dirEntry, sub } = await publishSharedDir();
expect((await serveSite(sub)).statusCode).toBe(200);
await server.services.share.unshare(actorFor(owner), {
uid: dirEntry.uuid,
recipient: { username: delegate.username },
});
expect((await serveSite(sub)).statusCode).toBe(404);
});
it('stops serving once the owner moves the root into their Trash', async () => {
// The grant is keyed on the node, so it survives the move — the
// trashed location is what makes the site unservable.
const { owner, dirEntry, sub } = await publishSharedDir();
expect((await serveSite(sub)).statusCode).toBe(200);
const trash = (await server.stores.fsEntry.getEntryByPath(
`/${owner.username}/Trash`,
))!;
await server.services.fs.move(owner.id, {
source: dirEntry,
destinationParent: trash,
});
expect((await serveSite(sub)).statusCode).toBe(404);
});
it("never consults the ACL for a site rooted in the publisher's own tree", async () => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry =
(await server.stores.fsEntry.getEntryByPath(homePath))!;
await writeFile(
owner.id,
`${homePath}/index.html`,
Buffer.from('<html>mine</html>'),
'text/html',
);
const sub = `own-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry.id,
});
const aclCheck = vi.spyOn(server.services.acl, 'check');
try {
const out = await serveSite(sub);
expect(out.statusCode).toBe(200);
expect(aclCheck).not.toHaveBeenCalled();
} finally {
aclCheck.mockRestore();
}
});
});
+115 -1
View File
@@ -22,8 +22,10 @@ import { contentType as contentTypeFromMime } from 'mime-types';
import { posix as pathPosix } from 'node:path';
import type { puterClients } from '../../../clients';
import type { puterServices } from '../../../services';
import { MANAGE_PERM_PREFIX } from '../../../services/permission/consts';
import type { puterStores } from '../../../stores';
import type { IConfig, LayerInstances } from '../../../types';
import { makeActor } from '../../actor';
import {
buildAppCenterFallback,
buildHostingConfig,
@@ -89,6 +91,32 @@ const isWorkersSourcePath = (urlPath: string): boolean =>
.split('/')
.some((segment) => segment.toLowerCase() === WORKERS_FOLDER);
/** Inside some owner's top-level Trash, which is where Delete puts things. */
const isTrashedPath = (path: string): boolean =>
/^\/[^/]+\/Trash(\/|$)/u.test(path);
/**
* Suggested value for `config.hosting_csp`. Not applied unless configured.
*
* Sources stay wide open because hosted sites legitimately pull from anywhere,
* and `'unsafe-inline'` stays because most hosted apps use inline script. What
* it withholds is `'unsafe-eval'` — the capability a stale library needs to
* reach `new Function`, which is how CVE-2024-4367 turns a crafted PDF into
* script execution in the viewer app's own origin.
*
* Apps that genuinely need eval (some wasm glue, some template engines) will
* break under this, which is why it ships as a suggestion rather than a
* default. Roll it out with `hosting_csp_report_only` first and read the
* violation reports before enforcing.
*/
export const RECOMMENDED_HOSTING_CSP = [
'default-src * data: blob:',
"script-src * data: blob: 'unsafe-inline'",
"style-src * data: blob: 'unsafe-inline'",
"object-src 'none'",
"base-uri 'self'",
].join('; ');
const SUBDOMAIN_404 = `<div style="font-size: 20px;
text-align: center;
height: calc(100vh);
@@ -460,6 +488,49 @@ export const createPuterSiteMiddleware = (
return;
}
// A site rooted in someone else's directory stands on the `manage`
// grant that authorized publishing it, and serves that whole subtree
// with the ACL bypassed — so the grant is re-read on every request.
// Trash is its own check: a grant is keyed on the node, not its
// location, so it survives the owner deleting the directory. Sites
// rooted in their publisher's own tree skip all of this.
if (rootEntry.userId !== site.user_id) {
let stillPublishable = false;
if (!isTrashedPath(rootEntry.path)) {
try {
stillPublishable = await layers.services.acl.check(
makeActor({
user: {
id: owner.id,
uuid: owner.uuid,
username: owner.username,
},
}),
{
path: rootEntry.path,
resolveAncestors: () =>
layers.services.fs.getAncestorChain(
rootEntry.path,
),
},
MANAGE_PERM_PREFIX,
);
} catch (e) {
// Fail closed — an unreadable grant is not a held one.
console.warn(
'[puter-site] publish grant recheck failed',
e,
);
}
}
if (!stillPublishable) {
res.status(404)
.type('text/html; charset=UTF-8')
.send(SUBDOMAIN_404);
return;
}
}
// Resolve URL path → absolute FS path under the site root.
let urlPath = req.path || '/';
if (urlPath.endsWith('/')) urlPath += 'index.html';
@@ -560,6 +631,33 @@ export const createPuterSiteMiddleware = (
return;
}
// A listener owning the site may withhold this entry (a system site
// that publishes per-user files conditionally). Denied looks exactly
// like missing, and a listener failure denies rather than serves.
const accessCheck = {
subdomain,
host,
requestPath: req.path,
entry,
result: { allowed: true },
};
try {
await layers.clients.event.emitAndWait(
'site.access.check',
accessCheck,
{},
);
} catch (e) {
console.error('[puter-site] site.access.check threw', e);
accessCheck.result.allowed = false;
}
if (!accessCheck.result.allowed) {
res.status(404)
.type('text/html; charset=UTF-8')
.send('<h1>404</h1><p>Not Found</p>');
return;
}
// Stream the file. `fsEntry.readContent` honours Range + emits
// ETag/Last-Modified when the S3 layer returns them. Range
// requests are suppressed when serving a custom error page so
@@ -587,7 +685,23 @@ export const createPuterSiteMiddleware = (
// Fire-and-forget signal for downstream extensions
const mimeBase = mime.split(';', 1)[0].trim().toLowerCase();
if (mimeBase === 'text/html' || mimeBase === 'application/xhtml+xml') {
const isActiveDocument =
mimeBase === 'text/html' || mimeBase === 'application/xhtml+xml';
// Only active documents carry a CSP — it does nothing for an image or
// a stylesheet, and every header we set here ships to third-party apps
// we don't control. Off unless `hosting_csp` is configured; see
// RECOMMENDED_HOSTING_CSP.
if (config.hosting_csp && isActiveDocument) {
res.setHeader(
config.hosting_csp_report_only
? 'Content-Security-Policy-Report-Only'
: 'Content-Security-Policy',
config.hosting_csp,
);
}
if (isActiveDocument) {
try {
const requestUrl = (req.originalUrl || '/').startsWith('/')
? req.originalUrl || '/'
@@ -20,11 +20,7 @@
import { posix as pathPosix } from 'node:path';
import type { FSEntry } from '../../../stores/fs/FSEntry';
/**
* Minimal Redis surface we need — `get` / `set` with EX TTL. Typed as a subset
* of ioredis so callers can pass either the real cluster client or a mock
* without needing the full Cluster type here.
*/
/** The subset of ioredis the loader uses, so tests can pass a mock. */
export interface SiteConfigCache {
get(key: string): Promise<string | null>;
set(
@@ -35,42 +31,6 @@ export interface SiteConfigCache {
): Promise<unknown>;
}
/**
* Site-level configuration loaded from `.puter_site_config` at the site root.
* Lets a hosted site customize how the server responds for it — currently
* limited to error-page mapping (e.g. SPA fallback that serves `/index.html`
* for any 404 with a 200 status).
*
* The on-disk JSON shape:
*
* { "errors": { "404": { "file": "/index.html", "status": 200 } } }
*
* Other static-hosting platforms (Vercel `vercel.json`, Netlify/Amplify
* `_redirects`, nginx `error_page`) express the same idea with different
* syntax. The internal `SiteConfig` is intentionally narrow so additional
* parsers can normalize to it without churning the consumer in `puterSite.ts` —
* see `SITE_CONFIG_FILENAMES` for the lookup list.
*
* Security posture (every input here originates from a user-uploaded file
* served on the open internet):
*
* - The on-disk file is size-capped before parse (`MAX_CONFIG_BYTES`),
* - JSON parsing is wrapped in try/catch — a malformed file silently falls back
* to default behavior, never 5xx,
* - Every `file` value is normalized as if it were a URL path: it must start with
* `/`, gets `pathPosix.normalize`d so `..` is collapsed, and is re-anchored
* under the site root before any FS lookup,
* - Status codes are clamped to a strict allow-list (the request side only
* honours 4xx/5xx error keys; the response side validates the `status` is a
* legitimate HTTP integer),
* - The config file itself is hidden from public serving by the caller
* (`isSiteConfigPath`) — same status/body as any other missing path, no
* separate 403 that would leak its existence.
*
* Loop safety is the consumer's responsibility: when serving an error page, do
* NOT re-consult `errors` if the error page itself is missing, otherwise a
* misconfigured site could spin a 404→404→404 cycle.
*/
export interface SiteErrorRule {
/** Absolute path under the site root (e.g. `/index.html`). */
file: string;
@@ -78,39 +38,32 @@ export interface SiteErrorRule {
status: number;
}
/**
* Per-site config from `.puter_site_config` at the site root, e.g. `{ "errors":
* { "404": { "file": "/index.html", "status": 200 } } }`. Every value comes
* from a user-uploaded file; the parser validates it before it reaches this
* shape. When serving an error page the consumer must not consult `errors`
* again if that page is missing, or a misconfigured site loops.
*/
export interface SiteConfig {
/** Map of HTTP status code → custom error rule. Keys are 4xx/5xx. */
/** HTTP status code (4xx/5xx) → error rule. */
errors: Record<number, SiteErrorRule>;
}
const MAX_CONFIG_BYTES = 64 * 1024;
// Cache key prefix is distinct from `subdomains:` (SubdomainStore) and
// other Redis users — keep this in sync if you rename, otherwise stale
// entries from prior deploys could be read back as configs.
const CACHE_KEY_PREFIX = 'puter-site-config:';
const CACHE_TTL_SECONDS = 60;
// Sentinel for "we looked, the site has no config" so repeated visits
// to a config-less site don't keep round-tripping S3 to confirm.
// Cached "site has no config" so config-less sites don't re-read storage.
const NEGATIVE_CACHE_MARKER = '__none__';
/**
* Filenames consulted at the site root, in priority order. First file that
* parses to a non-empty config wins. Extending this list to add Vercel /
* Netlify / nginx adapters is the entrypoint for multi-format support — each
* parser receives the raw text and returns a normalized `SiteConfig` (or null
* if the file isn't valid in that format).
*/
/** Filenames consulted at the site root, in priority order. */
const SITE_CONFIG_FILENAMES: ReadonlyArray<{
name: string;
parse: (text: string) => SiteConfig | null;
}> = [{ name: '.puter_site_config', parse: parsePuterSiteConfig }];
/**
* Returns true if `urlPath` (already normalized to start with `/`) names the
* site config file. Used by `puterSite.ts` to suppress direct serving so the
* deployment shape isn't leaked to visitors.
*/
/** Whether `urlPath` names a config file, which is never served publicly. */
export function isSiteConfigPath(urlPath: string): boolean {
const base = pathPosix.basename(urlPath);
return SITE_CONFIG_FILENAMES.some((f) => f.name === base);
@@ -119,12 +72,7 @@ export function isSiteConfigPath(urlPath: string): boolean {
interface LoadSiteConfigArgs {
/** Absolute site root path (e.g. `/<username>/Public`). */
rootPath: string;
/**
* Stable identifier for the site root, used as the cache key. We key on
* `rootDirId` (not the subdomain) so that renaming a subdomain — or
* pointing multiple subdomains at the same directory — neither orphans nor
* duplicates the cached entry.
*/
/** Cache key; the directory rather than the subdomain, which can be renamed. */
rootDirId: number;
fsEntryStore: {
getEntryByPath: (path: string) => Promise<FSEntry | null>;
@@ -138,21 +86,14 @@ interface LoadSiteConfigArgs {
contentLength: number | null;
}>;
};
/**
* Optional Redis cache. When omitted, every request re-reads the config
* from S3 — fine for tests, slow for prod. Cache failures are swallowed
* (best-effort): a transient Redis blip just falls through to the live
* read, never errors the request.
*/
/** Best-effort; cache failures fall through to a live read. */
cache?: SiteConfigCache;
}
/**
* Locate and parse the site config. Returns null when no config file exists,
* the file is unreadable, oversized, or fails validation — callers must treat
* null as "behave like there is no config" and never raise the error to the
* visitor. Errors are logged for the operator but never surfaced to the
* request.
* Locate and parse the site config. Null when there is none or it is
* unreadable, oversized, or invalid; errors are logged, never surfaced to the
* visitor.
*/
export async function loadSiteConfig(
args: LoadSiteConfigArgs,
+55 -8
View File
@@ -21,6 +21,10 @@
import crypto from 'node:crypto';
import { withSpan } from '../../../util/span.js';
import { HttpError } from '../HttpError.js';
import {
DEFAULT_FREE_SUBSCRIPTION,
FREE_SUBSCRIPTION_IDS,
} from '../../../services/metering/consts.js';
/**
* Sliding-window rate limiter with swappable, **co-resident** backends.
@@ -534,16 +538,15 @@ function resolveKey(req, scope, strategy) {
/**
* Bucket identity for an authenticated actor: `<user>[:<app>][:<worker>]`.
*
* The app segment is the app the actor acts as (`effectiveApp`, so an access
* token minted by an app lands in that app's bucket). The worker segment is the
* worker's session uid, unique per (user, app, worker name). Without these, a
* busy app or worker drains the limit shared by everything else the same user
* runs.
* The app segment is the app the actor acts as, so an access token minted by an
* app lands in that app's bucket. The worker segment is the worker's session
* uid, unique per (user, app, worker name). Without these, a busy app or worker
* drains the limit shared by everything else the same user runs.
*/
function actorKey(actor, userId) {
const parts = [userId];
const app = actor.effectiveApp ?? actor.app;
if (app?.uid) parts.push(app.uid);
const appUid = actor.effectiveApp?.uid;
if (appUid) parts.push(appUid);
if (actor.session?.kind === 'worker' && actor.session.uid) {
parts.push(actor.session.uid);
}
@@ -709,6 +712,41 @@ export async function checkRateLimit(key, limit, windowMs, backend) {
}
}
/**
* Charge a route-shaped rate-limit spec from inside a handler.
*
* `rateLimitGate` takes one static spec per route, which is wrong for a route
* whose cost depends on a request parameter: `stat` with `return_shares` does
* the same work as the share-listing route, and should spend from the same
* budget. This resolves the key and the per-subscription limit exactly as the
* gate does — same spec in, same bucket out — so a handler can charge a second
* scope conditionally. Returns true if allowed; fails open on backend error.
* Takes the array form too; windows charge in order, no refund on refusal.
*/
export async function consumeRouteRateLimit(req, spec) {
if (Array.isArray(spec)) {
for (const window of spec) {
if (!(await consumeRouteRateLimit(req, window))) return false;
}
return true;
}
const {
window: windowMs,
key: strategy = 'fingerprint',
scope,
backend,
} = spec;
const backendPair = resolveBackend(backend);
const key = resolveKey(req, scope ?? req.route?.path ?? 'route', strategy);
try {
const limit = await resolveSubscriptionLimit(req, spec);
return await backendPair.rate(key, limit, windowMs);
} catch (err) {
console.error('[rate-limit] handler charge failed, failing open:', err);
return true;
}
}
/**
* Read whether `key` still has budget, without spending any. The twin to
* `checkRateLimit` for gates whose budget is consumed by an outcome rather than
@@ -808,6 +846,15 @@ export const CONCURRENT_SLOT_TTL_MS = ORPHAN_SAFETY_TTL_MS;
* actor, no metering, metering throws) falls through to the base — rate /
* concurrency limiting should never _amplify_ a request failure path.
*/
// An unlisted free plan would otherwise take `limit`, the paid cap.
function overrideFor(bySubscription, subscriptionId) {
const own = bySubscription[subscriptionId];
if (typeof own === 'number') return own;
return FREE_SUBSCRIPTION_IDS.has(subscriptionId)
? bySubscription[DEFAULT_FREE_SUBSCRIPTION]
: undefined;
}
async function resolveSubscriptionLimit(req, opts) {
const base = opts.limit;
if (!opts.bySubscription || !meteringService) return base;
@@ -815,7 +862,7 @@ async function resolveSubscriptionLimit(req, opts) {
if (!actor?.user?.uuid) return base;
try {
const sub = await meteringService.getActorSubscription(actor);
const override = opts.bySubscription[sub.id];
const override = overrideFor(opts.bySubscription, sub.id);
return typeof override === 'number' ? override : base;
} catch {
return base;
@@ -38,6 +38,7 @@ import {
checkDriverRateLimit,
checkRateLimit,
concurrencyGate,
consumeRouteRateLimit,
configureRateLimit,
listConfiguredRateLimitBackends,
peekRateLimit,
@@ -406,6 +407,51 @@ describe('rateLimitGate — bySubscription overrides', () => {
expect(await runGate(opts, paidReq)).toBeUndefined();
});
it('holds a free plan the spec never named to the free cap', async () => {
// A team seat resolves to `org_seat_free`; no driver enumerates it, and
// falling through to `limit` would outrank an ordinary free account.
configureRateLimit({
metering: {
getActorSubscription: async () => ({ id: 'org_seat_free' }),
},
});
const opts = {
limit: 100,
window: 60_000,
bySubscription: { user_free: 1 },
key: 'user',
scope: 'rl-sub-orgseat',
};
const req = () => ({
actor: { user: { id: 7, uuid: 'seat' } },
headers: {},
});
expect(await runGate(opts, req())).toBeUndefined();
expect(isHttpError(await runGate(opts, req()))).toBe(true);
});
it('still gives a paid plan the base when it names no cap of its own', async () => {
configureRateLimit({
metering: {
getActorSubscription: async () => ({ id: 'some-paid-tier' }),
},
});
const opts = {
limit: 2,
window: 60_000,
bySubscription: { user_free: 1 },
key: 'user',
scope: 'rl-sub-paid-unlisted',
};
const req = () => ({
actor: { user: { id: 8, uuid: 'paid2' } },
headers: {},
});
expect(await runGate(opts, req())).toBeUndefined();
expect(await runGate(opts, req())).toBeUndefined();
expect(isHttpError(await runGate(opts, req()))).toBe(true);
});
it('falls back to the base `limit` when metering throws', async () => {
configureRateLimit({
metering: {
@@ -1427,6 +1473,91 @@ describe('checkRateLimit', () => {
});
});
describe('consumeRouteRateLimit', () => {
beforeEach(() => configureRateLimit());
afterEach(() => configureRateLimit());
const uniqueScope = () => `charge-${Math.random().toString(36).slice(2, 10)}`;
it('spends from the same bucket the route gate does', async () => {
// The whole point of the helper: a handler charge and the gate must
// resolve the identical key for the same spec + request.
const opts = { limit: 2, window: 60_000, key: 'user', scope: uniqueScope() };
const req = { actor: { user: { id: 7 } }, headers: {} };
expect(await consumeRouteRateLimit(req, opts)).toBe(true);
const next = vi.fn();
await rateLimitGate(opts)(req, {}, next);
expect(next.mock.calls[0][0]).toBeUndefined();
// Gate + helper together exhausted the bucket; either side now rejects.
expect(await consumeRouteRateLimit(req, opts)).toBe(false);
const rejectedNext = vi.fn();
await rateLimitGate(opts)(req, {}, rejectedNext);
expect(isHttpError(rejectedNext.mock.calls[0][0])).toBe(true);
});
it("buckets per user under the 'user' strategy", async () => {
const opts = { limit: 1, window: 60_000, key: 'user', scope: uniqueScope() };
const reqFor = (id) => ({ actor: { user: { id } }, headers: {} });
expect(await consumeRouteRateLimit(reqFor(1), opts)).toBe(true);
expect(await consumeRouteRateLimit(reqFor(1), opts)).toBe(false);
expect(await consumeRouteRateLimit(reqFor(2), opts)).toBe(true);
});
it('charges every window of an array spec, and any refusal refuses', async () => {
// The array form routes use (burst + sustained); the tightest bites.
const specs = [
{ limit: 5, window: 60_000, key: 'user', scope: uniqueScope() },
{ limit: 2, window: 60_000, key: 'user', scope: uniqueScope() },
];
const req = { actor: { user: { id: 9 } }, headers: {} };
expect(await consumeRouteRateLimit(req, specs)).toBe(true);
expect(await consumeRouteRateLimit(req, specs)).toBe(true);
expect(await consumeRouteRateLimit(req, specs)).toBe(false);
// The refusing window really was the second one, not the first.
expect(await consumeRouteRateLimit(req, specs[0])).toBe(true);
});
it('applies bySubscription overrides via the wired metering service', async () => {
configureRateLimit({
metering: {
getActorSubscription: async (actor) => ({
id: actor.user.uuid === 'free' ? 'user_free' : 'other',
}),
},
});
const opts = {
limit: 5,
window: 60_000,
bySubscription: { user_free: 1 },
key: 'user',
scope: uniqueScope(),
};
const freeReq = { actor: { user: { id: 1, uuid: 'free' } }, headers: {} };
expect(await consumeRouteRateLimit(freeReq, opts)).toBe(true);
expect(await consumeRouteRateLimit(freeReq, opts)).toBe(false);
});
it('fails open when the backend throws', async () => {
configureRateLimit({
default: 'redis',
redis: {
multi: () => {
throw new Error('redis exploded');
},
},
});
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const opts = { limit: 1, window: 60_000, key: 'ip', scope: uniqueScope() };
const req = { ip: '5.5.5.5', headers: {} };
expect(await consumeRouteRateLimit(req, opts)).toBe(true);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});
describe('peekRateLimit', () => {
beforeEach(() => configureRateLimit());
afterEach(() => configureRateLimit());
@@ -19,6 +19,7 @@
import type { Request, Response } from 'express';
import { describe, expect, it, vi } from 'vitest';
import { makeActor } from '../../actor.js';
import { TokenService } from '../../../services/auth/TokenService.js';
import {
createStepUpGate,
@@ -187,7 +188,10 @@ describe('createStepUpGate', () => {
});
const next = vi.fn();
const req = reqWith(undefined, USER_UUID, {
actor: { user: { uuid: USER_UUID }, app: { uid: 'app-xyz' } },
actor: makeActor({
user: { uuid: USER_UUID },
app: { uid: 'app-xyz' },
}),
} as never);
gate(req, {} as Response, next);
expect(next).toHaveBeenCalledWith();
@@ -201,7 +205,10 @@ describe('createStepUpGate', () => {
});
const next = vi.fn();
const req = reqWith(undefined, USER_UUID, {
actor: { user: { uuid: USER_UUID }, app: { uid: 'app-xyz' } },
actor: makeActor({
user: { uuid: USER_UUID },
app: { uid: 'app-xyz' },
}),
} as never);
gate(req, {} as Response, next);
expect(next.mock.calls[0][0]?.statusCode).toBe(403);
@@ -212,7 +219,10 @@ describe('createStepUpGate', () => {
const gate = createStepUpGate({ tokenService: ts });
const next = vi.fn();
const req = reqWith(undefined, USER_UUID, {
actor: { user: { uuid: USER_UUID }, app: { uid: 'app-xyz' } },
actor: makeActor({
user: { uuid: USER_UUID },
app: { uid: 'app-xyz' },
}),
} as never);
gate(req, {} as Response, next);
expect(next.mock.calls[0][0]?.statusCode).toBe(403);
@@ -21,6 +21,7 @@ import type { Request, RequestHandler } from 'express';
import type { IConfig } from '../../../types';
import type { UserRow } from '../../../stores/user/UserStore';
import type { TokenService } from '../../../services/auth/TokenService';
import { isAppActor } from '../../actor';
import { sessionCookieFlags } from '../../../util/cookieFlags';
import { HttpError } from '../HttpError';
@@ -169,7 +170,12 @@ export function createStepUpGate(deps: {
// elevate, so step-up is unsatisfiable for it. Any other actor — most
// importantly a root/human session with no app id in its token — falls
// through and must present the elevation proof.
const appUid = req.actor?.app?.uid;
// The app it carries directly, not the one acting: an access token can
// never satisfy step-up either, so widening the exemption to the token
// chain would only hand it a free pass.
const appUid = isAppActor(req.actor)
? req.actor?.effectiveApp?.uid
: undefined;
if (appUid && deps.allowedAppUids?.includes(appUid)) {
next();
return;
@@ -19,6 +19,7 @@
import type { Request, RequestHandler, Response, NextFunction } from 'express';
import bcrypt from 'bcrypt';
import { isPlainUserActor } from '../../actor';
import { HttpError } from '../HttpError';
import type { IConfig } from '../../../types';
import type { UserStore, UserRow } from '../../../stores/user/UserStore';
@@ -99,12 +100,7 @@ export const createSessionCookieGate = (config: IConfig): RequestHandler => {
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
) {
if (!actor?.user || !actor.session || !isPlainUserActor(actor)) {
return next(
new HttpError(401, 'Web session required', {
legacyCode: 'session_required',
+110 -242
View File
@@ -18,6 +18,7 @@
*/
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import type { UserRow } from '../../stores/user/UserStore';
import type { Actor } from '../actor';
/**
@@ -26,20 +27,9 @@ import type { Actor } from '../actor';
* it instead of accepting any token that authenticates.
*/
export type TokenSource =
| 'body'
| 'header'
| 'x-api-key'
| 'cookie'
| 'query'
| 'handshake';
'body' | 'header' | 'x-api-key' | 'cookie' | 'query' | 'handshake';
/**
* Every route method PuterRouter exposes. Mirrors the express router surface
* plus WebDAV verbs that some endpoints still use.
*
* `use` and `all` don't map to a single HTTP verb — they're treated uniformly
* by the materializer (see `v2/server.ts`).
*/
/** Express router methods plus the WebDAV verbs some endpoints use. */
export type RouteMethod =
| 'use'
| 'all'
@@ -59,37 +49,36 @@ export type RouteMethod =
| 'move';
/**
* Path shape accepted by express route methods. Kept permissive rather than
* re-exporting express's internal `PathParams` (which isn't stable public
* API).
* Express route path; kept permissive since express's `PathParams` is not
* public API.
*/
export type RoutePath = string | RegExp | Array<string | RegExp>;
/**
* Per-route options declared by the caller.
*
* The materializer (`v2/server.ts#materializeRoute`) translates these into a
* middleware chain in this order:
*
* subdomain → requireAuth (+ suspended) → emailConfirmed →
* requireUserActor → adminOnly → allowedAppIds → phoneVerified →
* cardVerified → requireReputation → requireSubscription → rateLimit →
* requireCredits → concurrent → caller `middleware: []` → handler
*
* `requireUserActor`, `adminOnly`, and `allowedAppIds` all imply `requireAuth`;
* the materializer dedupes so only one auth gate ends up in the chain.
* Commented-out slots are reserved for the next chunks (body parsing, post-auth
* gates, timing).
*/
/** The account-verification factors a route can ask for by name. */
export type VerificationFactor = 'phone' | 'card';
/** The user fields the account gates read; all optional, like `Actor.user`. */
export type AccountGateUser = Partial<
Pick<
UserRow,
| 'suspended'
| 'email_confirmed'
| 'requires_email_confirmation'
| 'requires_phone_verification'
| 'requires_card_verification'
| 'requires_password_change'
| 'phone'
| 'card_fingerprint'
>
>;
/** One rate-limit window. See `RouteOptions.rateLimit` for semantics. */
export interface RouteRateLimit {
limit: number;
window: number;
/**
* Per-subscription overrides for `limit` (`SubscriptionPolicy.id` → cap).
* Same mechanic as `concurrent.bySubscription`: resolved via the metering
* service per request; falls back to the base `limit` when there's no actor
* / no metering / no match.
* Per-`SubscriptionPolicy.id` overrides for `limit`; the base applies when
* there is no match.
*/
bySubscription?: Record<string, number>;
key?: 'fingerprint' | 'ip' | 'user' | ((req: Request) => string);
@@ -97,241 +86,148 @@ export interface RouteRateLimit {
backend?: 'memory' | 'redis' | 'kv';
}
/**
* Per-route options. The materializer (`v2/server.ts`) turns them into a
* middleware chain in this order:
*
* subdomain → requireAuth → emailConfirmed → requireUserActor → adminOnly →
* allowedAppIds → phoneVerified → cardVerified → anyVerified →
* requireReputation → requireSubscription → rateLimit → requireCredits →
* concurrent → `middleware` → handler
*
* Options that imply `requireAuth` are deduped to a single auth gate.
*/
export interface RouteOptions {
/**
* Extra per-route middleware. Applied after built-in gates, before the
* handler.
*/
/** Extra middleware, run after the built-in gates. */
middleware?: RequestHandler[];
/**
* Subdomain routing. If set, the route only matches requests whose leftmost
* subdomain is in this list (via `next('route')` skip).
*
* If omitted, verb-routes (get/post/etc.) are restricted to the root origin
* only (no subdomain). Pass `'*'` to explicitly match ANY subdomain/root.
* `use()` middleware is not gated by default.
* Only match requests whose leftmost subdomain is listed. Verb routes
* default to the root origin only; `'*'` matches any. `use()` middleware is
* not gated by default.
*/
subdomain?: string | string[];
/**
* Reject anonymous + suspended-user requests with 401/403. Only allows user
* and app actors
*/
/** 401 for anonymous requests, 403 for suspended accounts. */
requireAuth?: boolean;
/** Reject app/access-token actors. Implies `requireAuth`. */
requireUserActor?: boolean;
/**
* Only meaningful alongside `requireUserActor`. Relaxes the access-token
* half of that gate so a FULL-ACCESS personal access token (the user's own
* credential) is admitted — third-party apps and scoped tokens stay
* blocked. Use ONLY on user-resource / inference endpoints that gate with
* `requireUserActor` purely to keep apps out (e.g. the AI proxy). NEVER set
* on account/security/credential routes — those must stay closed to every
* access token. Default-deny: omitting this keeps PATs blocked.
* With `requireUserActor`, admit full-access personal access tokens; apps
* and scoped tokens stay blocked. Never set on account or security routes.
*/
allowFullAccessToken?: boolean;
/** Allows access-tokens */
/** Admit scoped access tokens. */
allowAccessToken?: boolean;
/**
* Reject bare user-session actors ("root" tokens — no app, no access token)
* with 403 `app_or_api_token_required`. Implies `requireAuth`. Use on API
* surfaces that must only be driven by a delegated credential (app/worker
* token or a dashboard-minted API token), so a copied session token can't
* double as an API credential. Compose with `requireUserActor` +
* `allowFullAccessToken` to further narrow to API tokens only (apps stay
* out too). Worker sessions (`session.kind === 'worker'`) always pass — a
* deployed worker is a delegated, revocable credential, never a root
* token.
* Reject bare account-session actors with 403 `app_or_api_token_required`;
* worker sessions pass. Implies `requireAuth`.
*/
noUserSession?: boolean;
/**
* Reject unless the actor's username is `admin`, `system`, or one of the
* extras in this array. `true` means just `admin`/`system`; an array adds
* to that pair (does not replace it). Implies `requireAuth`.
*
* Also requires a _root token_ (an actor with no app anywhere in its token
* chain), so an admin acting through a third-party app can't reach the
* route. Pair with `allowedAppIds` to make an admin route reachable by
* specific apps: the combination admits a root token OR a token scoped to
* an allowed app.
*
* Does NOT imply `requireUserActor` — a root token still includes an
* admin's full-access personal access token, not only browser sessions.
* Combine with `requireUserActor` to restrict to browser sessions.
* Reject unless the username is `admin`, `system`, or one of the listed
* extras, and the actor is a root token (no app in its chain). Pair with
* `allowedAppIds` to also admit tokens scoped to those apps. Implies
* `requireAuth`, not `requireUserActor`.
*/
adminOnly?: boolean | string[];
/**
* Reject an app-under-user actor whose app is not in this list. Implies
* Reject actors acting as an app that is not listed, whether carried
* directly or through the app that issued their access token. Actors with
* no app in the chain pass; see `allowedAppIdsGate`. Implies
* `requireAuth`.
*
* Not an app gate: actors carrying no app of their own — browser sessions,
* full-access personal access tokens, workers, and access tokens issued by
* an app — pass through untouched. `adminOnly` + `allowedAppIds` relies on
* that. See `allowedAppIdsGate` before using this to scope a route to
* apps.
*/
allowedAppIds?: string[];
/**
* Allow users whose account is pending email confirmation to access this
* route. By default, any authenticated route rejects users where
* `requires_email_confirmation && !email_confirmed` with 403. Set this to
* `true` on essential flows that must remain accessible before
* confirmation: logout, email-confirm, whoami, save-account, anti-CSRF
* token, etc.
*
* Only meaningful when the route also requires authentication (via
* `requireAuth`, `requireUserActor`, `adminOnly`, or `allowedAppIds`).
* Let accounts still pending a verification through. For the flows that
* clear the pending state (logout, confirm-email, whoami, save-account).
*/
allowUnconfirmed?: boolean;
/**
* Reject unless the actor's user has a confirmed email. 400 with
* `account_is_not_verified` on failure. No-op when
* `config.strict_email_verification_required` is falsy, so self-hosted
* deployments can opt in via config. Implies `requireAuth` but NOT
* `requireUserActor` — app-under-user actors also carry a `.user`, so
* verification applies uniformly whether the user acts directly or through
* an app.
* Reject unless the email is confirmed. Inert unless
* `config.strict_email_verification_required`. Implies `requireAuth`.
*/
requireVerified?: boolean;
/**
* Reject unless the user has a phone number verified on file (verified at
* some point, not merely never asked to). 403
* `phone_verification_required`. Implies `requireAuth`; independent of the
* default-on pending-verification gate, which only turns away accounts the
* abuse harness flagged.
*
* Opt-in per route, for surfaces worth the friction of an SMS round trip.
* 403 `phone_verification_required` unless a phone was verified. Implies
* `requireAuth`.
*/
requirePhoneVerified?: boolean;
/**
* Reject unless the user has a card verified on file. 403
* `card_verification_required`. Same shape as `requirePhoneVerified` — the
* opt-in, "must have actually done it" counterpart of the pending gate.
* 403 `card_verification_required` unless a card was verified. Implies
* `requireAuth`.
*/
requireCardVerified?: boolean;
/**
* Per-route JSON body parsing override. By default the global parser
* handles every `application/json` request with a 50mb limit and stashes
* the raw bytes on `req.rawBody` for signature-verification use cases.
*
* Use this option only when a route needs different parser settings:
*
* - `false` — opt out of parsing entirely (rare; the route reads the raw
* stream itself, e.g. some webhook proxies). The global parser will still
* have already run if the content-type was JSON, so this is mostly useful
* for routes that accept _non_-JSON body shapes and want to ensure no
* further parsers attach.
* - `{ limit, type }` — override the limit (e.g., for ML endpoints that
* legitimately need 100mb) or the matched content-type list (e.g., to
* ALSO accept `application/x-ndjson`).
* Reject unless one of the listed factors is verified; a paid plan counts
* for `card`. Only factors this deployment can verify are asked for, so
* with none verifiable the gate is inert. The 403 lists the verifiable
* factors in `factors`. An empty list is a boot error.
*/
requireAnyVerified?: readonly VerificationFactor[];
/**
* Override the global JSON parser: `false` opts out, `{ limit, type }`
* changes the size limit or the matched content types.
*/
bodyJson?: false | { limit?: string; type?: string | string[] };
/**
* Per-route raw (Buffer) body parser. Use for binary uploads where the
* route handler wants `req.body: Buffer` directly. Default content-type
* match is `application/octet-stream`; pass `type` to override.
* Buffer body parser. Matches `application/octet-stream` unless `type`
* overrides.
*/
bodyRaw?: boolean | { limit?: string; type?: string | string[] };
/**
* Per-route text body parser. `req.body` becomes a string. Default
* content-type match is `text/plain`.
*/
/** Text body parser. Matches `text/plain` by default. */
bodyText?: boolean | { limit?: string; type?: string | string[] };
/**
* Per-route urlencoded form parser. `req.body` becomes a parsed object.
* Default `extended: true` (uses `qs`); pass `extended: false` for the
* built-in `querystring` parser.
*/
/** Form parser. `extended: true` (default) uses `qs`. */
bodyUrlencoded?: boolean | { limit?: string; extended?: boolean };
/**
* Require captcha verification. When `true`, the route rejects requests
* that don't carry valid `captchaToken` + `captchaAnswer` fields in the
* body. No-op when captcha is disabled in config.
* Require valid `captchaToken` + `captchaAnswer` in the body. No-op when
* captcha is disabled.
*/
captcha?: boolean;
/**
* Require a valid one-time anti-CSRF token in `req.body.anti_csrf`. The
* token is consumed on use. Requires authentication (keyed by user uuid).
* Require a valid one-time `anti_csrf` body token. Needs an authenticated
* user.
*/
antiCsrf?: boolean;
/**
* Restrict the route to pages on this deployment's own GUI origin
* (`config.origin`, plus any `config.allow_gui_origins`). Requests with no
* `Origin` header still pass — the gate stops cross-origin browser pages,
* not non-browser clients.
*
* For routes that return a session credential to the caller. See
* `guiOriginGate` for why reflected-CORS makes this necessary.
* Only allow pages on this deployment's GUI origin; requests with no
* `Origin` header still pass. For routes returning a session credential;
* see `guiOriginGate`.
*/
guiOriginOnly?: boolean;
/**
* Per-route rate limiting. In-memory sliding window keyed by request
* identity.
*
* `key` controls how requests are bucketed:
*
* - `'fingerprint'` (default) — network hash (IP + headers), refined by the
* client's device fingerprint when one was supplied. Safe for shared IPs
* (offices, VPNs): each device gets its own bucket instead of the whole
* network sharing one.
* - `'ip'` — bare IP address.
* - `'user'` — actor's user ID. Use for authenticated routes where you want
* per-account limits.
* - `(req) => string` — custom key function.
*
* `scope` is an optional namespace prefix to isolate counters between
* routes that share the same key strategy. Defaults to the route path.
*
* `backend` selects the storage backend ('memory' / 'redis' / 'kv'). All
* registered backends stay co-resident at runtime, so different routes can
* pick whichever fits their access pattern. Omitting `backend` uses the
* server-wide default (`config.rate_limit.backend`).
*
* An array applies every limit independently (a request must pass all of
* them). Use this to pair a per-client budget with a coarser backstop on a
* different key — e.g. per-fingerprint for fairness on shared IPs, plus
* per-IP so rotating headers can't mint fresh fingerprint buckets
* indefinitely. Give each entry its own `scope` so the counters don't
* collide.
* Sliding-window rate limit. `key` picks the bucket: `fingerprint`
* (default; network hash refined by device fingerprint), `ip`, `user`, or a
* function. `scope` namespaces the counter (defaults to the route path);
* `backend` defaults to `config.rate_limit.backend`. An array applies every
* limit independently; give each its own `scope`.
*/
rateLimit?: RouteRateLimit | RouteRateLimit[];
/**
* Concurrent in-flight limiting. Caps how many requests for this key are
* simultaneously in flight, rather than how many fire per window. A slot is
* acquired before the handler runs and released on `res.finish` /
* `res.close` — aborted requests still give their slot back.
*
* { concurrent: { limit: 5, key: 'user' } } { concurrent: { limit: 5,
* bySubscription: { user_free: 2 } } }
*
* `bySubscription` overrides the base `limit` per subscription tier
* (`SubscriptionPolicy.id`) — `user_free`, `temp_free`, `unlimited` out of
* the box. Requires the metering service to be wired into the rate-limit
* module (`configureRateLimit({ metering: ... })`) and an authenticated
* actor; otherwise the base `limit` applies.
*
* `key`, `scope`, `backend` parallel the `rateLimit` option exactly;
* there's no `window` — that's the whole point of the second gate.
* Cap simultaneous in-flight requests per key; slots are released on
* response finish or close. `bySubscription` overrides `limit` per
* `SubscriptionPolicy.id` when metering is wired in. `key`, `scope`,
* `backend` as in `rateLimit`.
*/
concurrent?: {
limit: number;
@@ -342,55 +238,23 @@ export interface RouteOptions {
};
/**
* Reject an account with nothing left of its usage budget with 402
* `insufficient_funds`.
*
* For routes that spend metered resources on the caller's behalf — moving
* file content, making object-store requests. Not for the routes that show
* an account what it has or let it delete things: an account that has run
* out still needs to be able to see its files, clear space, and reach its
* billing pages, and blocking that leaves no way out other than paying.
*
* Anonymous callers and worker sessions pass; see `requireCreditsGate`.
* Rate limits remain the bound on request _count_ — this bounds spend, and
* lags the traffic that produced it by the metering buffer window, so it
* stops sustained usage rather than a burst.
* 402 `insufficient_funds` when the account has no usage budget left. For
* routes that spend metered resources, not for the ones that let an account
* see or free up what it has. Anonymous callers and worker sessions pass.
*/
requireCredits?: boolean;
/**
* Reject a caller whose plan doesn't include this route with 402
* `subscription_required`.
*
* `true` accepts any plan that isn't one of the free ones, so a plan
* registered by an extension counts without being named here. An array of
* `SubscriptionPolicy.id`s accepts only those, for a feature that belongs
* to specific plans (`requireSubscription: ['business', 'pro']`).
*
* Opt-in: nothing is subscriber-only unless it says so. Implies
* `requireAuth` — there is no plan to read off an anonymous caller. The
* answer comes from the metering service's per-actor subscription cache, so
* the check costs a map lookup on a warm actor. Distinct from
* `requireCredits`, which asks whether an account has budget left rather
* than which plan it is on; a route may want both.
* 402 `subscription_required` unless the plan qualifies: `true` accepts any
* non-free plan, an array of `SubscriptionPolicy.id`s only those. Implies
* `requireAuth`.
*/
requireSubscription?: boolean | string[];
/**
* Reject a caller whose account isn't trusted enough for this route with
* 403 `reputation_required`.
*
* The value names a tier; the minimum score that tier takes is deployment
* config (`reputationGate.tiers`), so the number a surface is worth stays
* out of the surface's declaration and can be retuned without editing it. A
* tier the running config doesn't define is inert and everyone passes — a
* deployment that doesn't score its accounts turns nobody away.
*
* Opt-in, and implies `requireAuth`: there is no account to have earned
* anything on an anonymous caller. The declaration decides the chain, so a
* route asking for a tier is authenticated whether or not the tier
* currently enforces anything. `false` is "no requirement", the same as
* leaving it out.
* 403 `reputation_required` unless the account meets the named tier.
* Thresholds come from `reputationGate.tiers`; an undefined tier is inert.
* Implies `requireAuth`.
*/
requireReputation?: string | false;
@@ -464,15 +328,19 @@ export type AuthRequired<O extends RouteOptions> = O extends {
: O extends { requireCardVerified: true }
? true
: O extends {
requireSubscription:
| true
| readonly string[]
| string[];
requireAnyVerified:
| readonly VerificationFactor[]
| VerificationFactor[];
}
? true
: O extends { requireReputation: string }
: O extends {
requireSubscription:
true | readonly string[] | string[];
}
? true
: false;
: O extends { requireReputation: string }
? true
: false;
/** Express `Request` with `actor` narrowed based on the route's options. */
export type TypedRequest<O extends RouteOptions> = Omit<Request, 'actor'> & {
+1
View File
@@ -27,6 +27,7 @@ export {
isSystemActor,
isAppActor,
isAccessTokenActor,
isPlainUserActor,
actorUid,
assertResolvedActor,
makeActor,
+6 -1
View File
@@ -17,7 +17,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { ORG_SEAT_FREE } from './orgSeatFreePolicy.js';
import { REGISTERED_USER_FREE } from './registeredUserFreePolicy.js';
import { TEMP_USER_FREE } from './tempUserFreePolicy.js';
export const SUB_POLICIES = [TEMP_USER_FREE, REGISTERED_USER_FREE];
export const SUB_POLICIES = [
TEMP_USER_FREE,
REGISTERED_USER_FREE,
ORG_SEAT_FREE,
];
Loaded 100 of 566 files, more files were not shown because too many files have changed in this diff. Show more