Compare commits

...
70 Commits
Author SHA1 Message Date
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
Daniel Salazar 51ce868d1e fix: shared kv handles also allow value opt in (#3895) 2026-09-17 10:34:35 -07:00
Daniel Salazar bb97660ad7 feat: kv events value opt in (#3894) 2026-09-17 00:17:12 -07: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 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
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 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
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
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 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 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 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
368 changed files with 20087 additions and 4757 deletions

No files matched your search

+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: |
+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.
+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",
+20 -6
View File
@@ -285,12 +285,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
+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
+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 }, () =>
+28 -17
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 {
+90 -635
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.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
"integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
"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",
@@ -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.4",
"@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[]>;
}
@@ -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,6 +54,8 @@ 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;
@@ -79,6 +81,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);
@@ -162,7 +165,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 +204,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 +227,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 +357,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 +397,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 -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 = 81;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -113,6 +113,8 @@ 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']],
];
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,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,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;
+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. */
+5 -3
View File
@@ -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>
+33 -17
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 {}
@@ -294,6 +284,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 +311,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 +423,12 @@ 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[];
};
/**
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
@@ -695,6 +693,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 +718,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
@@ -793,10 +808,11 @@ export type EventKey = keyof EventMap & string;
// Generates a wildcard for every non-final dot-separated prefix of K.
export type WildcardPrefixes<K extends string> =
K extends `${infer Head}.${infer Tail}`
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
@@ -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(
@@ -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 });
});
@@ -2357,10 +2356,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 +2403,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 +2453,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 +2475,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 +2526,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 +2633,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 +2648,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 +2691,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 +2714,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 +2748,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 +2762,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 +2780,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 +2795,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 +2810,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',
@@ -3879,8 +3915,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 +3929,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,
@@ -5006,6 +5070,32 @@ 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 +5343,52 @@ 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()}`;
@@ -5686,10 +5822,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(
@@ -6487,6 +6623,28 @@ 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
+79 -37
View File
@@ -28,6 +28,10 @@ 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 { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
import {
@@ -61,6 +65,7 @@ import {
SEND_PHONE_RATE_LIMIT,
SEND_PHONE_RATE_WINDOW_MS,
cardFallbackAfterAttempts,
cardFallbackDepsFrom,
cardFallbackFlagKey,
isCardFallbackEligible,
isCardFallbackEnabled,
@@ -196,20 +201,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',
@@ -1621,18 +1612,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 +1878,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 +2009,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 +2024,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 +2130,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 +2144,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,
@@ -2293,6 +2280,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 +2546,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', {
@@ -2617,6 +2623,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', {
@@ -3251,8 +3267,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 +3324,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 +3347,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 +3382,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
@@ -4374,6 +4390,15 @@ 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' },
);
}
res.clearCookie(this.config.cookie_name ?? 'puter_token');
res.clearCookie('puter_token_v2');
res.clearCookie('puter_revalidation');
@@ -4704,8 +4729,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 +4841,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 +4880,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 +4908,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 });
}
@@ -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,
@@ -58,6 +59,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,
+21 -9
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
@@ -258,6 +262,14 @@ export const EVENTS_WORKER_LIST_LIMIT = userWindow('events:workers:list', 120);
*/
export const EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT = 50;
/**
* Largest key-value value a delivery inlines, in serialized bytes. A value over
* this is left out and the subscriber re-reads the key: a delivery fans out to
* many rows, may cross regions and may sit in a backlog, none of which is sized
* for the store's own ceiling.
*/
export const EVENTS_KV_VALUE_MAX_BYTES = 16 * 1024;
/**
* Broadcast deliveries per minute, per subscription.
*
@@ -238,7 +238,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 };
};
}
@@ -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,
@@ -798,6 +800,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 +1858,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, {
+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,
@@ -1921,3 +1921,211 @@ 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 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]!),
];
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,13 @@ 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 { 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 +428,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', () => {
@@ -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',
@@ -1930,10 +1938,7 @@ export class LegacyFSController extends PuterController {
const subjectRef = body.subject;
const appRef = body.app;
const mode = (getString(body, 'mode') ?? 'read') as
| 'see'
| 'list'
| 'read'
| 'write';
'see' | 'list' | 'read' | 'write';
if (!subjectRef || !appRef)
throw new HttpError(400, '`subject` and `app` are required', {
legacyCode: 'bad_request',
+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;
@@ -955,6 +955,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 +1857,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(
@@ -302,6 +302,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,
});
},
);
@@ -924,6 +925,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 +937,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;
@@ -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);
});
});
@@ -57,6 +57,8 @@ export async function toClientShare(
...(share.pending
? { pending: true, 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',
};
@@ -235,6 +235,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 () => {
+33 -4
View File
@@ -85,6 +85,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, {
@@ -105,6 +106,7 @@ export class TeamController extends PuterController {
})
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);
res.json({
items: teams.map((t) =>
@@ -182,10 +184,13 @@ export class TeamController extends PuterController {
})
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,
);
// Only the owner: the uuid is what billing keys a seat's plan on, and
// one member has no business identifying another.
const isOwner = team.owner_user_id === userId;
const page = await this.stores.team.listMembers(
this.#param(req, 'uid'),
@@ -202,6 +207,7 @@ export class TeamController extends PuterController {
username: m.username,
org_owned: Number(m.org_owned) === 1,
created_at: m.created_at,
...(isOwner ? { uuid: m.uuid } : {}),
})),
...(page.cursor ? { cursor: page.cursor } : {}),
});
@@ -249,7 +255,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 +355,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 +412,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 +444,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`, {
+3 -22
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 {
+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;
};
+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';
@@ -780,7 +781,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 +792,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 +806,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
@@ -451,16 +451,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';
@@ -1369,3 +1370,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();
}
});
});
+88 -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';
@@ -587,7 +658,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',
+111 -236
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';
/**
@@ -33,13 +34,7 @@ export type TokenSource =
| '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 +54,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 +91,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 +243,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 +333,21 @@ 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,
];
@@ -0,0 +1,32 @@
/*
* 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 { ORG_SEAT_FREE_SUBSCRIPTION } from '../../services/metering/consts.js';
import { REGISTERED_USER_FREE } from './registeredUserFreePolicy.js';
/** Half the free plan, so a team cannot mint full free tiers by provisioning. */
export const ORG_SEAT_FREE = {
id: ORG_SEAT_FREE_SUBSCRIPTION,
monthUsageAllowance: Math.floor(
REGISTERED_USER_FREE.monthUsageAllowance / 2,
),
monthlyStorageAllowance: Math.floor(
REGISTERED_USER_FREE.monthlyStorageAllowance / 2,
),
};
@@ -738,6 +738,41 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
});
}
it('rejects subscriber-only models for a team seat on the free org plan', async () => {
// `org_seat_free` pays nothing, so it must not reach a paid model —
// the gate checks every free plan, not two named ones.
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
id: 'subonly-seat',
aliases: [],
costs_currency: 'usd-cents',
costs: { 'input-tokens': 100, 'output-tokens': 100 },
max_tokens: 8192,
subscriberOnly: true,
},
]);
const d = await makeDriver();
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
1_000_000,
);
vi.spyOn(
server.services.metering,
'getActorSubscription',
).mockResolvedValue({ id: 'org_seat_free' } as never);
await expect(
withTestActor(() =>
d.complete({
model: 'subonly-seat',
messages: [{ role: 'user', content: 'hi' }],
}),
),
).rejects.toMatchObject({
statusCode: 403,
legacyCode: 'permission_denied',
});
});
it('rejects subscriber-only models for the default free subscription', async () => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
@@ -23,12 +23,10 @@ import { EventMap } from '../../clients/event/types.js';
import type { Actor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import { HttpError, isHttpError } from '../../core/http/HttpError.js';
import {
DEFAULT_FREE_SUBSCRIPTION,
DEFAULT_TEMP_SUBSCRIPTION,
} from '../../services/metering/consts.js';
import { FREE_SUBSCRIPTION_IDS } from '../../services/metering/consts.js';
import type { CreditHold } from '../../services/metering/types.js';
import { NO_CREDIT_HOLD } from '../../services/metering/types.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import type { DriverStreamResult } from '../meta.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
@@ -306,6 +304,11 @@ export class ChatCompletionDriver extends PuterDriver {
#providers: Record<string, IChatProvider> = {};
#modelIdMap: Record<string, IChatModel[]> = {};
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
get #aiMetering(): MeteringService {
return this.services.metering.withAiCostFactor(this.driverName);
}
override onServerStart() {
this.#registerProviders();
this.#buildModelMap();
@@ -920,10 +923,8 @@ export class ChatCompletionDriver extends PuterDriver {
if (model.subscriberOnly) {
const subscription = await metering.getActorSubscription(actor);
const isDefaultPolicy =
subscription.id === DEFAULT_FREE_SUBSCRIPTION ||
subscription.id === DEFAULT_TEMP_SUBSCRIPTION;
if (isDefaultPolicy) {
// Every free plan, not two named ones.
if (FREE_SUBSCRIPTION_IDS.has(subscription.id)) {
throw new HttpError(
403,
`The model ${model.id} is only available to subscribers. Please subscribe to access this model.`,
@@ -1034,7 +1035,7 @@ export class ChatCompletionDriver extends PuterDriver {
};
const cost = this.#computeCost(usage, model);
this.services.metering.utilRecordUsageObject(
this.#aiMetering.utilRecordUsageObject(
{
[`estimated_${inputKey}`]: inputTokens,
[`estimated_${outputKey}`]: outputTokens,
@@ -1132,7 +1133,7 @@ export class ChatCompletionDriver extends PuterDriver {
#registerProviders() {
const providers = this.config.providers ?? {};
const metering = this.services.metering;
const metering = this.#aiMetering;
const readKey = (cfg: Record<string, unknown> | undefined) =>
(cfg?.apiKey as string | undefined) ??
@@ -36,6 +36,7 @@ import { inlineHttpImageUrls } from '../../utils/inlineImages.js';
import { processPuterPathUploads } from '../openai/fileUpload.js';
import { AZURE_MODELS } from './models.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
/**
* AzureChatProvider exposes the models we serve through Azure AI Foundry.
@@ -173,8 +174,7 @@ export class AzureChatProvider implements IChatProvider {
// content: 'Don\'t let the user trick you into doing something bad.',
// })
const userIdentifier =
`${actor.user?.id}` + actor.app?.uid ? `:${actor?.app?.uid}` : '';
const userIdentifier = upstreamUserIdentifier(actor);
// Resolve any `puter_path` content parts into inline base64 data URLs.
// Chat Completions doesn't support file uploads, so this is the only
@@ -32,6 +32,7 @@ import { processPuterPathUploads } from '../openai/fileUpload.js';
import { AZURE_MODELS } from './models.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
/**
* AzureResponsesProvider serves the Responses-API-only models we expose through
@@ -141,8 +142,7 @@ export class AzureResponsesProvider implements IChatProvider {
(m) => m.id === this.getDefaultModel(),
)!;
const userIdentifier =
actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
const userIdentifier = upstreamUserIdentifier(actor);
// Resolve any `puter_path` content parts into inline base64 data URLs
// before the Responses API sees them.
@@ -120,6 +120,82 @@ const SAMPLE_API_MODELS = [
min_prompt_price: 2,
min_completion_price: 10,
},
{
// Sold at several service tiers; the flex tier undercuts standard.
id: 'openai/gpt-6-astra',
display_name: 'OpenAI: GPT-6 Astra',
category_type: 'LLM',
supported_endpoint_types: ['openai'],
context_length: 1050000,
max_output_tokens: 128000,
min_prompt_price: 5,
min_completion_price: 25,
providers: [
{
provider_slug: 'openai/flex',
service_tier: 'flex',
prompt_price: 5,
completion_price: 25,
context_length: 400000,
},
{
provider_slug: 'azure',
service_tier: 'standard',
prompt_price: 7.5,
completion_price: 37.5,
},
{
provider_slug: 'openai',
service_tier: 'standard',
prompt_price: 10,
completion_price: 50,
},
{
provider_slug: 'openai/priority',
service_tier: 'priority',
prompt_price: 20,
completion_price: 100,
},
],
},
{
// Flex is the only tier on offer — nothing to pin.
id: 'example/flex-only-model',
display_name: 'Flex Only',
category_type: 'LLM',
supported_endpoint_types: ['openai'],
context_length: 128000,
max_output_tokens: 4096,
min_prompt_price: 1,
min_completion_price: 2,
providers: [
{
provider_slug: 'example/flex',
service_tier: 'flex',
prompt_price: 1,
completion_price: 2,
},
],
},
{
// Catalog ids can contain a colon of their own.
id: 'example/colon-model:free',
display_name: 'Colon Model (free)',
category_type: 'LLM',
supported_endpoint_types: ['openai'],
context_length: 64000,
max_output_tokens: 4096,
min_prompt_price: 0,
min_completion_price: 0,
providers: [
{
provider_slug: 'example',
service_tier: 'standard',
prompt_price: 0,
completion_price: 0,
},
],
},
{
// Non-chat modality — filtered out.
id: 'black-forest-labs/flux-2.1',
@@ -262,10 +338,87 @@ describe('InfronProvider model catalog', () => {
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
});
it('quotes the tier Infron routes to by default, not the cheaper flex tier', async () => {
const { provider } = makeProvider();
const models = await provider.models();
// Cheapest standard offering is Azure at $7.5/$37.5 per million,
// not the $5/$25 flex tier the catalog floor reports.
expect(models).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'infron:openai/gpt-6-astra',
costs: expect.objectContaining({
prompt: 750,
completion: 3750,
input_cache_read: 750,
}),
}),
]),
);
});
it('lists a :flex id alongside the default-tier id, each at its own price', async () => {
const { provider } = makeProvider();
const models = await provider.models();
expect(models).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'infron:openai/gpt-6-astra',
costs: expect.objectContaining({
prompt: 750,
completion: 3750,
}),
}),
expect.objectContaining({
id: 'infron:openai/gpt-6-astra:flex',
costs: expect.objectContaining({
prompt: 500,
completion: 2500,
}),
}),
expect.objectContaining({
id: 'infron:openai/gpt-6-astra:priority',
costs: expect.objectContaining({
prompt: 2000,
completion: 10000,
}),
}),
]),
);
});
it('takes a tier variant context window from its own offering', async () => {
const { provider } = makeProvider();
const models = await provider.models();
const flex = models.find(
(m) => m.id === 'infron:openai/gpt-6-astra:flex',
);
// The flex offering caps context below the model-level figure.
expect(flex?.context).toBe(400000);
});
it('accepts the explicit :standard spelling as an alias of the plain id', async () => {
const { provider } = makeProvider();
const models = await provider.models();
const base = models.find((m) => m.id === 'infron:openai/gpt-6-astra');
expect(base?.aliases).toContain('openai/gpt-6-astra:standard');
});
it('still offers an explicit tier id when no default tier is sold', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
// The plain id leaves routing to Infron; the :flex id pins the one
// tier on offer. Same price, different routing guarantee.
expect(ids).toContain('infron:example/flex-only-model');
expect(ids).toContain('infron:example/flex-only-model:flex');
});
it('converts USD-per-million-token prices to microcents per token', async () => {
const { provider } = makeProvider();
const models = await provider.models();
// $10/M tokens → 10 * 100 = 1000 microcents per token.
// $10/M tokens → 10 * 100 = 1000 microcents per token. This model
// carries no per-tier breakdown, so the catalog floor is used.
expect(models).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -314,6 +467,68 @@ describe('InfronProvider.complete request shape', () => {
expect(args.usage).toEqual({ include: true });
});
it('pins the standard service tier so billing matches the quoted price', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'infron:openai/gpt-6-astra',
messages: [{ role: 'user', content: 'hello' }],
}),
);
expect(createMock.mock.calls[0]![0].provider).toEqual({
service_tier: 'standard',
});
});
it('leaves routing to Infron for models with no standard tier', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'infron:example/flex-only-model',
messages: [{ role: 'user', content: 'hello' }],
}),
);
expect('provider' in createMock.mock.calls[0]![0]).toBe(false);
});
it('pins the tier named by a :flex model id and strips it from the wire id', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'infron:openai/gpt-6-astra:flex',
messages: [{ role: 'user', content: 'hello' }],
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.model).toBe('openai/gpt-6-astra');
expect(args.provider).toEqual({ service_tier: 'flex' });
});
it('keeps a colon-bearing catalog id intact rather than reading it as a tier', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'infron:example/colon-model:free',
messages: [{ role: 'user', content: 'hello' }],
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.model).toBe('example/colon-model:free');
expect(args.provider).toEqual({ service_tier: 'standard' });
});
it('only sets stream_options.include_usage when streaming', async () => {
const { provider } = makeProvider();
@@ -31,12 +31,25 @@ import type {
ICompleteArguments,
} from '../../types.js';
/**
* One upstream offering of a model in Infron's catalog. The same model is often
* served at several service tiers with different prices; `flex` is
* batch-oriented and cheapest, `priority` is the fastest and dearest.
*/
type InfronApiProviderOffer = {
provider_slug?: string;
service_tier?: string;
prompt_price?: number;
completion_price?: number;
context_length?: number;
};
/**
* Shape of one entry in Infron's `GET /v1/models` catalog. Unlike OpenRouter
* there is no `pricing` object; prices are USD per million tokens in
* `min_prompt_price` / `min_completion_price`, and the catalog mixes non-chat
* modalities (image, video, embeddings) that this provider filters out via
* `category_type`.
* there is no `pricing` object; prices are USD per million tokens, per offering
* in `providers` and as a catalog-wide floor in `min_prompt_price` /
* `min_completion_price`. The catalog mixes non-chat modalities (image, video,
* embeddings) that this provider filters out via `category_type`.
*/
type InfronApiModel = {
id: string;
@@ -49,6 +62,117 @@ type InfronApiModel = {
min_prompt_price?: number;
min_completion_price?: number;
min_request_price?: number;
providers?: InfronApiProviderOffer[];
};
/** Tier Infron routes to when a request carries no explicit `service_tier`. */
const DEFAULT_SERVICE_TIER = 'standard';
/** What one tier of a model costs, in USD per million tokens. */
type InfronTierPrices = {
prompt: number;
completion: number;
context?: number;
};
/**
* Cheapest offering of each service tier a model sells. Tiers differ in price
* and sometimes in context window, so each one is quoted from its own row
* rather than from the catalog-wide `min_*` floor.
*/
const pricesByTier = (model: InfronApiModel) => {
const byTier = new Map<string, InfronTierPrices>();
for (const offer of model.providers ?? []) {
const tier = offer.service_tier;
if (!tier) continue;
const prompt = offer.prompt_price ?? 0;
const completion = offer.completion_price ?? 0;
const seen = byTier.get(tier);
if (seen && seen.prompt + seen.completion <= prompt + completion) {
continue;
}
byTier.set(tier, { prompt, completion, context: offer.context_length });
}
return byTier;
};
/**
* Splits a model id into the id Infron expects on the wire and the tier to pin.
* Catalog ids can contain a colon themselves (`…:free`), so an exact catalog
* match always wins over reading the last segment as a tier suffix.
*/
const resolveTier = (id: string, catalog: InfronApiModel[]) => {
const tiersOf = (model: InfronApiModel) =>
new Set((model.providers ?? []).map((offer) => offer.service_tier));
const exact = catalog.find((model) => model.id === id);
if (exact) {
return {
wireModelId: id,
// Unsuffixed ids are quoted at the default tier, so pin it — left
// unset, Infron load-balances across tiers and could bill another.
tier: tiersOf(exact).has(DEFAULT_SERVICE_TIER)
? DEFAULT_SERVICE_TIER
: undefined,
};
}
const cut = id.lastIndexOf(':');
const base =
cut > 0 ? catalog.find((m) => m.id === id.slice(0, cut)) : undefined;
const tier = id.slice(cut + 1);
if (base && tiersOf(base).has(tier)) return { wireModelId: base.id, tier };
return { wireModelId: id, tier: undefined };
};
/**
* One listed model: the default tier under the plain id, or a single service
* tier under a `…:<tier>` id priced from that tier's own offering.
*/
const coerceModel = (
model: InfronApiModel,
prices: InfronTierPrices,
tier?: string,
): IChatModel => {
const suffix = tier ? `:${tier}` : '';
const shortId = model.id.split('/').slice(1).join('/');
// Catalog prices are USD per million tokens; costs are microcents per
// token, so the conversion is ×100.
const promptCost = Math.round(prices.prompt * 100);
return {
id: `infron:${model.id}${suffix}`,
name: `${model.display_name || model.id} (Infron${tier ? `, ${tier}` : ''})`,
aliases: [
`${model.id}${suffix}`,
...(model.display_name && !tier ? [model.display_name] : []),
`infron/${model.id}${suffix}`,
`${shortId}${suffix}`,
// The plain id already means the default tier; accept the
// explicit spelling of it too.
...(tier ? [] : [`${model.id}:${DEFAULT_SERVICE_TIER}`]),
],
context: tier
? (prices.context ?? model.context_length)
: model.context_length,
max_tokens: model.max_output_tokens ?? 0,
costs_currency: 'usd-cents',
input_cost_key: 'prompt',
output_cost_key: 'completion',
costs: {
tokens: 1_000_000,
prompt: promptCost,
completion: Math.round(prices.completion * 100),
// The catalog carries no cache-read price; charge the full
// prompt rate in the fallback path so cached tokens are never
// billed below list. The normal path bills the
// gateway-reported cost instead.
input_cache_read: promptCost,
// USD per request → microcents per request.
request: Math.round(
(model.min_request_price ?? 0) * 1_000_000 * 100,
),
},
};
};
type InfronUsage = OpenAI.Completions.CompletionUsage & {
@@ -112,17 +236,25 @@ export class InfronProvider implements IChatProvider {
[m.id, ...(m.aliases || [])].includes(model),
) || availableModels.find((m) => m.id === this.getDefaultModel())!;
const modelIdForParams = modelUsed.id.startsWith('infron:')
const catalogId = modelUsed.id.startsWith('infron:')
? modelUsed.id.slice('infron:'.length)
: modelUsed.id;
// A `…:<tier>` id carries the tier to pin; a plain id means the
// default tier. Only tiers the model actually sells are pinned —
// Infron routes freely (and reports the tier back) when unset.
const { wireModelId, tier } = resolveTier(
catalogId,
await this.#rawModels(),
);
const actor = Context.get('actor');
messages = await OpenAIUtil.process_input_messages(messages);
const completionParams = {
messages,
model: modelIdForParams,
model: wireModelId,
...(tools ? { tools } : {}),
max_tokens,
temperature,
@@ -132,6 +264,10 @@ export class InfronProvider implements IChatProvider {
stream_options: { include_usage: true },
}
: {}),
// Without this Infron load-balances across service tiers, so a
// request could be billed at a tier other than the one whose
// price we quote in the catalog.
...(tier ? { provider: { service_tier: tier } } : {}),
// Surfaces the authoritative `cost` field (USD) on the
// response so metering doesn't depend on catalog prices.
usage: { include: true },
@@ -216,7 +352,8 @@ export class InfronProvider implements IChatProvider {
});
}
async models() {
/** The catalog as Infron returns it, kv-cached and shared by callers. */
async #rawModels(): Promise<InfronApiModel[]> {
let models = kv.get(KV_MODELS_KEY) as InfronApiModel[] | undefined;
if (!models) {
try {
@@ -235,7 +372,11 @@ export class InfronProvider implements IChatProvider {
console.log(e);
}
}
if (!models) return [];
return models ?? [];
}
async models() {
const models = await this.#rawModels();
const coerced_models: IChatModel[] = [];
for (const model of models) {
// The catalog mixes chat with image/video/embedding/search
@@ -245,40 +386,19 @@ export class InfronProvider implements IChatProvider {
if (!(model.supported_endpoint_types ?? []).includes('openai')) {
continue;
}
// Catalog prices are USD per million tokens; costs are
// microcents per token, so the conversion is ×100.
const promptCost = Math.round((model.min_prompt_price ?? 0) * 100);
coerced_models.push({
id: `infron:${model.id}`,
name: `${model.display_name || model.id} (Infron)`,
aliases: [
model.id,
...(model.display_name ? [model.display_name] : []),
`infron/${model.id}`,
model.id.split('/').slice(1).join('/'),
],
context: model.context_length,
max_tokens: model.max_output_tokens ?? 0,
costs_currency: 'usd-cents',
input_cost_key: 'prompt',
output_cost_key: 'completion',
costs: {
tokens: 1_000_000,
prompt: promptCost,
completion: Math.round(
(model.min_completion_price ?? 0) * 100,
),
// The catalog carries no cache-read price; charge the
// full prompt rate in the fallback path so cached
// tokens are never billed below list. The normal path
// bills the gateway-reported cost instead.
input_cache_read: promptCost,
// USD per request → microcents per request.
request: Math.round(
(model.min_request_price ?? 0) * 1_000_000 * 100,
),
},
});
const byTier = pricesByTier(model);
const defaultPrices = byTier.get(DEFAULT_SERVICE_TIER) ?? {
prompt: model.min_prompt_price ?? 0,
completion: model.min_completion_price ?? 0,
};
// The unsuffixed id is the default tier; every other tier the
// model sells gets its own `…:<tier>` id so callers can ask for
// one by name and see what it costs.
coerced_models.push(coerceModel(model, defaultPrices));
for (const [tier, prices] of byTier) {
if (tier === DEFAULT_SERVICE_TIER) continue;
coerced_models.push(coerceModel(model, prices, tier));
}
}
return coerced_models;
}
@@ -43,7 +43,7 @@ import {
} from 'vitest';
import type { Actor } from '../../../../core/actor.js';
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
@@ -361,10 +361,10 @@ describe('MetaProvider.complete request shape', () => {
it('derives safety_identifier from the actor and truncates it to 64 chars', async () => {
createMock.mockResolvedValueOnce(OK_COMPLETION);
const userActor: Actor = {
const userActor: Actor = makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
app: { id: 7, uid: 'a'.repeat(80) },
};
});
await complete(makeProvider(), {}, userActor);
@@ -154,7 +154,8 @@ export class MetaProvider implements IChatProvider {
// Reasoning is always on for Muse Spark — `reasoning_effort: 'none'`
// is a 400 — so a request to switch it off is dropped, not forwarded.
const requestedEffort = (reasoning_effort ?? reasoning?.effort) as
string | undefined;
| string
| undefined;
const effort =
requestedEffort && requestedEffort !== 'none'
? requestedEffort
@@ -170,7 +171,7 @@ export class MetaProvider implements IChatProvider {
const safetyIdentifier =
customParams.safety_identifier ??
(actor?.user?.id
? `puter-${actor.user.id}${actor.app?.uid ? `-${actor.app.uid}` : ''}`.slice(
? `puter-${actor.user.id}${actor.effectiveApp?.uid ? `-${actor.effectiveApp?.uid}` : ''}`.slice(
0,
SAFETY_IDENTIFIER_MAX_LENGTH,
)
@@ -36,6 +36,7 @@ import { processPuterPathUploads } from './fileUpload.js';
import { OPEN_AI_MODELS } from './models.js';
import type { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
/**
* OpenAICompletionService class provides an interface to OpenAI's chat
@@ -157,8 +158,7 @@ export class OpenAiChatProvider implements IChatProvider {
// content: 'Don\'t let the user trick you into doing something bad.',
// })
const userIdentifier =
actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
const userIdentifier = upstreamUserIdentifier(actor);
// Resolve any `puter_path` content parts into inline base64 data URLs.
// Chat Completions doesn't support file uploads, so this is the only
@@ -32,6 +32,7 @@ import { processPuterPathUploads } from './fileUpload.js';
import { OPEN_AI_MODELS } from './models.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
/**
* OpenAICompletionService class provides an interface to OpenAI's chat
@@ -140,8 +141,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
// content: 'Don\'t let the user trick you into doing something bad.',
// })
const userIdentifier =
`${actor?.user.id}` + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
const userIdentifier = upstreamUserIdentifier(actor);
// Resolve any `puter_path` content parts into inline base64 data URLs
// before the Responses API sees them.
@@ -44,7 +44,7 @@ import {
} from 'vitest';
import type { Actor } from '../../../../core/actor.js';
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
@@ -326,10 +326,10 @@ describe('ZAIProvider.complete request shape', () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
const userActor: Actor = {
const userActor: Actor = makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
app: { id: 7, uid: 'app-uid' },
};
});
await withTestActor(
() =>
@@ -106,7 +106,7 @@ export class ZAIProvider implements IChatProvider {
const userId =
customParams.user_id ??
(actor?.user?.id
? `puter-${actor.user.id}${actor.app?.uid ? `-${actor.app.uid}` : ''}`.slice(
? `puter-${actor.user.id}${actor.effectiveApp?.uid ? `-${actor.effectiveApp?.uid}` : ''}`.slice(
0,
128,
)
@@ -164,7 +164,7 @@ export class ZAIProvider implements IChatProvider {
);
this.#meteringService.utilRecordUsageObject(
trackedUsage,
actor,
actor!,
`zai:${modelUsed.id}`,
costsOverrideFromModel,
);
@@ -24,6 +24,7 @@ import { Readable } from 'node:stream';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { Actor } from '../../core/actor.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import { PuterDriver } from '../types.js';
import { secureFetch } from '../../util/secureHttp.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
@@ -73,6 +74,11 @@ export class ImageGenerationDriver extends PuterDriver {
#providers: Record<string, IImageProvider> = {};
#modelIdMap: Record<string, IImageModel[]> = {};
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
get #aiMetering(): MeteringService {
return this.services.metering.withAiCostFactor(this.driverName);
}
override onServerStart() {
this.#registerProviders();
this.#buildModelMap();
@@ -253,7 +259,7 @@ export class ImageGenerationDriver extends PuterDriver {
#registerProviders() {
const providers = this.config.providers ?? {};
const m = this.services.metering;
const m = this.#aiMetering;
const readKey = (
...cfgs: Array<Record<string, unknown> | undefined>
@@ -34,6 +34,7 @@ import { OPEN_AI_IMAGE_GENERATION_MODELS } from './models.js';
import { fetchImageAsBase64, isHttpUrl } from '../../inputImage.js';
import { estimateTextTokens } from '../../../util/tokenEstimate.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
interface OpenAIImageUsage {
inputTokens: number;
@@ -176,8 +177,7 @@ export class OpenAiImageProvider implements IImageProvider {
}
const actor = Context.get('actor');
const userIdentifier =
actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
const userIdentifier = upstreamUserIdentifier(actor);
const estimatedPromptTokenCount =
this.#estimatePromptTokenCount(prompt);
@@ -28,6 +28,7 @@ import type {
import { XAI_IMAGE_GENERATION_MODELS } from './models.js';
import { HttpError } from '../../../../core/http/HttpError.js';
import { assertInputImageString } from '../../inputImage.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
const DEFAULT_MODEL = 'grok-imagine-image';
// xAI's Grok Imagine edit endpoint accepts up to 3 source images per request.
@@ -94,8 +95,7 @@ export class XAIImageProvider implements IImageProvider {
const aspectRatio = this.#aspectRatio(ratio);
const actor = Context.get('actor');
const userIdentifier =
actor?.user.id + actor?.app?.uid ? `:${actor?.app?.uid}` : '';
const userIdentifier = upstreamUserIdentifier(actor);
const outputPriceInCents = selectedModel.costs[`output:${resolution}`];
const mediaInputPriceInCents = selectedModel.costs.media_input ?? 0;
+13 -5
View File
@@ -27,6 +27,7 @@ import { Actor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { mimeFromName } from '../../util/fileSigning.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import { loadFileInput, type LoadedFile } from '../util/fileInput.js';
@@ -102,6 +103,11 @@ export class OCRDriver extends PuterDriver {
readonly rateLimit = AI_RATE_LIMIT;
readonly concurrent = AI_CONCURRENT;
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
get #aiMetering(): MeteringService {
return this.services.metering.withAiCostFactor(this.driverName);
}
override getReportedCosts() {
return Object.entries(OCR_COSTS).map(([usageType, ucentsPerUnit]) => ({
usageType,
@@ -132,9 +138,11 @@ export class OCRDriver extends PuterDriver {
const providers = this.config.providers ?? {};
const textract = providers['aws-textract'] as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const textractAws = (textract?.aws ?? textract) as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const textractAccessKey = textractAws?.access_key as string | undefined;
const textractSecretKey = textractAws?.secret_key as string | undefined;
const textractRegion =
@@ -342,7 +350,7 @@ export class OCRDriver extends PuterDriver {
}
const pages = pageCount || 1;
this.services.metering.incrementUsage(
this.#aiMetering.incrementUsage(
actor,
usageType,
pages,
@@ -454,14 +462,14 @@ export class OCRDriver extends PuterDriver {
const pagesProcessed =
response?.usageInfo?.pagesProcessed ??
(Array.isArray(response?.pages) ? response.pages.length : 1);
this.services.metering.incrementUsage(
this.#aiMetering.incrementUsage(
actor,
'mistral-ocr:ocr:page',
pagesProcessed,
OCR_COSTS['mistral-ocr:ocr:page'] * pagesProcessed,
);
if (annotations) {
this.services.metering.incrementUsage(
this.#aiMetering.incrementUsage(
actor,
'mistral-ocr:annotations:page',
pagesProcessed,
@@ -20,6 +20,7 @@
import { Readable } from 'node:stream';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import type { DriverStreamResult } from '../meta.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
@@ -69,6 +70,11 @@ export class VoiceChangerDriver extends PuterDriver {
readonly rateLimit = AI_RATE_LIMIT;
readonly concurrent = AI_CONCURRENT;
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
get #aiMetering(): MeteringService {
return this.services.metering.withAiCostFactor(this.driverName);
}
override getReportedCosts(): Record<string, unknown>[] {
return Object.entries(VOICE_CHANGER_COSTS).map(
([usageType, ucentsPerUnit]) => ({
@@ -87,7 +93,8 @@ export class VoiceChangerDriver extends PuterDriver {
override onServerStart() {
const elevenlabs = this.config.providers?.elevenlabs as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
this.#apiKey =
(elevenlabs?.apiKey as string | undefined) ??
@@ -296,7 +303,7 @@ export class VoiceChangerDriver extends PuterDriver {
const arrayBuffer = await response.arrayBuffer();
const stream = Readable.from(Buffer.from(arrayBuffer));
this.services.metering.incrementUsage(
this.#aiMetering.incrementUsage(
actor,
usageKey,
estimatedSeconds,
@@ -19,6 +19,7 @@
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import {
@@ -66,6 +67,11 @@ export class SpeechToTextDriver extends PuterDriver {
#providers: Record<string, ISpeechToTextProvider> = {};
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
get #aiMetering(): MeteringService {
return this.services.metering.withAiCostFactor(this.driverName);
}
override onServerStart() {
this.#registerProviders();
}
@@ -195,7 +201,7 @@ export class SpeechToTextDriver extends PuterDriver {
const deps: ISpeechToTextDeps = {
stores: this.stores,
fs: this.services.fs,
metering: this.services.metering,
metering: this.#aiMetering,
};
this.#providers['openai'] = new OpenAISpeechToTextProvider(deps, {
+22 -10
View File
@@ -19,6 +19,7 @@
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import type { DriverStreamResult } from '../meta.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
@@ -73,6 +74,11 @@ export class TTSDriver extends PuterDriver {
#providers: Record<string, ITTSProvider> = {};
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
get #aiMetering(): MeteringService {
return this.services.metering.withAiCostFactor(this.driverName);
}
override onServerStart() {
this.#registerProviders();
}
@@ -244,7 +250,7 @@ export class TTSDriver extends PuterDriver {
#registerProviders() {
const providers = this.config.providers ?? {};
const m = this.services.metering;
const m = this.#aiMetering;
const openaiConfig =
(providers['openai-tts'] as Record<string, unknown> | undefined) ??
@@ -266,7 +272,8 @@ export class TTSDriver extends PuterDriver {
}
const elevenlabs = providers['elevenlabs'] as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const elevenKey =
(elevenlabs?.apiKey as string | undefined) ??
(elevenlabs?.api_key as string | undefined) ??
@@ -277,7 +284,8 @@ export class TTSDriver extends PuterDriver {
apiKey: elevenKey,
apiBaseUrl: elevenlabs?.apiBaseUrl as string | undefined,
defaultVoiceId: elevenlabs?.defaultVoiceId as
string | undefined,
| string
| undefined,
});
} catch (e) {
console.warn(
@@ -288,9 +296,11 @@ export class TTSDriver extends PuterDriver {
}
const polly = providers['aws-polly'] as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const pollyAws = (polly?.aws ?? polly) as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const pollyAccessKey = pollyAws?.access_key as string | undefined;
const pollySecretKey = pollyAws?.secret_key as string | undefined;
const pollyRegion =
@@ -317,9 +327,10 @@ export class TTSDriver extends PuterDriver {
}
#registerGeminiProvider(providers: Record<string, unknown>) {
const m = this.services.metering;
const m = this.#aiMetering;
const gemini = (providers['gemini'] ?? providers['gemini-tts']) as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const geminiKey =
(gemini?.apiKey as string | undefined) ??
(gemini?.api_key as string | undefined) ??
@@ -339,9 +350,10 @@ export class TTSDriver extends PuterDriver {
}
#registerXAIProvider(providers: Record<string, unknown>) {
const m = this.services.metering;
const m = this.#aiMetering;
const xai = (providers['xai'] ?? providers['xai-tts']) as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const xaiKey =
(xai?.apiKey as string | undefined) ??
(xai?.api_key as string | undefined) ??
@@ -361,7 +373,7 @@ export class TTSDriver extends PuterDriver {
}
#registerSpeechifyProvider(providers: Record<string, unknown>) {
const m = this.services.metering;
const m = this.#aiMetering;
const speechify = (providers['speechify'] ??
providers['speechify-tts']) as Record<string, unknown> | undefined;
const speechifyKey =
@@ -23,6 +23,7 @@ import { Readable } from 'node:stream';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { Actor } from '../../core/actor.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import { PuterDriver } from '../types.js';
import { secureFetch } from '../../util/secureHttp.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
@@ -94,6 +95,11 @@ export class VideoGenerationDriver extends PuterDriver {
#providers: Record<string, IVideoProvider> = {};
#modelIdMap: Record<string, IVideoModel[]> = {};
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
get #aiMetering(): MeteringService {
return this.services.metering.withAiCostFactor(this.driverName);
}
override onServerStart() {
this.#registerProviders();
this.#buildModelMap();
@@ -303,7 +309,7 @@ export class VideoGenerationDriver extends PuterDriver {
#registerProviders() {
const providers = this.config.providers ?? {};
const m = this.services.metering;
const m = this.#aiMetering;
// Same lenient reader as ImageGenerationDriver — accept
// `apiKey || secret_key`, and fall back from the video-specific
@@ -345,9 +351,11 @@ export class VideoGenerationDriver extends PuterDriver {
// pair its missing apiBaseUrl with the shared block's key (or vice
// versa) and point a region-scoped key at the wrong endpoint.
const byteplusVideoCfg = providers['byteplus-video-generation'] as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const byteplusSharedCfg = providers['byteplus'] as
Record<string, unknown> | undefined;
| Record<string, unknown>
| undefined;
const byteplusKey = readKey(byteplusVideoCfg, byteplusSharedCfg);
if (byteplusKey) {
this.#providers['byteplus-video-generation'] =
@@ -356,7 +364,8 @@ export class VideoGenerationDriver extends PuterDriver {
apiKey: byteplusKey,
apiBaseUrl: (byteplusVideoCfg?.apiBaseUrl ??
byteplusSharedCfg?.apiBaseUrl) as
string | undefined,
| string
| undefined,
},
m,
);
+6 -5
View File
@@ -265,7 +265,7 @@ export class AppDriver extends PuterDriver {
try {
app = await this.appStore.create(fields, {
ownerUserId: actor.user.id,
appOwner: actor.app?.id ?? null,
appOwner: actor.effectiveApp?.id ?? null,
});
} catch (err) {
if (!isUniqueViolation(err)) throw err;
@@ -369,7 +369,7 @@ export class AppDriver extends PuterDriver {
for (const app of apps) {
if (
!app.protected ||
actor.app?.uid === app.uid ||
actor.effectiveApp?.uid === app.uid ||
actor.user?.id === app.owner_user_id
) {
localVisible.add(app);
@@ -836,7 +836,7 @@ export class AppDriver extends PuterDriver {
async #canReadApp(app, actor) {
if (!app.protected) return true;
// Self-app access
if (actor.app?.uid === app.uid) return true;
if (actor.effectiveApp?.uid === app.uid) return true;
// Owner access
if (actor.user?.id === app.owner_user_id) return true;
// Permission check
@@ -857,10 +857,11 @@ export class AppDriver extends PuterDriver {
async #checkWriteAccess(app, actor) {
// App actor matching app_owner
const ownApp = actor.effectiveApp;
let hasAccess = false;
if (!actor.app?.id) {
if (!ownApp?.id) {
hasAccess = actor.user?.id === app.owner_user_id;
} else if (actor.app.id === app.app_owner) {
} else if (ownApp.id === app.app_owner) {
hasAccess = actor.user?.id === app.owner_user_id;
}
// System-wide write
+11 -81
View File
@@ -36,96 +36,26 @@ import {
type DriverRequireSubscriptionConfig,
} from './meta';
/** Options for the `@Driver` class decorator. */
/**
* Options for the `@Driver` class decorator. Each policy takes `{ default?,
* methods? }`; a method covered by neither is ungated (rate limits fall through
* to the global driver default). See `DriverMeta` for semantics.
*/
export interface DriverOptions {
/**
* Unique name for this implementation within its interface. Defaults to the
* class name.
*/
/** Unique within the interface. Defaults to the class name. */
name?: string;
/** When true, this driver is the default for its interface. */
/** The default driver for its interface. */
default?: boolean;
/**
* Rate-limit policy. Each driver method can declare its own limit / window
* / storage backend; methods not listed inherit `default`, and undeclared
* methods fall through to the global driver default (600/min in
* `checkDriverRateLimit`).
*
* ```ts
* @Driver('puter-kvstore', {
* rateLimit: {
* default: { limit: 600, window: 60_000 },
* methods: {
* list: { limit: 60, window: 60_000, backend: 'kv' },
* set: { limit: 200, window: 60_000, backend: 'redis' },
* },
* },
* })
* ```
*/
rateLimit?: DriverRateLimitConfig;
/**
* Concurrent in-flight policy. Same envelope as `rateLimit` minus `window`.
* Adds `bySubscription` to scale the cap by subscription tier
* (`SubscriptionPolicy.id` from MeteringService).
*
* ```ts
* @Driver('puter-chat-completion', {
* concurrent: {
* default: { limit: 5, backend: 'redis' },
* methods: {
* complete: {
* limit: 5,
* bySubscription: { user_free: 1, unlimited: 50 },
* backend: 'redis',
* },
* },
* },
* })
* ```
*
* Methods that don't appear in either `default` or `methods` are unbounded
* — matching today's behaviour where nothing is gated.
*/
concurrent?: DriverConcurrentConfig;
/**
* When true, `/drivers/call` rejects bare account-session ("root") tokens
* for this driver — callers need an app/worker token or a dashboard-minted
* API token. See `DriverMeta.noUserSession`.
*/
/** Reject bare account-session tokens on `/drivers/call`. */
noUserSession?: boolean;
/**
* Subscriber-only methods. `true` accepts any non-free plan; an array of
* `SubscriptionPolicy.id`s accepts only those. Methods listed in neither
* `methods` nor `default` stay open to every plan — this is opt-in.
*
* ```ts
* @Driver('puter-video-generation', {
* requireSubscription: {
* methods: {
* generate: true,
* generateLong: ['business', 'pro'],
* },
* },
* })
* ```
* `true` accepts any non-free plan; an array of `SubscriptionPolicy.id`s
* only those.
*/
requireSubscription?: DriverRequireSubscriptionConfig;
/**
* Methods that need a trusted-enough account. Each entry names a tier; what
* the tier takes is deployment config (`reputationGate.tiers`), so a driver
* declares the standard it holds a method to and not the number. Methods
* listed in neither `methods` nor `default` ask for no floor — this is
* opt-in.
*
* ```ts
* @Driver('puter-chat-completion', {
* requireReputation: {
* methods: { complete: 'standard' },
* },
* })
* ```
*/
/** Reputation tier names; thresholds come from `reputationGate.tiers`. */
requireReputation?: DriverRequireReputationConfig;
}
+3 -3
View File
@@ -27,10 +27,10 @@ export const KV_CACHED_READ_RATE_SHARE = 0.1;
// Microcents per underlying DynamoDB capacity unit, as reported by
// SystemKVStore.KVUsage. Cost is `KV_COSTS[op] * usage.<op>`.
export const KV_COSTS = {
'kv:read': 17,
'kv:write': 90,
'kv:read': 50,
'kv:write': 250,
// 10% of `kv:read` — kept as a literal so the reported rate is exactly this
// and not a float artifact of the multiplication. The unit count is the one
// the equivalent uncached read consumed.
'kv:read:cached': 1.7,
'kv:read:cached': 5,
} as const;
+22 -80
View File
@@ -73,13 +73,8 @@ export const DRIVER_REQUIRE_REPUTATION_KEY =
// -- Driver rate-limit config ----------------------------------------
//
// A driver declares its rate-limit policy alongside its other metadata
// (`@Driver({ rateLimit: ... })` or an imperative `readonly rateLimit`
// field). `DriverController.#handleCall` resolves the spec for the
// requested method via `resolveDriverMethodRateLimit` and passes it to
// `checkDriverRateLimit`. Different driver methods can therefore use
// different storage backends and different limits — there's no longer a
// single boot-time backend that constrains everyone.
// Declared per driver; `DriverController` resolves the spec for the requested
// method, so methods can differ in limits and storage backend.
export const RATE_LIMIT_BACKEND_NAMES = ['memory', 'redis', 'kv'] as const;
export type RateLimitBackend = (typeof RATE_LIMIT_BACKEND_NAMES)[number];
@@ -89,36 +84,20 @@ export interface DriverRateLimitSpec {
limit: number;
/** Window length, in milliseconds. */
window: number;
/**
* Per-subscription overrides for `limit`. Keyed by `SubscriptionPolicy.id`
* (`user_free`, `temp_free`, `unlimited`, etc.). Falls back to `limit` when
* the actor's subscription isn't in the map. Same mechanic as
* `DriverConcurrentSpec.bySubscription`.
*/
/** Per-`SubscriptionPolicy.id` overrides for `limit`. */
bySubscription?: Record<string, number>;
/**
* Storage backend to count against. Omit to use the server-wide default
* configured by `config.rate_limit.backend`.
*/
/** Defaults to `config.rate_limit.backend`. */
backend?: RateLimitBackend;
}
export interface DriverRateLimitConfig {
/**
* Applied to any method not listed in `methods`. Lets a driver opt the
* whole interface into tighter limits than the global driver default
* without enumerating every method.
*/
/** Applied to any method not listed in `methods`. */
default?: DriverRateLimitSpec;
/** Per-method overrides. Keys are driver method names. */
methods?: Record<string, DriverRateLimitSpec>;
}
/**
* Validate a `rateLimit` block declared by a driver. Throws on bad shape so
* registration fails loudly at boot rather than silently misconfiguring
* production traffic. Returns the value unchanged on success for chaining.
*/
/** Validate a driver's `rateLimit` block; throws at boot on a bad shape. */
export function validateDriverRateLimit(
value: unknown,
label: string,
@@ -207,25 +186,13 @@ export function resolveDriverMethodRateLimit(
}
// -- Driver concurrent-limit config ----------------------------------
//
// Twin to `DriverRateLimitConfig` but for in-flight concurrency. Same
// shape minus `window`, plus `bySubscription` to vary the cap by the
// caller's subscription tier (resolved via `MeteringService`).
export interface DriverConcurrentSpec {
/** Maximum simultaneous in-flight requests. */
limit: number;
/**
* Per-subscription overrides keyed by `SubscriptionPolicy.id` (`user_free`,
* `temp_free`, `unlimited`, etc.). Falls back to `limit` when the actor's
* subscription isn't in the map.
*/
/** Per-`SubscriptionPolicy.id` overrides for `limit`. */
bySubscription?: Record<string, number>;
/**
* Storage backend. Memory is per-process (use only on single-node
* deployments); redis coordinates across nodes; kv is rarely the right
* choice for concurrency but supported for parity.
*/
/** `memory` is per-process; use `redis` on multi-node deployments. */
backend?: RateLimitBackend;
}
@@ -442,59 +409,34 @@ export function resolveDriverMethodRequireReputation(
}
/**
* Resolved metadata for a registered driver. Read from either decorator
* metadata or imperative instance properties.
* Resolved metadata for a registered driver, from decorator metadata or
* imperative instance properties. The gates live here rather than on a route
* because every driver shares the `/drivers/call` dispatch route.
*/
export interface DriverMeta {
/** The interface this driver implements (e.g. 'puter-chat-completion'). */
/** E.g. 'puter-chat-completion'. */
interfaceName: string;
/** Unique name within its interface (e.g. 'openai-completion', 'claude'). */
/** Unique within the interface. */
driverName: string;
/** When true, this driver is the default for its interface. */
isDefault: boolean;
/**
* Additional driver names that resolve to the same instance. Used by
* multi-provider drivers (TTS/OCR/image/video) so legacy puter-js calls
* that pass a provider id in the `driver` slot (e.g. `aws-polly`,
* `openai-tts`) still find the unified driver. The requested alias is
* exposed to the driver method via `Context.get('driverName')` so the
* method can route to the right internal provider.
* Other names resolving to this driver, so legacy calls naming a provider
* (`aws-polly`) still find the unified driver. The requested alias reaches
* the method via `Context.get('driverName')`.
*/
aliases: string[];
/**
* Rate-limit policy for this driver. Per-method specs override the
* `default` spec; both are optional. `DriverController` consults this
* before invoking the method and falls back to the global driver default
* (600/min) if nothing is declared.
*/
/** Falls back to the global driver default when absent. */
rateLimit?: DriverRateLimitConfig;
/**
* Concurrent in-flight policy for this driver. When set, the controller
* acquires a slot before invoking the method and releases in `finally`.
* Absent → no concurrency cap (current behaviour).
*/
/** No concurrency cap when absent. */
concurrent?: DriverConcurrentConfig;
/**
* When true, `/drivers/call` rejects bare account-session ("root") tokens
* for this driver: callers must present an app/worker token or a
* dashboard-minted API token. Per-driver counterpart of the `noUserSession`
* route option — the dispatch route is shared, so the flag lives on the
* driver.
*/
/** Reject bare account-session tokens. */
noUserSession?: boolean;
/**
* Which methods on this driver need a subscription, and which plans count.
* Per-method entries override `default`; a method covered by neither stays
* open to every plan. Per-driver for the same reason as `noUserSession` —
* the dispatch route is shared.
* Per-method plan requirement; a method covered by neither `default` nor
* `methods` is open.
*/
requireSubscription?: DriverRequireSubscriptionConfig;
/**
* Which methods on this driver need a reputation tier, and which tier.
* Per-method entries override `default`; a method covered by neither asks
* for no floor. Per-driver for the same reason as `requireSubscription` —
* the dispatch route is shared.
*/
/** Per-method reputation tier; same coverage rule as `requireSubscription`. */
requireReputation?: DriverRequireReputationConfig;
}
Loaded 100 of 368 files, more files were not shown because too many files have changed in this diff. Show more