Compare commits

...
Author SHA1 Message Date
Nariman JelvehandClaude Opus 5.5 3e11e7dc79 refactor: keep folder sort and layout in puter.kv, drop /set_sort_by and /set_layout (#4642)
Explorer saved each folder's sort and layout through POST /set_sort_by and
/set_layout, which wrote them onto the folder's fsentries row. stat only
returns `layout`, so a saved sort was never read back.

The GUI now keeps them in the user's key-value store, one key per folder
uid (`folder_view:<uid>` holding `{ sort_by, sort_order, layout }`), via
helpers/folderViewPrefs.js. A change to one preference keeps the others.
A folder with nothing stored falls back to the layout its fsentry still
carries, then to the defaults, so existing layouts carry over.

refresh_item_container reads the folder's view after stat and re-sorts
items that went in under the previous sort; a view for a folder the
window has left is ignored. update_window_path no longer applies sort and
layout itself, since the refresh it starts does.

With no callers left, DesktopController and its two routes are removed.
The fsentries columns and stat's `layout` field stay.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 21:34:37 -07:00
Nariman JelvehandClaude Opus 5.5 5b1b5b8a7a fix(puter-js): apps launched from desktop.puter.com take its messages (#4640)
In an app, puter.js trusted only https://puter.com as the GUI origin, so
every desktop message from desktop.puter.com failed the origin check and
was dropped. The desktop waits for the app's answer to windowWillClose,
so app windows there never closed; itemsOpened, drag-and-drop, focus,
theme and locale changes were lost the same way, and a reauth request
was posted to the wrong origin.

An app now takes its GUI origin from the launch URL's `puter.origin`,
but only when that is one of Puter's own desktops (exactly
https://puter.com or https://desktop.puter.com); anything else keeps
https://puter.com, so a page that frames an app still can't drive it.
An explicit PUTER_ORIGIN still wins, and a top-level page never reads it.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 20:51:33 -07:00
Daniel Salazar b2eb8ed895 feat(auth): a first-party page served on its own origin signs in with a session of its own (#4635)
A deployment can list origins of its own pages in
`first_party_session_origins`. When such a page asks the GUI to sign it in,
the sign-in hands it a GUI token on a `web` session of its own, parented to
the user's sign-in, instead of an app token.

The session is minted by POST /auth/first-party-session, which only the GUI's
own page script may call, and only as the user's sign-in itself: app tokens,
access tokens and a session minted this way are refused. It never gets a
session cookie, ends with the sign-in it was handed out under (revoking either
takes the tokens minted under the page along), is not counted as a sign-in,
and stays up with its sign-in when all other sessions are revoked.

The sessions list shows it under its sign-in, titled with the page's host and
badged as a site session.
2026-11-13 17:47:57 -07:00
Daniel Salazar d1cbfb9fb1 fix(puter-js): a godmode app opened in its own tab stays signed in across a reload (#4632)
A tab the desktop opened for an app boots the SDK as a top-level page, where
a session-type token was kept in memory only. Once the desktop hands such a
tab its launch token, the SDK now keeps it in sessionStorage, as a framed app
does, and a reload of that tab reads it back before any stored app token.

A page that wasn't handed a token this way still persists no session token.
2026-11-13 15:46:17 -07:00
Neal ShahandClaude Opus 5.5 ac44de819d fix(hosting): choose the site and subdomain from Host, not X-Forwarded-Host (#4459)
With trust proxy on, express derives req.hostname and req.subdomains from
X-Forwarded-Host, which any client can send, while host validation reads the
raw Host. A request could serve one hosted site's files on the main domain,
or reach the API on a hosting host. The header is now dropped before routing.

Fixes PUT-2272

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 14:56:16 -05:00
Daniel Salazar 4ad53899da feat(auth): debug app sessions that act as a user in one app (#4629)
Adds a `debug_app` session kind and AuthService.createDebugAppToken, which
mints an app-under-user token on its own session row instead of the user's
app session. The row has a hard expiry that activity never slides, its calls
record no user activity, it is hidden from the user's session list, and a
password reset revokes it. SessionStore.deleteExpiredDebugAppSessions purges
expired rows. Migrations add the kind to the sessions constraint on sqlite,
mysql and postgres.
2026-11-13 11:19:08 -07:00
Daniel Salazar c253b7e05e feat(auth): signups record the request's TLS fingerprint when a fronting proxy supplies one (#4622)
`tlsFingerprintHeader` names a request header that a TLS-terminating proxy
sets to the client's TLS fingerprint. When set, the value is exposed as
`req.tlsFingerprint`, stored on the new `user.signup_tls_fingerprint`
column, passed to `puter.signup.validate`, and forwarded to the phone
verification provider when it is a JA4.
2026-11-13 10:41:57 -07:00
647995133e fix(http): responses stay under Node's close-listener limit, starting with WebDAV GET (#4621)
* fix(webdav): GET pipes the file without stacking close listeners on the response

stream.pipeline put seven close listeners on the response. With metering,
compression, the concurrency gate and request tracing on top, every WebDAV
download crossed Node's ten-listener warning. GET now uses pipeToResponse,
like the other file download routes.

* fix(drivers): a streamed result stops its source on unpipe instead of its own close listener

pipe already unpipes when the response finishes, closes or fails, so the
extra close listener only spent part of the response's listener budget. The
routes that hold a response open now have a test that keeps each within that
budget.

* refactor(http): everything that waits on a response's close shares one listener

Egress metering, the concurrency gate, the large-body budget, route
lifecycle events, driver call slots, disconnect aborts and SSE writers each
put their own close listener on the response. They now register callbacks
with onResponseClose, which keeps one listener per response and runs them in
the order added, so a streamed response holds three close listeners instead
of five or six and request tracing has room for its own.

* test(webdav): handle streamed response errors in test sink

Co-authored-by: Salazareo <[email protected]>

* test(webdav): a storage failure mid-download is covered against a real response

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Salazareo <[email protected]>
2026-11-13 09:56:21 -07:00
Reynaldi Chernando 9d3a049988 document puter.host behavior (#4606)
* document puter.host behavior

* update
2026-11-13 19:13:59 +07:00
Juan Fernando CastroandDaniel Salazar 1c5909331c fix(fs): a path lookup of an old row returns its healed path while the replica catches up (#4612)
* fix(fs): a healed entry carries its written path, and an entry with no path answers 404

The lineage heal now returns the entry with the path it just wrote
instead of re-reading the row, so a replica that hasn't caught up can't
hand back the old NULL path. Entries that still have no path are kept
out of the cache, resolve to the usual 404 in the fs routes, and an ACL
check on a resource with no path refuses instead of throwing.

* fix(fs): heal paths along one parent chain, fill only empty paths, and return the healed row from the primary

The path lineage now picks the target row and walks up its own
parent_uid links, so each row in a healed path is the parent of the
next. The backfill only fills a NULL path and never replaces one that
is set. Both lineage routes return the row read back from the primary,
so every column is current. Path-less entries are kept out of the
cache by the cache writers themselves, and a missing or empty path is
unresolvable both in resolveOrNotFound and in ACLService.check.

* fix(fs): a path lineage resolves only to a row still at that path, and the backfill runs its updates concurrently

The lineage lookup now answers a miss when the row read back from the
primary sits at a different path than the one walked, and doesn't cache
it. The backfill runs its per-row updates through
runWithConcurrencyLimit and drops cached entries only for rows it
actually filled. ACLService.check refuses only a resource with no
string path, so an empty client path keeps its existing 400.

* fix(fs): build healed paths from the primary's ancestor chain, and invalidate the paths the heal wrote

Both lineage routes now read the ancestor chain from the primary and
write the path it spells, so a lagging replica's names never end up in
a path column. A path lookup whose replica walk no longer matches the
primary's chain is a miss and writes nothing. The backfill drops the
cached entries of the rows it filled in one call, keyed by the path it
wrote. The lazy-chain comparison now lets a missing path reach
ACLService.check on both sides.

* fix(fs): leave path-less uid and id lookups to resolveNode

resolveNode already answers a uid or id lookup whose entry has no path
as not found, and a path lookup always returns an entry with its path,
so resolveOrNotFound goes back to trusting what it resolves. The
readdir case for a broken parent chain is covered by the existing HTTP
test.

* fix(fs): the ancestor walk stops at a fixed depth and updates only rows with an empty path

The primary's ancestor walk now selects each row's path and hands only
the rows whose path is NULL to the backfill, so filled ancestors cost no
UPDATE. The walk stops after LINEAGE_MAX_DEPTH steps; a chain that has
not reached a row without a parent by then is treated as broken and the
row is left alone.

* fix(fs): a listing heals its path-less entries a bounded number at a time

* test(fs): a stored path the parent chain no longer spells heals from the primary, and a listing leaves a path-less entry uncached

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-11-12 22:24:37 -07:00
Juan Fernando CastroandDaniel Salazar 5d541cd8e2 fix(auth): a just-minted read URL or token is found while the replica catches up (#4556)
* fix(auth): a just-minted read URL or token is found while the replica catches up

* fix(auth): a token's grants aren't cached when it's revoked mid-mint

* fix(permissions): a mint succeeds when its grants cannot be read back to cache

The grants are already stored at that point, so a failed read-back drops
the cache key and the next read fills it.

* test(auth): assert the grant list cached at mint; skip the sqlite-copy replica test on postgres

* fix(auth): test that a token row is read from the primary only when both replica lookups miss

* fix(auth): one-line comments on the replica-then-primary token row lookup

* perf(auth): a mint caches the grants it stored without reading them back

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-11-12 22:22:42 -07:00
Juan Fernando CastroandDaniel Salazar fb526bfb7e perf(share): skip the ancestor read for owners with nothing shared in their own home (#4597)
* fix(share): look up a cross-owner move's origin by its former owner

The move listener now passes the event's fromUserId down to the origin
side of the fan-out, so the shares at the former location are looked up
under the owner they belong to, and re-marks the new owner as shared
before the fan-out runs instead of after it starts.

Neither changes what recipients hear today: the origin's ancestors
already carry the former owner, and the fan-out reads ancestors before
it consults the owner-shares cache. Both are needed once the fan-out
checks the entry's owner before reading ancestors. Tests cover a real
cross-owner move with a cold owner-shares cache, and a hand-over to an
owner with nothing shared.

* perf(share): skip the ancestor read when the entry's owner shares nothing

Every fs write, create, rename and move resolved the entry's ancestors
(a cache batch) before asking whether any owner on that chain had
anything shared. Ask about the entry's owner first, plus the former
owner for the origin side of a cross-owner move, and return no audience
without reading ancestors when none of them has a share. Otherwise the
lookup runs as before.

Self-contained so it can be reverted on its own. It assumes every entry
in a shared subtree carries the subtree owner's user id. Entries that
older writers created under someone else's shared folder with the
writer's own id would stop notifying that folder's recipients when the
writer has nothing shared, so this only holds once existing data has no
such mixed-owner subtrees.

* fix(share): read ancestors whenever an entry sits outside its owner's home

The early return in the share fan-out assumed every entry in a subtree
carries the subtree owner's id. Rows from older writers don't always:
an app writing into another user's AppData stamped itself as the owner,
so an entry in a shared folder can name an owner with nothing shared.

Skip the ancestor read only when the entry's owner has nothing shared
and the path sits in that owner's own home directory; anything under
another user's home takes the full lookup as before, whatever the data
holds. The owner's username comes from a per-process map (60s TTL,
10k entries) backed by the user store's cache, so a hit costs nothing
and a miss one user cache read, against the ancestor batch it replaces.

* refactor(share): drop the former-owner hint from the move fan-out

With the home-directory check, the origin side of a cross-owner move
sits under the former owner's home, not the entry's new owner's, so it
always takes the full ancestor lookup and finds the former owner's
shares through the chain. Passing fromUserId down and re-marking the new
owner before the fan-out no longer change anything, so both go back to
how they were. The test for a hand-over to an owner with nothing shared
stays; the slower duplicate that waited out the cache is removed.

* perf(share): keep the chain owner check after the ancestor read

The early return replaced the check that ran after the ancestor read,
so an entry outside its owner's home went on to the share-table lookup
even when nobody on its chain had anything shared. Ask about every owner
on the chain again once the ancestors are in hand, as before.

* fix(share): mark the new owner shared before a hand-over's fan-out

The fan-out now reads the owner-shares cache before it reads ancestors.
On a move into another user's home, a "nothing shared" answer cached
for the new owner shortly before the move made the destination side
skip the entry's re-pointed share, so its recipient was told the item
was removed rather than moved. Mark the new owner shared before the
fan-out starts whenever the move changed the owner.

The earlier "drop the former-owner hint" commit said this ordering no
longer mattered; it does for the destination side.

* perf(share): cache missing usernames and share in-flight lookups

The home check now uses isOwnHomePath with the owner's username. The
per-process username map also keeps a null answer for an owner with no
account or no username, and a burst of writes by one owner shares a
single user lookup, as the owner-shares lookups already do. A failed
lookup is not cached and reads as someone else's home, so the full
ancestor lookup runs. Tests cover an owner with no account, a home that
differs only in case and a renamed owner, each taking the full lookup.

* test(share): run the hand-over's revoke for real and assert the destination read

* perf(share): check the home path before asking whether the owner shares

An entry outside its owner's home goes straight to the full read, which asks about every owner on the chain together. The username cache is read once per call and the single-caller home helper is inlined.

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-11-12 22:22:26 -07:00
Thibaud-VdbandDaniel Salazar 1abd25eb31 ci: take release-please from its current home, scope three workflow tokens and lock the actions (#4521)
* ci: take release-please from its current home

google-github-actions/release-please-action is archived: the action
moved to googleapis/release-please-action, same code, same inputs,
same major version, and that is where the fixes now land. The
release-please workflow references the maintained repository.

* ci: declare what the three unscoped workflows may do with the token

Docs Build, Maintain Release Merge PR and Notify HeyPuter had no
permissions block, so they ran with the repository's default token
scope. Docs Build only checks out and builds, Notify HeyPuter only
calls the dispatch endpoint with its own token: both declare
contents: read. Maintain Release Merge PR pushes the
auto-update/release branch and opens the release pull request, so it
declares contents: write and pull-requests: write. The other
workflows already declared their scopes and are left as they are.

* ci: stop persisting the checkout credentials in the jobs that never push

actions/checkout keeps the job token in the clone's git config, where
every later step can read it. Twelve checkouts now set
persist-credentials: false: the backend and puter.js test jobs, the
coverage jobs, the type checks, the docs build, the AI provider tests
and the Docker image build read the clone and push nothing with git.
The two checkouts of Maintain Release Merge PR keep the token: that
job resets the release branch and pushes it through
create-pull-request.

* ci: lock the workflow actions to the commits they resolve to

GitHub's workflow dependency lockfile holds the commit each action
reference resolves to, transitive actions included, and the runner uses
those commits even if a tag moves later. .github/workflows/actions.lock
comes from gh actions-lock (v0.4.1), which also narrowed every reference
to the exact release it locked, so Dependabot keeps bumping versions and
its security alerts keep matching. Re-run the same command after adding
or bumping an action.

* ci: drop the unused token scope from Notify HeyPuter, match workflow indentation

* ci: remove the disabled release workflows

release-please and Maintain Release Merge PR have been disabled since 2024; releases are cut from calver tags. Their entries leave the lockfile with them.

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-11-13 03:59:06 +00:00
Daniel Salazar b5e4f20087 fix: KV size and credit edge cases, a metering double count, site name caching, /token-read content type (#4618)
* fix(fs): /token-read serves a file with no known extension as application/octet-stream

* fix(kv): a value too large once stored rejects with value_too_large, and a consistent read of several keys is consistent

* fix(kv): remove on an account with no credits left removes the paths and returns null

* fix(metering): a buffered increment whose base read fails is not also written through

* fix(hosting): site lookups by name share one cache entry for every spelling

* fix(metering): an increment whose base read fails is written through and taken back out of the buffer

* fix(hosting): create stores a site name trimmed and lowercased
2026-11-12 20:49:51 -07:00
Reynaldi Chernando bdb88507df throw error for get usage APIs (#4608) 2026-11-12 20:47:03 -07:00
Juan Fernando Castro 0694b76751 feat(email): sends name a stream, and streams add headers, a from address and message tags from config (#4520)
* feat(email): sends name a stream, and streams add headers, a from address and message tags from config

`email.streams` maps a stream name to optional headers, a from address and a
tags header. A send that names a configured stream gets that stream's
headers (caller headers win) and from address (when the caller gives none),
and its tags rendered as `name=value` pairs into the tags header. Unknown or
missing streams change nothing, and `streams` never reaches the transport.

Account mail is sent on `auth`, share and app feedback mail on `notify`, and
the signup confirmation on `signup` unless a `puter.signup.validate` listener
names another stream, which is remembered in the account metadata for
resends, deferred sends and save-account.

The signup response carries `confirmation_email_sent` when a confirmation
email was due at signup, and `/send-confirm-email` answers `{ sent }`.

PUT-2346

* fix(email): review fixes for stream naming

* fix(email): contact form mail names the notify stream, streams resolve from own config entries, the no-transport log shows the stream

Templated sends take only replyTo and stream; the header and tag options had no caller.

* fix(signup): a claimed row drops its earlier confirmation stream, and save-account reports a failed send under its own stage
2026-11-12 22:46:34 -05:00
Juan Fernando Castro e7ee778985 perf(notifications): notify and mark-ack skip their primary read-backs (#4547)
* perf(notifications): notify returns the row it wrote instead of reading it back

The service hands the driver the row the store built on insert, and a
group replacement builds the row it leaves from the one it looked up, so
neither path reads the row back from the primary.

* perf(notifications): mark-ack reads the row's fixed columns from a replica

The type, app uid and audience the dismissal event carries never change,
so they are read from a replica alongside the update, and from the
primary only when the replica has no row yet.

* test(notifications): cover a repeat dismissal and tidy notify docs

* fix(notifications): a repeat mark-ack returns without waiting for the replica read, and a failed replica read is logged

* refactor(notifications): NotificationRow spells out the row's columns, and a replacement passes the app uid it already has
2026-11-12 22:46:25 -05:00
Juan Fernando Castro c3ff8ca3b2 fix(permissions): an app opened on a file in a shared folder can read and save it (#4555)
* fix(permissions): an app opened on a file in a shared folder can read and save it

* perf(permissions): app scans check implied options before scanning the user

* test(permissions): cover app grants on folders and revoked shares

* fix(permissions): an app's folder grant counts only while its user holds that folder

The app branch of the ACL check counted a user-to-app grant on any folder above the file. It now counts a grant on the file itself, or on a folder above it that the user holds at the asked mode, so the grant and the user's own access meet on the same node.

* test(fs): the other-app case pins 404 beside the opened app's 200
2026-11-12 22:46:15 -05:00
Juan Fernando Castro 39d931306d fix(auth): sign-in relays a token set between waits (#4572)
* fix(auth): sign-in relays a token set between waits

* fix(auth): sign-in relay skips another app's stored token and survives a failed store

* fix(auth): sign-in relay always clears the stored copy and logs only the error message

* fix(auth): the sign-in relay keeps only signed app tokens, per app, and a subscribed wait answers without Redis

/login/set stores a token only when this server signed it for an app, under a
key that includes the app, so a later set for another app or an unsigned value
leaves a pending token in place. /login/wait takes the stored copy once and
falls back to listening when Redis cannot be read. A token that arrives by
event is delivered to every subscribed wait for that app; the stored copy is
then removed best-effort, and left in place when the wait's client has gone.
A relayed token for another app no longer ends the wait. The event payload is
back to { authtoken }.

* chore(events): event types file matches main again
2026-11-12 22:46:04 -05:00
Juan Fernando Castro 70e823c5af feat(email): notification emails ask listeners before each recipient (#4596)
* feat(email): notification emails ask listeners before each recipient

Share notification emails (account holders and invites) and the app
feedback email to the owner now emit `email.notify.validate` with
`{ to, kind, allow }` once per recipient address, next to the existing
unsubscribe and blocked-domain checks. A listener that sets `allow` to
false skips that recipient's email; the others are sent as before. The
invite check runs before its send budget is claimed and the feedback
check before the per-app cap slot, so a skipped address uses neither.
When no listener answers or the hook fails, the email is sent.

* refactor(email): type the notify kind as a template name, drop the dead catch

`emitAndWait` already catches a listener that throws, so the try/catch
around it never ran; a failed listener leaves `allow` true and the email
is sent. `kind` is now `EmailTemplateName`, so renaming a template breaks
the type check instead of the listener.

* fix(share): ask email.notify.validate when the digest is sent

A digest entry can wait out its window or a retried send, so the
listener is asked about the recipient right before the mail goes.
Invites are still asked once more before their budget is claimed.

* fix(email): any falsy allow on email.notify.validate skips the mail

Matches how email.validate reads its answer. The helper also takes the
event client as required, since every caller has one.

* feat(email): email.notify.validate carries every recipient of one kind of mail

The payload is { kind, recipients: [{ to, allow }] }. Share invites ask
once for all their addresses before any budget is claimed; the digest
send and the feedback mail ask with one entry. kind is narrowed to the
three templates that ask.
2026-11-12 22:45:56 -05:00
dependabot[bot] 1bc1953344 chore(deps): bump ws from 8.22.0 to 8.22.2 (#4567)
Bumps [ws](https://github.com/websockets/ws) from 8.22.0 to 8.22.2.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.22.0...8.22.2)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.22.1
  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-11-12 20:35:51 -07:00
dependabot[bot] 0f83bb9b56 chore(deps): bump nodemailer from 10.1.4 to 10.1.7 (#4478)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 10.1.4 to 10.1.7.
- [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/v10.1.4...v10.1.7)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 10.1.7
  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-11-12 20:15:05 -07:00
dependabot[bot] 1ae95d97e8 build(deps-dev): bump @babel/core from 7.29.7 to 7.29.13 (#4565)
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.29.7 to 7.29.13.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.13/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-version: 7.29.13
  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-11-12 20:13:34 -07:00
dependabot[bot] 31f8936da2 build(deps): bump undici from 7.30.1 to 7.30.2 (#4566)
Bumps [undici](https://github.com/nodejs/undici) from 7.30.1 to 7.30.2.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.30.1...v7.30.2)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.30.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-11-12 20:12:08 -07:00
dependabot[bot] aab559c9c1 chore(deps): bump zod from 4.4.3 to 4.6.7 (#4465)
Bumps [zod](https://github.com/colinhacks/zod) from 4.4.3 to 4.6.7.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.4.3...v4.6.7)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.6.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-12 20:08:45 -07:00
dependabot[bot] 7f1d46028a chore(deps): bump fastify from 5.12.5 to 5.12.7 (#4464)
Bumps [fastify](https://github.com/fastify/fastify) from 5.12.5 to 5.12.7.
- [Release notes](https://github.com/fastify/fastify/releases)
- [Commits](https://github.com/fastify/fastify/compare/v5.12.5...v5.12.7)

---
updated-dependencies:
- dependency-name: fastify
  dependency-version: 5.12.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-12 20:08:27 -07:00
dependabot[bot] 39d9482409 chore(deps): bump body-parser from 2.3.0 to 2.4.0 (#4463)
Bumps [body-parser](https://github.com/expressjs/body-parser) from 2.3.0 to 2.4.0.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/v2.3.0...v2.4.0)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 2.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-12 20:08:06 -07:00
Juan Fernando CastroandDaniel Salazar 4a958b3ca1 fix(gui): desktop push status counts only Puter's devices and follows subscription changes (#4550)
* fix(gui): desktop push status counts only Puter's devices and follows subscription changes

- fix(gui): desktop push status counts only Puter's own devices
- fix(gui): the desktop keeps its push subscription when the browser replaces it
- docs(gui): shorten push comments to one line
- fix(gui): removed browsers stay off after a subscription change, and websites can reach Puter push
- docs(puter-js): fit the desktop push request comment on one line
- fix(gui): background push registration checks every earlier endpoint and waits out a 429
- fix(puter-js): inside the desktop, requestPermission leaves a held push grant to the desktop
- fix(gui): a browser that stays off after being removed forgets its receipt
- fix(gui): push status holds right after enable(), and a subscription change is picked up after a run in flight
- fix(gui): an app's push grant counts whenever the app list can't be read

* fix(puter.js): a held push grant is left to the desktop only when it says it answers push

The desktop lists `push-permission` in `puter.gui_features` on the app
iframe's URL. Without it the SDK settles a held `notifications:push` from
its own check, so a desktop whose requestPermission handler has no held
check shows no dialog for a grant the app already holds.

* fix(gui): a held push grant isn't asked again beside other access, and never counts without an app uid

requestPermission drops a push grant held for Puter's devices from the
dialog and asks only for what is missing. The app list lookup answers no
for a missing app uid, whatever the list says; an app the list leaves out
still keeps its grant, since the list is capped and can trail a grant.

* test(puter.js): e2e for a push grant the app already holds inside the desktop

A held grant resolves true with no dialog, and a request for it beside
access the app is missing shows only the missing access.

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-11-12 20:06:23 -07:00
Juan Fernando Castro 4653ff6dae fix(gui): a removed browser stays off, and launches reuse the page's push status (#4549)
* fix(gui): a browser removed from push stays off until its user turns it on

The silent registration on load and app launch no longer replaces a
subscription the user removed in Puter. When the server answers
push_device_removed, the browser's subscription and its receipt are
dropped and the browser is recorded as turned off, so later loads leave
it alone. Turning push on from Settings or a push dialog still replaces
the removed subscription.

* perf(gui): launches reuse the page's last push status

The built-in push client keeps the last registered status it read in
memory, for the same user and subscription. App launches and an app's
puter.notifications.status() answer from it instead of listing devices
again. It is dropped on enable(), disable(), an unregistered answer and
the worker's subscription-changed message, and the Notifications tab
and push dialogs still read fresh, so a new page load always asks the
server.

* fix(notifications): a background enable opens no sign-in, and a page keeps its last push on for five minutes

_enableWebPush takes { background } in place of { renewRemoved }: a call
without a click leaves a removed browser removed and sends its requests
without interactive sign-in.

The desktop's push client keeps the on that enable() read, so the next
cached status lists no devices, and asks the server again once that on is
five minutes old.

* fix(gui): the kept push on belongs to the user whose devices were listed, and a failed enable() keeps none

A status read or enable() that another user's sign-in overlapped keeps no
on, and an enable() that fails drops what a read begun meanwhile kept.
2026-11-12 20:06:23 -07:00
Juan Fernando Castro a5c7b427b8 fix(puter-js): a confirmed push subscription registers for another account without a new confirmation (#4548)
* fix(puter-js): a confirmed push subscription registers for another account without a new confirmation

The server's verify and active subscribe answers can carry a receipt for
the subscription. It is kept per worker scope and sent with the next
announce of the same endpoint, so the Puter desktop registers the same
browser for the next signed-in account without another confirmation push.
The receipt is forgotten when the subscription is ended, replaced or
removed, and when the app is turned off in Puter. A server without
receipts behaves as before.

* docs(puter-js): shorten a push helper comment

* fix(notifications): keep receipts only in the Puter GUI and store the new one as soon as the server answers
2026-11-12 20:06:22 -07:00
Daniel Salazar cd3600bae9 fix(fs): overwrite a file in place when a write spells its name in another case or normalization form (#4620)
* fix(fs): overwrite a file in place when a write spells its name in another case or normalization form

* docs(fs): say a write needs overwrite off to get a new name beside a look-alike one
2026-11-12 20:02:39 -07:00
Juan Fernando Castro 2db55bd636 perf(share): read share owners from the entry owner column and backfill missing ones (#4598)
* perf(share): skip the legacy owner probe once a recorded share answers

ownerHasShares ran its recorded-owner and legacy NULL-owner queries side
by side, so the join to fsentries ran even when the first query already
found a share. Run the legacy half only when the recorded half finds
nothing; the answers stay the same.

* perf(share): drop the NULL-owner fallbacks from owner and outbound reads

ownerHasShares, listOutbound, countOutbound, the outbound apps summary
and the team-row filter behind listByHolder/countByHolder each carried a
second half for share rows written before entry_owner_user_id existed,
matched by joining fsentries on a NULL owner. The column migration
backfilled those rows from fsentries, so the joins only add work: each
costs about min(owner's files, NULL-owner rows) per call.

Every remaining read goes through entry_owner_user_id alone. Invite rows
(no fsentry, no owner) are matched as before: they were never reachable
through the joins.

Self-contained so it can be reverted on its own. It assumes existing
data has no share row that names an fsentry but has a NULL
entry_owner_user_id; if any remain, they stop showing up in these reads.

* fix(share): backfill the entry owner on share rows written without it

The share reads now match on entry_owner_user_id alone. Add a migration
for each engine that fills that column from fsentries on every row that
names an entry but still has a NULL owner, so the backfill runs before
the reads stop looking at NULL-owner rows and their answers don't depend
on what existing data holds. Rows that name no entry (invites) keep a
NULL owner, and a replay finds nothing to fill.

* fix(share): record the entry owner when a claim names its entry

applyPending can point a claimed invite at an entry, but left
entry_owner_user_id NULL, so the row was missing from the owner's reads
that now match on that column alone. Fill it from fsentries with the
same subquery the upserts use. The share service claims without an
entry today; the parameter stays for callers that pass one.

* refactor(share): read the app from the data column directly

Every caller of the app filter and the app expression passes the share
row's own data column now, so the column argument goes. Also names the
owner probe test after what it checks and puts back an unrelated line
the earlier formatting pass had reflowed.

* chore(share): drop the redundant MySQL and Postgres owner backfills

MySQL and Postgres replay every migration file on boot, and mysql_mig_47
and postgres_mig_36 already end with the same owner backfill, so they
re-run it on every boot. Only SQLite, which tracks a schema version and
runs each file once, needs its own backfill migration (0103).

* fix(share): list a team row for its members when no entry owner is recorded

* refactor(share): claiming an invite leaves its entry and owner as written

* test(db): run the share owner backfill against the full prior schema

* fix(share): ship the owner backfill as its own MySQL and Postgres migration

The backfill at the end of mysql_mig_47 and postgres_mig_36 replays on boot
only when database.migrationPaths is set. A new file gives installs that apply
migrations another way a step to run; a replay finds nothing to fill.
2026-11-12 18:06:29 -07:00
Daniel Salazar 36e2d199c8 fix(fs): refuse instead of replacing an entry whose name differs only by an emoji or accent (#4589)
* fix(fs): refuse instead of replacing an entry whose name differs only by an emoji or accent

MySQL compares fsentries names and paths under collations that also fold
accents and most emoji, so a lookup for one name could return a sibling
with another, and writes then overwrote and renamed it.

Path lookups now return only an entry whose stored path matches the
requested one after NFC normalization and lower-casing; case-only and
NFC/NFD spellings still resolve as before. Name-collision checks opt in
to the folded match and treat it as taken by a different entry: write,
mkdir, touch, rename, shortcut, move and copy dedupe when asked and
otherwise answer 409, and never replace or return that entry.

* fix(fs): leave a sibling's subtree alone when a folder its path folds onto is renamed or moved

The subtree rewrite matches descendants with `path LIKE 'old/%'`, and LIKE
compares under the path collation (ASCII case on SQLite). With a sibling
folder whose path differs only that way, renaming or moving one folder also
rewrote the paths below the other.

When another row sits at such a path, the rewrite now follows parent links
down from the moved folder instead. Without one, the rewrite is unchanged,
after one indexed lookup.

* fix(fs): resolve an entry whose stored path is spelled unlike its names, and heal that path on the first read

Older rows can store a `path` whose spelling differs from the names on their
parent chain in ways the path collation ignores. A lookup that finds such a row
by its real spelling now confirms it through the parent chain, writes the path
from the stored names, and returns it, so overwriting, touching or copying over
it by name works and the next read takes the single indexed query again.

The lineage walk now writes a row's path only when it differs byte for byte
from the one its names give, and drops only those rows' cache keys, so a walk
over rows that are already right issues no writes. Whether an entry found at a
name is a different, folded name is decided by its own name and its path after
that walk, not by a stale path column. With `foldedNames`, a row only the path
collation matches still counts as holding the name.

* fix(fs): match entry names under full case mapping

Names are compared after upper- then lower-casing, with a dotted capital I
taken as I, so a request in another case finds the stored entry for Greek
final sigma (`οδος.txt` for `ΟΔΟΣ.txt`), German sharp s (`STRASSE.txt` for
`Straße.txt`) and Turkish dotted I (`istanbul.txt` for `İstanbul.txt`).
Accent, emoji, kana and width differences still make two names different.

* fix(fs): name the existing entry when a folded name is already taken, and show why a confirmed replace failed

`item_with_same_name_exists` from write, move and copy now carries the
existing entry's own name in `entry_name` when it differs from the requested
one only by accents or emoji, so the prompt names the file the folder shows.

In the GUI, a replace confirmed from that prompt that still fails is reported
with its error message and counts as not written, instead of rejecting out of
`handle_same_name_exists` and leaving the caller waiting.

* fix(webdav): answer MKCOL with 405 and COPY or MOVE with Overwrite: F with 412 on a folded name

The existence checks before MKCOL, COPY and MOVE now see an entry whose name
differs from the target only by accents or emoji, so those requests answer as
for an existing target instead of reaching the filesystem and coming back as
409.

* docs(fs): list the errors write, move and copy return when a name is taken

`puter.fs.write()` gets an Errors section with `item_with_same_name_exists`,
`cannot_overwrite_a_directory` and `conflict`, the last for a name that differs
from an existing one only by accents or emoji. The move and copy pages say how
they reject in that case.

* fix(fs): walk the parent chain on the primary when a path lookup reads hard

A lookup that asks for a try-hard read (the checks after a create or move hits
the unique key) now also runs its parent-chain walk on the primary, and falls
back to the primary for the entry it finds. Before, the walk read a replica, so
a name the replica had not seen yet came back as missing and the mkdir or move
surfaced the duplicate-key error as a 500 instead of a 409.

Lookups without a try-hard read are unchanged. A try-hard lookup issues the
same number of queries; its walk reads the primary instead of a replica.

* fix(fs): treat a folder only the collation matches as missing instead of naming a file in it

A name-collision lookup lets only the last segment of the path match a
differently spelled name. When a folder above it matches only under the
collation (`😁dir` for `😀dir`), the lookup reports no occupant, so a write,
mkdir or touch there fails as for a missing folder: 404, or with
`createMissingParents` a 409 that names the folder. Before, it reported the
file inside the other folder and answered 409 naming that file. A folder whose
stored path is spelled that way still counts as the same folder, so lookups
built from stored paths find what they did before.

A rename onto a name the unique key already holds now answers 409 when the
lookup before it missed the occupant, instead of a 500.

* fix(fs): name the existing entry when a rename's name is folded, and leave a copy onto itself out of the copy docs

A rename refused because the folder holds a name that differs only by accents
or emoji now names that entry in its 409, as write, move and copy do.

A copy onto a folded spelling of the source's own name answers as a copy onto
itself (400 with `overwrite`), so the copy page's paragraph on folded names
now covers only items other than the source.

* fix(fs): walk the parent chain on the primary only when a hard read found another spelling of the path
2026-11-12 17:26:22 -07:00
Daniel Salazar 8616b00f7b fix(gui): save a URL from showSaveFilePicker, and always answer the app when the save dialog closes (#4587)
* fix(gui): save a URL from showSaveFilePicker and always answer the picker

The save picker downloads an http(s) URL itself, without cookies or a
referrer, up to 100 MiB, and writes it where the user chose, in apps and on
websites. A failed download shows an error. In apps, a save dialog that
closes without saving (failed download, failed write, close button) answers
fileSaveCancelled once instead of leaving the call pending.

* fix(gui): answer the save picker with the save's real outcome when it closes mid-save

Closing the save dialog while a URL is downloading stops the download,
writes nothing and answers fileSaveCancelled, in apps and on websites. In
apps, closing it once a write has started leaves the answer to the write:
fileSaved if it lands, fileSaveCancelled if it fails, never both. A save
that throws shows an error and answers fileSaveCancelled instead of leaving
the dialog stuck, and a second Enter during a save is ignored.

On an https desktop only https URLs are accepted, with a message saying so,
since the browser blocks http downloads there.

* fix(gui): retry signing a saved file once, and say so when the app can't get it

The app save picker signs the written file for the app with one retry. If
both tries fail, the user is told the file was saved but couldn't be passed
back, and the app gets fileSaveCancelled. The picker's one-answer state
(answered, closed, saving, writing) lives in helpers/savePickerAnswer.js so
the website picker can share it.

* fix(gui): answer the website save picker once, with the save's real outcome

The website save picker runs one save at a time, so a second Enter is
ignored. Closing it while a URL downloads stops the download, writes nothing
and answers fileSaveCancelled; closing it once the write has started leaves
the answer to the write, so the page hears fileSaved or fileSaveCancelled,
never both. A failed download shows an error, answers fileSaveCancelled and
closes the picker, as in apps, and so does a save that throws. A saved file
that can't be signed is reported to the user and answered as cancelled.

* fix(gui): show apostrophes in the save picker's failure messages
2026-11-12 17:26:05 -07:00
Daniel Salazar 873303a7c4 fix(events): offer a queued delivery only to connections that run its handler (#4590)
* fix(events): offer a queued delivery only to connections that run its handler

A queue subscription with an events worker was sent to every connection of
the app. A connection without the handler declined it at once, which moved it
to the worker while the tab with the handler was still running it.

puter.js now sends `events.handle` with the persistent subscriptions a
connection runs a handler for, on connect and whenever that set changes. The
server puts the connection in a room for each queue row it holds, and offers
the row's deliveries only to that room. With nobody in it anywhere in the
region, the delivery goes to the worker at once. A peer region handed such a
delivery declines it straight back when none of its connections run the
handler. Rows without a worker are still sent to the whole app room.

* fix(events): probe the handler room only when the app has a connection in the region

A queued delivery for a pair with no connection here goes to the worker on
the same local lookup and presence read as before, with no request to the
other nodes.

* fix(events): probe a forwarded batch's handler rooms together, within one timeout

A region receiving a batch now looks up every handler room its queued
deliveries need before it sends any of them: each distinct room once, up to
16 at a time, all under one shared 500 ms deadline. A room not answered by
then counts as empty, so its delivery is declined home with `no_handler` and
never sent. Deliveries still go out in the batch's order, and a batch with no
such delivery probes nothing.

* fix(events): resend a handler announce refused for the call budget

puter.js now sends `events.handle` with an ack and a 10 second timeout. When
the server refuses it with `too_many_requests`, the client sends the current
set again after 10 seconds, until one goes through or the connection is
replaced. No answer, as from a server without the verb, and any other refusal
are left alone. A connection that never had a persistent handler sends no
announce; one that had sends an empty set when the last is removed.

The rate limits page now says that announces count toward the
acknowledgement budget.

* docs(events): say when a client starts getting first try at queued events

A client counts once the server has registered its handler, shortly after it
connects or calls `onPersistent()`. Before then, an event for a subscription
with an events worker goes to another client with the handler or to the
worker.

* fix(socket): settle a cluster room count on the first node that holds a socket

A count ask now hears each node's answer as it arrives: the first node holding
a socket answers it at once, every node saying none answers it once all have
said so, and a node that never answers only decides it when no answering node
holds one. The request sent is unchanged, so every node answers it as before.

* fix(events): look up handler connections in the region's record, not every node

Each connection that announces a handler is kept in a per-subscription
sorted set in the region's Redis, scored by renewal: added after it joins
the handler room, removed before it leaves or on disconnect, renewed every
minute while it runs one, and counted only for three minutes after its last
renewal. Handing out a queued delivery, and receiving a forwarded one, now
check this node's sockets and then that set with one read, instead of
asking every node in the region to count the room.

A connection the set names but that is gone gets the delivery sent to an
empty room, and the socket lease hands it to the worker as before.

* fix(events): try the next region with the app open after one declines for want of a handler

A queued delivery's region is picked by app presence, so the first region
tried can have the app open without the handler. Its no_handler decline now
hands the delivery to the next region with the app open that hasn't had it,
and the worker runs it once none is left. A lease that lapses still goes to
the worker.

A decline now counts only when it comes from the region the current attempt
went to (this region for a local attempt). The region goes into the entry in
the same write as the attempt's lease, so a second decline from a region
already tried can't take the next region's lease. An attempt made after a
decline stamps its lease in its own field, which a server that doesn't make
such attempts never reads, so it can't take that lease back either.
2026-11-12 17:25:54 -07:00
Juan Fernando CastroandDaniel Salazar 11107f3108 fix(permissions,share): revoked team shares and removed links aren't read back from a lagging replica (#4554)
* fix(permissions): a revoked group grant isn't read back from a lagging replica

* fix(share): links removed by trash or a move aren't read back from a lagging replica, in any region

* test(permissions): start each test server in the file on an empty redis mock

* fix(permissions): node deletes find just-made grants on the primary; add a group membership hook

* fix(share): link revokes reach peer regions even when the local marker fails, and subtree retires read the primary

* fix(permissions): read a group's members after the grant is deleted

* fix(permissions): mark group members before a group grant is deleted

The member read and the marker now come before the delete, so a failed
member read leaves the grant in place for a retry and member reads are
already on the primary when the row goes. Members are read again after
the delete, best effort, to include someone who joined meanwhile.

The node-delete path returns those members, so ShareService bumps the
same list instead of reading each group again from a replica.
readUserGroupPerms checks the marker without the unused base-key GET.

* perf(share): trash and delete read grants from a replica unless the owner shared just now

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-11-12 17:58:05 -05:00
Juan Fernando CastroandDaniel Salazar 2c3b69011f fix(puter-js): unshare lists refused pairs in failed and rejects when all were refused (#4557)
* fix(puter-js): unshare rejects when every revoke was refused

/share/revoke reports refused pairs in a 200 body, and unshare() only
read `revoked`, so a call where every pair was refused resolved as if it
had worked. It now rejects with the first pair's { message, code }, the
same way share() does; a call where some pairs went through still
resolves with { revoked }.

GUI tests drive the SDK's unshare against a refused body and check that
the share dialogs and Remove from Shared show the error and keep the
row. The unshare docs list the per-pair codes.

* fix(puter-js): unshare and share reject with the first refused pair that names a code

A pair the server could not complete carries no code and a generic message,
so it no longer comes ahead of a refusal that says what happened. Both
operations pick the error through one helper. The unshare docs describe which
pair's error is surfaced and the request-level rejections.

* fix(gui): share modal says how many items a person was removed from when some were refused

After removing someone from several items, the refreshed listing is compared
with the items the removal covered, and the status reports the count that
went through instead of all of them.

* test(gui): unshare fixture swaps XMLHttpRequest per call; alert mock cleared in hooks

* feat(puter-js): unshare resolves with the refused pairs; share is as it was

unshare resolves { revoked, failed }, with one failed entry per refused
recipient/item pair. A pair refused because the recipient or the item is gone
counts as nothing to withdraw: the call rejects only when every pair was
refused and at least one for another reason, with the first such refusal that
names a code. paths and recipients are typed and documented.

share() picks its rejection as before; the error choice lives in unshare.

* fix(gui): sharing dialogs report the items an unshare was refused for

The share modal counts refused items from the unshare result across every
run of a large selection, going on past a run refused whole, for removing a
person and for closing the link on several items. The share window and
Remove from Shared show the refusal when the call resolves with the pair
refused.

* fix(gui): sharing dialogs keep the unshare outcome on screen after the listing refresh

The share modal shows the partial count once its refresh has settled, for
removing a person and for closing the link, so a listing that fails for the
refused item does not replace it. The share window does the same when closing
the link.

* feat(puter-js): an unshare rejected for refused pairs lists them in failed; failed[].path is documented as the absolute path

* test(puter-js): API suite covers unshare's refused pairs

* fix(gui): share modal stops at a revoke request that failed and counts only refused items

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-11-12 17:57:59 -05:00
Juan Fernando CastroandDaniel Salazar eefc1f2f2e fix(auth): the session list leaves out expired sessions, and revoking one works (#4573)
* fix(auth): the session list leaves out expired sessions

* test(auth): cover revoking expired sessions and their children

* fix(auth): the session list follows what authentication accepts

A token minted under a session that has expired is left out of the
list along with that session. The check runs in listSessions with the
rule authentication uses, so getByUserId returns expired rows again
and the revoke callers need no option.

* fix(auth): revoke-session answers 404 for another user's session

Missing, already revoked and other users' rows now share one answer.

* fix(auth): type the session list rows instead of casting them

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-11-12 17:57:51 -05:00
Juan Fernando CastroandDaniel Salazar 7e093d2ba7 perf(puter-js): reading the user at load joins the boot lookup (#4574)
* perf(puter-js): reading the user at load joins the boot lookup

* fix(puter-js): keep the token fallback to the user lookup resend

The fallback bearer is opt-in on initXhr, so other requests and their
replays send no bearer after sign-out, as before. A failed join (the
lookup or the copy) falls back to sending the request. Adds tests for
the legacy callback form and a resend after a network failure.

* fix(puter-js): join the boot user lookup only at the same API origin

A user lookup sent after setAPIOrigin() joined the boot /whoami still in
flight for the old origin and resolved its user instead of asking the new
one. The in-flight marker now records the origin too.

* fix(puter-js): a joined user read signs in from the boot lookup's answer and leaves on its first retry

- The boot lookup keeps one in-flight record (token, origin, user, refusal), made and cleared in cacheWhoami_.
- getUser(), puter.getUser() and whoami() share one join. A refused boot lookup drives the sign-in from its own response, so the token is not sent again; a sign-out meanwhile fails with 401 and sends nothing.
- The join ends when the boot lookup first has to retry, so the caller's own request goes out at once (new fetchUrl onRetry option).
- The joined user is copied with a JSON round trip; initXhr is back to its previous signature.

* fix(puter-js): whoami() keeps its refused-request error when it joined the boot lookup

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-11-12 17:56:42 -05:00
velzie bd7614372a fix(peer): grant forgery bug and stale comment 2026-11-12 17:48:10 -05:00
Daniel Salazar 61b5aa4900 fix(fs): treat path-less entries found by uid or id as missing (#4611)
A uid or id lookup can return a legacy row with no path when its parent
chain is broken. resolveNode now reports it as not found, so readdir,
stat and the other uid-addressed routes answer 404 instead of crashing
in the ACL check.
2026-11-12 15:44:14 -07:00
Wei-Lun Li a6e75b46cb fix(install): turn off plan gates in the Windows installer config (#4599)
install.sh writes "meteringEnforcement": { "subscriptions": false } into
config.json because a self-hosted install has no paid plans, so with the
gates on the OpenAI/Anthropic-compatible AI endpoints answer 402
subscription_required for every account. install.ps1 never got that
key, so a Windows install made with the README one-liner still has the
gates on. Write the same key from install.ps1.
2026-11-12 22:43:22 +00:00
Daniel Salazar dd32c7f9b2 fix(auth): a token whose lookup fails gets the outage error, not token_auth_failed (#4586)
The auth probe used to mark a token as failed whenever authenticate threw,
so a database error while checking a valid token came back as 401
token_auth_failed, which clients read as "discard this token".

The probe now records the thrown error on the request instead. Routes
that require auth hand it to the error handler (503 db_unavailable with
Retry-After for a database outage, a generic 500 otherwise); routes that
don't require auth serve the request anonymously, as before. Only a token
that authenticate reports invalid still gets 401 token_auth_failed.

The periodic socket re-check likewise keeps a connection up when its
lookup fails, instead of disconnecting it.
2026-11-12 15:39:51 -07:00
Nariman Jelveh aaa5c82236 Redirect /show/* to the dashboard and remove leftover desktop code (#4610)
* Redirect /show/* to the dashboard's Files tab

/show/<path> redirects to /dashboard#files/<path> with its query intact,
instead of booting the desktop and opening Explorer there.

Also removes what only that route used: the shell's launch options, the
launch_options GUI param, and the GUI service that ran them.

* Remove the unused component window and Flexer

Nothing opens UIComponentWindow or uses Flexer, and JustHTML was only the
component window's default body. Also drops the attach() shim UIQRCode kept
for component containers.

* Remove translation keys left by removed features

Drops 36 keys nothing references in every language: the keyboard shortcuts
guide, desktop shortcuts, personalization, the toolbar's fullscreen, GitHub
and refer entries, the referral strings, and the publishing option prompt.

* Inline the toolbar height, which is always zero

The toolbar is gone, so window.toolbar_height only added or subtracted zero
in window sizing and placement.

* Remove window edge snapping

Dragging a window to a screen edge or corner no longer snaps it to half or a
quarter of the screen. Double-clicking the head and the scale button still
maximize.

* Remove the window color theme service

Nothing can set a window theme, so the GUI stops reading the saved theme
file on boot and windows keep the default colors. puter.ui.on() still
accepts themeChanged, which is never sent; its docs entry is removed.

* Remove styles and icons nothing references

Drops the stylesheet rules of the removed Settings window, launch menu,
intro menu and a few other removed features, and 16 icons whose names
appear nowhere in the GUI.

* Remove more translation keys nothing references

Drops 56 keys in every language that no GUI code, extension or backend
message uses, such as the system information labels, the old 2FA setup and
confirm-code steps, and the auth token copy dialog strings.

* Remove the global menubar leftovers and more unused styles

Nothing creates a global menubar, so the window code stops hiding, showing
and removing one. Also drops stylesheet rules for classes nothing uses,
including the old auth dialog, the responsive hidden-* and visible-*
helpers, and two keyframes only those rules animated.
2026-11-12 15:31:38 -07:00
Daniel Salazar 8b68c103c5 fix(auth): mint under a full-access token without re-reading its session row, and look token rows up by uid through their indexes (#4588)
* fix(auth): mint under a full-access token from the row auth loaded, and look up token rows by uid with one index per column

A token minted by a full-access token now parents to the session row that
authentication already loaded and checked for the minting token, instead of
reading it again by token uid. The same applies to the session a relay token
is bound to. Tokens whose claims name no row of their own still look it up.

findActiveByAccessTokenUid now queries access_token_uid first and the legacy
uid only on a miss, each through its own index, instead of one OR across both
columns.

* fix(auth): send both access-token row lookups together, so a miss on the first costs no second round trip
2026-11-12 15:17:45 -07:00
Daniel Salazar 454f94fc54 fix(hosting): serve a launched public app's assets from its billing cookie without re-minting it (#4585)
Same-origin assets of a launched public app carry the launch token in the
Referer. When that token's signature verifies and it names the same user
and session as a valid public billing cookie (and, for an app token, the
same app), the cookie identity is used directly: no re-authentication, no
new cookie, no Set-Cookie. Any other combination resolves exactly as
before. Responses that do set the public cookie now carry
`Cache-Control: private`.
2026-11-12 14:28:35 -07:00
Daniel Salazar 8b7d380de1 fix(metering): a settle whose balance read fails still charges the allowance (#4584)
When the purchased-credit read or the near-allowance exact read fails while
settling a metered call, the call is now settled against the month record it
already has: the share that fits the monthly allowance is charged, and
purchased credit is left untouched since its balance could not be read.
Previously the whole settle was skipped, leaving the cost in the month total
but in neither pool.

Such settles skip the cached-balance update and overuse check, and raise one
info alarm id with the amount credit would have covered.
2026-11-12 14:27:29 -07:00
Daniel Salazar 8052a4287e fix(auth): close a revoked access token's sockets on every node (#4583)
Access-token revocation is announced as outer.auth.access-token.revoked,
so peer clusters evict the token's socket.io connections and realtime
relay sessions as the revoking cluster does. Peer payloads without a
string token_uid are ignored.
2026-11-12 14:27:10 -07:00
Daniel Salazar f114b13306 perf(gui): boot keeps a same-session token and skips /get-gui-token when the page already answers it (#4591)
* perf(gui): keep the stored token when /get-gui-token returns one for the same session

/get-gui-token signs a new string on every call, so every signed-in boot
stored a token that differed from the one it held only in `iat` and passed
it to puter.setAuthToken. That opened a second FS socket, re-sent /whoami
and the cache timestamp, and every other open tab picked the new token up
from storage and did the same.

The boot now keeps the stored token when the returned one carries exactly
the same claims apart from `iat`; any other difference replaces it as
before. A kept token still starts the boot from an empty FS cache, as the
swap did. A boot that keeps the persisted cache (no session cookie) now
waits for the cache timestamp check before the first render, which it
started before reading the user.

* perf(gui): skip /get-gui-token at boot when the page already says what it would return

Every signed-in boot waited on /get-gui-token before it read the user. The
shell is rendered from the same session cookie that route reads, so it now
carries `guiTokenDigest` in gui_params: a digest of the claims (minus
`iat`) of the GUI token the cookie's session would get, or null when the
request has no session cookie. A cookie that doesn't resolve to a user
session leaves it out.

The GUI skips the request when the digest matches its stored token (same
session, user and token type), or when it is null (the route would refuse).
It still asks when the parameter is missing, the digest names another
session, nothing is stored, the page and the route are on different
origins, or Navigation Timing doesn't show this page as freshly served for
this load (a history navigation or restored tab can reuse a page rendered
for an older cookie).
2026-11-12 14:26:09 -07:00
Nariman Jelveh ce70728eeb Remove the desktop's icons and right-click menu, and redirect /desktop (#4609)
* Remove the desktop's icons and right-click menu

The desktop no longer lists the Desktop folder's items, and right-clicking
its background opens no menu. It stays as the surface windows open on.

Also removes what only the icons used: file drops and item drops on the
desktop, rubber-band selection, auto arrange and saved icon positions, and
their two keys in puter.js's GUI boot cache. Clicking the desktop clears the
active item container so keyboard shortcuts stop acting on a window's items.

* Redirect /desktop and /settings to the dashboard

/desktop redirects to the root with its query intact, and /settings to the
dashboard's Home tab. /desktop drops out of the OIDC return_to allowlist,
sign-out returns to the root, and the auth cover page's logo links there.

Also removes eight GUI globals nothing references.

* Remove code paths that only ran for the desktop's item container

The desktop is no longer an item container, so the Trash item that
refresh_item_container added to it, the empty-folder message exemption,
and the context menu parent check for it can't be reached.
2026-11-12 14:00:29 -07:00
Nariman Jelveh 8518023b0b Remove more desktop-only features and entry points (#4607)
* Remove the desktop's Publish Website window

The right-click menu on desktop folders and on an Explorer window's
background no longer has a Publish as Website item. This removes
UIWindowPublishWebsite, the publishAsWebsite helper that opened it, both
menu items, the window's CSS, window.extractProtocol (only the window used
it), and the dir_published_as_website and pick_name_for_website i18n keys.

The dashboard keeps publishing websites through its own dialog.
generateFileContextMenu no longer falls back to the removed window, so it
offers Publish as Website only when the caller passes onPublishWebsite,
which the dashboard's Files tab does. The website badge on desktop items
and the backend hosting API are unchanged.

* Remove the desktop's Hide Desktop Icons toggle

The desktop's right-click menu no longer has a Hide/Show Desktop Icons
item. Without the wallpaper, hiding the icons left an empty page. This
removes the menu item, toggleDesktopIcons/hideDesktopIcons/
showDesktopIcons, the boot-time read of the saved setting, and the
.desktop-icons-hidden CSS rule.

puter.js's GUI boot cache no longer batch-fetches desktop_icons_hidden,
menubar_style or user_preferences.clock_visible, which nothing reads
anymore. Values already saved under those keys stay in KV, unread.

Also drops the desktop's BroadcastChannel, whose message handler was
empty and which nothing else referenced.

* Remove the desktop's ?download= link handler

Opening puter.com/?download=<url> or /desktop?download=<url> no longer
asks to save that remote file to the Desktop. The parameter is ignored,
and a root URL that carries it boots the dashboard like any other root
URL instead of forcing the desktop.

This removes the handler in UIDesktop, its UIWindowProgress import, the
download entry in the GUI's dashboard routing, and the
confirm_download_file_to_desktop, downloading, downloading_file,
uploading_file and error_download_failed i18n keys only it used.

The decision of whether a page load boots the dashboard moves from an
inline block in initgui into helpers/bootsDashboard.js, so it is tested.

* Stop granting Camera and Recorder Desktop access on desktop boot

The desktop no longer grants Camera and Recorder write access to the
user's Desktop folder the first time it loads. Those apps now ask
through the normal permission prompt, as they already did for users
who only used the dashboard. Grants already given stay in place.

puter.js's GUI boot cache no longer batch-fetches the
has_set_default_app_user_permissions flag that kept the grant to one
run. Values already saved under it stay in KV, unread.

* Redirect /@username links to the dashboard

puter.com/@<username> no longer opens that user's Public folder on the
desktop. The backend now redirects /@:username to /, so old links land
on the dashboard instead of booting the desktop.

This removes the GUI's /@ handler and the error_invalid_username i18n
key only it used. error_user_or_path_not_found stays, since share links
still use it.

* Redirect /desktop/app links and stop opening share links on the desktop

/desktop/app/<name> now redirects to /app/<name>, keeping its query
string, so the app opens in the dashboard. The backend no longer serves
an app shell there, OIDC sign-in no longer returns to it, and the GUI no
longer strips a leading /desktop from its route segments. The rate-limits
page lists only /app/<name>, the route the limit still applies to.

/desktop?shared=<path> no longer opens the shared item on the desktop.
The desktop ignores ?shared=: it doesn't ask a guest to sign in for it,
doesn't switch to the account a share email names, and OIDC sign-in from
/desktop doesn't carry it along. Share emails link to /?shared=, which
the dashboard handles as before. Opening a share notification on the
desktop still opens the item.

* Remove the desktop's ?app=explorer link handler

?app=explorer[&path=<folder>] no longer boots the desktop and opens an
Explorer window. ?app= is ignored and no longer forces the desktop, so a
root URL that carries it opens the dashboard, and /app/<name>?app=... now
launches its app in the dashboard instead of booting the desktop and
launching nothing.

* Stop ?puter.fullpage= from booting the desktop

The root and /app/<name> with ?puter.fullpage= now open in the dashboard
like a plain /app/<name>, with the app maximized. The dashboard always
shows apps full page, so the parameter no longer overrides that. Iframe
embeds and popups, which still boot the desktop, read it as before.

bootsDashboard no longer takes the query string, since no parameter
decides the interface anymore.
2026-11-12 11:54:40 -07:00
Nariman Jelveh 33988ac84c Remove the desktop taskbar (#4605)
* Remove the desktop taskbar

The desktop no longer has a taskbar. Windows on /desktop can't be
minimized, since nothing could bring them back; the dashboard still
minimizes into app tiles. The desktop and maximized windows use the
full page height.

Backend: POST /update-taskbar-items is removed, and /whoami and the
login response no longer include taskbar_items. The user table's
taskbar_items column stays until a migration drops it.

puter.ui.createWindow() still accepts show_in_taskbar, which has no
effect and is marked deprecated.

* Keep desktop backdrops above ordinary windows

A backdrop's z-index used to take the taskbar's as a floor, which kept
modal dialogs on /desktop above every ordinary window, including ones
focused after the dialog opened. Keep that floor on the desktop; the
dashboard and fullpage mode stack as before.

Also drops commented-out geometry that referenced window.taskbar_height,
and a dashboard comment about the removed fullpage minimize rule.

* Keep dropped desktop icons fully on screen, and fill the phone popover

A desktop icon drop is accepted only where the whole item fits above the
bottom edge. Phone popovers use the full viewport height.
2026-11-11 20:16:13 -07:00
Nariman Jelveh 72de6e16b2 Remove the desktop wallpaper (#4604)
The desktop no longer shows a wallpaper. It shows the page's plain
background color: white, or #1e1e1e in dark mode. The sign-in dialogs
and account picker that opened over the wallpaper now open over that
color too.

This removes the default wallpaper image and its credit, its preload in
both page shells, the "Set as Desktop Background" item on images'
right-click menu, the GUI's set_desktop_background and
refresh_desktop_background, and the set_as_background i18n key.
Desktop icon labels were white with a shadow to read on the wallpaper;
they now use the same gray as labels inside windows, and a light gray
in dark mode.

The backend drops POST /set-desktop-bg, and /whoami no longer returns
desktop_bg_url, desktop_bg_color or desktop_bg_fit. The user table's
desktop_bg_* columns stay; dropping them needs a migration.
2026-11-11 17:40:52 -07:00
Nariman Jelveh 234994d3af Remove the desktop toolbar and nine desktop features, and use puter.js's color picker on the desktop (#4602)
* Remove the desktop's QR login button

The desktop toolbar no longer shows a QR code that signs another device
into the current session. This removes the toolbar button, its click
handler, the UIWindowQR window (its only caller), the qr.svg icon, the
window's CSS, and the four i18n keys only that window used.

UIQRCode and the qrcode library stay, since 2FA setup still uses them.
The ?auth_token= handler in initgui also stays, because the Playwright
e2e harness signs in through it.

* Remove the desktop's My Websites window

The user menu no longer has a My Websites item. This removes the
UIWindowMyWebsites window, its menu item, its CSS, the plug.svg icon only
it used, and the i18n keys only it used.

When publishing a website or worker hits the subdomain limit, the error
now shows only the server's message, without the "Manage your subdomains"
link that opened the window. The trash handlers keep fading the website
badge on a trashed folder and no longer touch the window's rows.

* Remove the desktop's Task Manager window

The user menu no longer has a Task Manager item. This removes the
UIWindowTaskManager window, its menu item, its CSS, the cog.svg icon only
it used, and the i18n keys only it used, including the process_type_*
and process_status_* keys it built at runtime.

The process model in definitions.js, including END_SOFT and END_HARD,
stays: windows still register processes, and a process still forwards
an end signal to its children.

* Remove the desktop's Change Desktop Background window

The desktop's context menu no longer has a "Change Desktop Background…"
item, or the separator above it. This removes the
UIWindowDesktopBGSettings window, its CSS, the palette.svg icon only it
used, and the i18n keys only it used.

Desktop backgrounds still work: the saved background loads at boot, and
an image's "Set as Desktop Background" item still applies and saves it
through /set-desktop-bg.

* Remove the desktop's Start menu

The taskbar no longer has a Start button, so it begins at Explorer. This
removes the Start item and its app popover (recent and recommended apps,
search, tile context menu, and drag-to-taskbar), along with:

- The taskbar's startup /get-launch-apps request and the desktop's
  app.opened socket handler, which only kept the menu's lists current
- The Start popover's search, click and keyboard handlers in UIDesktop.js,
  keyboard.js, initgui.js and UITaskbarItem.js
- The taskbar's handling of items dropped from outside it, and the
  UITaskbarItem append_to_taskbar option only that path used
- The Start CSS, the start.svg icon, and the i18n keys only the menu used

The dashboard's recently used apps still load, since the dashboard fetches
its own list and launchApp.js still records launches. UITaskbar.test.js now
covers the taskbar's item order and that it doesn't fetch launch apps.

* Remove the desktop's Search window

The desktop toolbar no longer has a Search button, and Ctrl/Cmd+F on the
desktop no longer opens the Search window, so it falls through to the
browser's find as it already did on the dashboard. This removes
UIWindowSearch, the toolbar button and its handler, the window's
keyboard handling (arrow keys, Enter, Escape) and click-outside close,
its CSS, the search.svg and magnifier-outline.svg icons only it used, and
the i18n keys only it used.

The backend /search endpoint is unchanged.

* Remove the desktop toolbar

The desktop no longer has the toolbar at the top of the screen, so its
Puter logo, guest Save Account button, and account menu are gone from the
desktop. Log out, account switching, language, settings, and contact all
remain on the dashboard.

Removed:
- The toolbar markup, its auto-hide logic and mouse tracking, its
  right-click menu, and the account menu with its click handlers
- The modify-user-options-menu GUI extension, which only added a
  Developer item to that menu
- UIWindowFeedback, the desktop's Contact Us window, which only the menu
  opened; the dashboard keeps its own contact dialog
- Toolbar hooks elsewhere: the hide_toolbar() call on window maximize,
  pointer-events toggles during window and item drags, the click guard in
  initgui, the account-button toggle in helpers.js, and the avatar sync
  for the toolbar's profile image in TabAccount
- The toolbar and feedback CSS, the toolbar's two KV keys from the
  puter.js GUI read cache, and the i18n keys only these used

window.toolbar_height stays: it was already 0, so window and desktop
layout are unchanged. The welcome window keeps its backdrop stacking.

* Remove the desktop's Share window

The right-click menu on desktop and Explorer items, and on items in open
and save file dialogs, no longer has a Share item. This removes
UIWindowShare, that menu item, the share-outline.svg icon only it used, its
CSS, and the share_with and share_row_team i18n keys only it used.

The Dashboard keeps sharing through UIShareModal. generateFileContextMenu
no longer falls back to the removed window, so it offers Share only when
the caller passes onShare, which the Dashboard's Files tab does. The share
dialog layout classes the Blocked Senders window uses, and the recipient
suggestion styles the share modal uses, stay.

* Remove the desktop's Welcome window

New desktop users no longer see the Welcome window on their first visit.
This removes UIWindowWelcome, the first-visit check in UIDesktop that
opened it, its CSS, the welcome_* i18n keys only it used, and the
UIWindow backdrop_covers_toolbar option only it passed.

has_seen_welcome_window is also dropped from the puter.js GUI boot read
cache. Values already saved under that key stay in KV, unread.

* Replace the desktop's color picker with puter.js's picker

puter.ui.showColorPicker() inside Puter now opens puter.js's own
<puter-color-picker>, the one it already shows on websites outside Puter,
instead of the desktop's iro-based window. Apps get the same answer
everywhere: a lowercase 6-digit hex string, or null when the user cancels.
Inside Puter it used to resolve to an 8-digit hex with alpha, or undefined.

This removes UIWindowColorPicker, UIColorPickerWidget, iro.min.js and its
lib-list entries, and the select_color i18n key only that window used.

The GUI loads puter.js from js.puter.com, so the IPC handler doesn't rely
on this branch's component: it drops the alpha from an 8-digit default,
leaves out a default that isn't a 3- or 6-digit hex, and removes the
picker once it answers.

<puter-color-picker> drops the alpha from an 8-digit default instead of
failing to open, opens on its own color when the default isn't valid,
and removes itself when Escape closes it. showColorPicker()'s type and
docs now give the 6-digit return value and null on cancel.

* Remove the desktop's Publish Worker window

The right-click menu on .js files on the desktop and in Explorer, and in
open and save file dialogs, no longer has a Publish as Worker item. This
removes UIWindowPublishWorker, that menu item, its CSS, and the
pick_name_for_worker i18n key only it used.

The Dashboard keeps publishing workers through its own dialog.
generateFileContextMenu no longer falls back to the removed window, so it
offers Publish as Worker only when the caller passes onPublishWorker,
which the Dashboard's Files tab does.

* Add a Language setting to the dashboard's Account tab

The desktop toolbar's account menu was the only place to change Puter's
language, so removing the toolbar left no way to change it. The Account
tab now has a Language card that shows the current language and opens a
dialog listing every supported language by its own name. Saving a new
one stores it on the account and reloads the page in it.

changeLanguage() now returns false when the language can't be saved, so
the dialog can show an error instead of doing nothing. The
change_language key comes back in every locale, and
language_save_failed is new.
2026-11-11 17:17:30 -07:00
Nariman Jelveh eef2d089a8 Remove the dashboard's Open Desktop card and the unused theme dialog (#4601)
* Remove the unused theme dialog and its translations

UIWindowThemeDialog has had no importers since the settings window was
removed, and the reset_colors, ui_colors and change_ui_colors strings
are referenced only by it or by nothing at all.

* Remove the Open Desktop card from the dashboard Home tab

Drops the card's markup, styles, color tokens, and the open_desktop and
open_desktop_hint strings, which nothing else uses.
2026-11-11 15:12:34 -07:00
Nariman Jelveh 711ece5800 feat(gui): let a first-party page frame the custom domain dialog (#4581)
* feat(gui): let a first-party page frame the custom domain dialog

`/embed/attach-custom-domain` joins the framed popups. On a signed-in page
one label under the Puter origin, `puter.hosting.attach()` now shows the
custom domain dialog in a frame over the page instead of a popup window.
Apps, the desktop and every other website keep their current behavior.

The frame covers the page with a transparent GUI page, so the dialog
floats over it with its own scrim as it does on the desktop. The GUI
serves only the framing page's own user, with no account check waiting,
and otherwise reports `unavailable`, which leaves the page to open the
popup as before. It runs the popup's action unchanged: the framing page
is the requester the dialog names, and gets the same
`customDomainAttached` answer.

Escape is left to the dialog, which doesn't close while a request is in
flight, and the frame allows `clipboard-write` for the DNS record copy
buttons. A call made while the frame shows resolves null without
replacing it, as a second call does on the desktop.

* Update RecommendedAppsService.ts

* fix(gui): let the framed custom domain dialog finish closing before it answers

The answer removes the frame from the page, so the dialog's exit transition
never showed. The framed action waits for the overlay to leave first, with the
wait the feedback embed already had, now one shared helper.
2026-11-11 11:44:39 -07:00
Filip Kujundžić 40ea3686da Merge pull request #4594 from HeyPuter/fix/chat-served-tier-pricing
Price native service tiers and served premium capacity
2026-11-11 18:02:29 +01:00
439 changed files with 21625 additions and 19798 deletions

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/ai-provider-integration-tests.yaml':
- 'actions/[email protected]'
- 'actions/[email protected]'
'.github/workflows/backend-tests.yaml':
- 'actions/[email protected]'
- 'actions/[email protected]'
- 'actions/[email protected]'
- 'actions/[email protected]'
- 'davelosert/[email protected]'
- 'dorny/[email protected]'
'.github/workflows/dependabot-automerge.yml':
- 'dependabot/[email protected]'
'.github/workflows/docker-image.yaml':
- 'actions/[email protected]'
- 'docker/[email protected]'
- 'docker/[email protected]'
- 'docker/[email protected]'
- 'docker/[email protected]'
- 'docker/[email protected]'
- 'peter-evans/[email protected]'
'.github/workflows/docs-build.yml':
- 'actions/[email protected]'
- 'actions/[email protected]'
'.github/workflows/notify-dev.yaml': []
'.github/workflows/notify-prod.yaml': []
'.github/workflows/puterjs-tests.yaml':
- 'actions/[email protected]'
- 'actions/[email protected]'
- 'actions/[email protected]'
- 'actions/[email protected]'
- 'davelosert/[email protected]'
- 'dorny/[email protected]'
'.github/workflows/tag-alias.yaml':
- 'docker/[email protected]'
- 'docker/[email protected]'
dependencies:
'actions/[email protected]':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/[email protected]':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/[email protected]':
ref: 'v7.1.0'
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
owner_id: 44036562
repo_id: 189476904
'actions/[email protected]':
ref: 'v7.0.2'
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
owner_id: 44036562
repo_id: 192625955
'davelosert/[email protected]':
ref: 'v2.13.2'
commit: 'sha1-74c2d9210b4fc44c84d8213f61d7ec99837b7aa0'
owner_id: 4287128
repo_id: 501176456
'dependabot/[email protected]':
ref: 'v3.1.0'
commit: 'sha1-25dd0e34f4fe68f24cc83900b1fe3fe149efef98'
owner_id: 27347476
repo_id: 371068214
'docker/[email protected]':
ref: 'v7.4.0'
commit: 'sha1-c3c9e263c25d99ce0380d002d59b67737d91b0dc'
owner_id: 5429470
repo_id: 241092383
'docker/[email protected]':
ref: 'v4.8.0'
commit: 'sha1-8aa5831fb8d48d8f76594c7ddde61ae58019f550'
owner_id: 5429470
repo_id: 287743349
'docker/[email protected]':
ref: 'v6.2.0'
commit: 'sha1-dc802804100637a589fabce1cb79ff13a1411302'
owner_id: 5429470
repo_id: 306769011
'docker/[email protected]':
ref: 'v4.4.1'
commit: 'sha1-f87e5991a6d7451dcb8d9637bfbc97413f497069'
owner_id: 5429470
repo_id: 288485773
'docker/[email protected]':
ref: 'v4.4.0'
commit: 'sha1-99012661954931238ded8c8b007157a8430204e1'
owner_id: 5429470
repo_id: 288487894
'dorny/[email protected]':
ref: 'v4.0.3'
commit: 'sha1-ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d'
owner_id: 1588572
repo_id: 265601040
'peter-evans/[email protected]':
ref: 'v5.0.0'
commit: 'sha1-1b9a80c056b620d92cedb9d9b5a223409c68ddfa'
owner_id: 18365890
repo_id: 193685475
@@ -17,10 +17,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v7.1.0
with:
node-version: '26'
cache: 'npm'
+18 -12
View File
@@ -23,8 +23,10 @@ jobs:
outputs:
backend: ${{ steps.filter.outputs.backend }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- uses: dorny/[email protected]
id: filter
with:
filters: |
@@ -52,10 +54,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v7.1.0
with:
node-version: '26'
cache: 'npm'
@@ -80,13 +84,14 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
ref: ${{ github.event.pull_request.head.sha || github.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v7.1.0
with:
node-version: '26'
cache: 'npm'
@@ -106,7 +111,7 @@ jobs:
- name: Upload blob report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: backend-blob-${{ matrix.shard }}
path: .vitest-reports/
@@ -146,13 +151,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
ref: ${{ github.event.pull_request.head.sha || github.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v7.1.0
with:
node-version: '26'
cache: 'npm'
@@ -161,7 +167,7 @@ jobs:
run: npm ci
- name: Download blob reports
uses: actions/download-artifact@v8
uses: actions/download-artifact@v8.0.2
with:
pattern: backend-blob-*
path: .vitest-reports
@@ -173,7 +179,7 @@ jobs:
CI: 'true'
- name: Upload coverage artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: backend-coverage-${{ github.event_name == 'push' && 'main' || 'pr' }}
path: src/backend/coverage/
@@ -204,7 +210,7 @@ jobs:
- name: Report coverage on PR
if: github.event_name == 'pull_request'
uses: davelosert/vitest-coverage-report-action@v2
uses: davelosert/vitest-coverage-report-action@v2.13.2
with:
json-summary-path: src/backend/coverage/coverage-summary.json
json-final-path: src/backend/coverage/coverage-final.json
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v3
uses: dependabot/fetch-metadata@v3.1.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
+10 -8
View File
@@ -45,30 +45,32 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Log in to GitHub Package Container registry
uses: docker/login-action@v4
uses: docker/login-action@v4.8.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v4.8.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v4.4.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v4.4.1
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v6.2.0
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -82,7 +84,7 @@ jobs:
type=raw,value=main,enable=${{ github.event_name == 'push' || inputs.push_main }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v7.4.0
with:
platforms: linux/amd64,linux/arm64
context: .
@@ -95,7 +97,7 @@ jobs:
cache-to: type=gha,mode=max
- name: Sync README to Docker Hub
uses: peter-evans/dockerhub-description@v5
uses: peter-evans/dockerhub-description@v5.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+7 -2
View File
@@ -5,16 +5,21 @@ on:
paths:
- 'src/docs/**'
permissions:
contents: read
jobs:
build-docs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v7.1.0
with:
node-version: '26'
cache: 'npm'
-40
View File
@@ -1,40 +0,0 @@
name: Maintain Release Merge PR
on:
push:
branches:
- main
jobs:
update-release-pr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Setup Git
run: |
git config user.name github-actions
git config user.email [email protected]
- name: Check for existing PR
id: find-pr
uses: juliangruber/find-pull-request-action@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
branch: release
- uses: actions/checkout@v7
if: steps.find-pr.outputs.number == ''
with:
ref: release
- name: Reset release branch
if: steps.find-pr.outputs.number == ''
run: |
git fetch origin main:main
git reset --hard main
- name: Create/Update PR
if: steps.find-pr.outputs.number == ''
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: Update release branch
title: Merge Release Auto-PR
body: Merging this PR will invoke release actions
branch: auto-update/release
+2
View File
@@ -5,6 +5,8 @@ on:
branches:
- main
permissions: {}
jobs:
notify:
runs-on: ubuntu-latest
+23 -14
View File
@@ -22,8 +22,10 @@ jobs:
outputs:
puterjs: ${{ steps.filter.outputs.puterjs }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v4
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- uses: dorny/[email protected]
id: filter
with:
filters: |
@@ -52,10 +54,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v7.1.0
with:
node-version: '26'
cache: 'npm'
@@ -73,10 +77,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v7.1.0
with:
node-version: '26'
cache: 'npm'
@@ -130,14 +136,15 @@ jobs:
steps:
- name: Checkout ${{ matrix.ref }}
if: needs.changes.outputs.puterjs == 'true'
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
ref: ${{ matrix.ref }}
repository: ${{ matrix.repository }}
- name: Set up Node.js
if: needs.changes.outputs.puterjs == 'true'
uses: actions/setup-node@v7
uses: actions/setup-node@v7.1.0
with:
node-version: '26'
cache: 'npm'
@@ -161,7 +168,7 @@ jobs:
- name: Upload coverage artifact
if: needs.changes.outputs.puterjs == 'true' && !cancelled()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: puterjs-coverage-${{ matrix.artifact }}
path: src/puter-js/coverage/coverage-*.json
@@ -174,13 +181,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
# Missing when the PR leg failed; `coverage (pr)` already reports that.
- name: Download PR coverage
id: pr-coverage
continue-on-error: true
uses: actions/download-artifact@v8
uses: actions/download-artifact@v8.0.2
with:
name: puterjs-coverage-pr
path: src/puter-js/coverage
@@ -191,14 +200,14 @@ jobs:
id: base-coverage
if: steps.pr-coverage.outcome == 'success'
continue-on-error: true
uses: actions/download-artifact@v8
uses: actions/download-artifact@v8.0.2
with:
name: puterjs-coverage-base
path: coverage-base
- name: Report coverage on PR
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome == 'success'
uses: davelosert/vitest-coverage-report-action@v2
uses: davelosert/vitest-coverage-report-action@v2.13.2
with:
name: puter.js SDK
json-summary-path: src/puter-js/coverage/coverage-summary.json
@@ -207,7 +216,7 @@ jobs:
- name: Report coverage on PR (no base comparison)
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome != 'success'
uses: davelosert/vitest-coverage-report-action@v2
uses: davelosert/vitest-coverage-report-action@v2.13.2
with:
name: puter.js SDK
json-summary-path: src/puter-js/coverage/coverage-summary.json
-18
View File
@@ -1,18 +0,0 @@
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
name: release-please
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: google-github-actions/release-please-action@v4
with:
release-type: node
+2 -2
View File
@@ -45,14 +45,14 @@ jobs:
fi
- name: Log in to GitHub Package Container registry
uses: docker/login-action@v4
uses: docker/login-action@v4.8.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v4.4.1
- name: Create alias tag
env:
+1 -1
View File
@@ -40,7 +40,7 @@ The following have already been reviewed and determined **not to be vulnerabilit
- **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. 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.
- **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 (`/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.
- **Re-claiming a site or worker name its owner deleted.** A hosted site or worker name that its owner deletes is treated as abandoned by that owner. If one of their apps still points at it, whoever claims the name next serves that app's `index_url` and receives what the app is launched with, and a new app on the same origin may share the old one's identity. Guarding app identities against being recycled this way is ongoing hardening, not a security boundary, so new ways to re-claim an abandoned name, or to inherit an app's tokens or data through one, are not eligible.
- **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.
+14
View File
@@ -62,6 +62,10 @@
// direct socket peer. NEVER set to `true` in prod — it trusts every hop
// and makes X-Forwarded-For forgeable.
"trust_proxy": false,
// Request header a TLS-terminating proxy in front of the server sets to
// the client's TLS fingerprint (JA3/JA4). Only set it when that proxy
// overwrites any value the client sends. Unset disables it.
// "tlsFingerprintHeader": "x-tls-fingerprint",
"no_browser_launch": false,
// ── Dev watcher (devWatcher extension) ──────────────────────────────
@@ -254,6 +258,16 @@
"user": "",
"pass": ""
}
// Each send names a stream: "auth", "signup" or "notify". A stream
// listed here adds its headers to every message on it, and its "from"
// when the sender gives none. Tags are written to "tagsHeader".
// "streams": {
// "notify": {
// "from": "\"Puter\" <[email protected]>",
// "headers": { "X-Example-Stream": "notify" },
// "tagsHeader": "X-Example-Tags"
// }
// }
},
// ── Inbound mail (separate process) ─────────────────────────────────
+31 -1
View File
@@ -322,6 +322,36 @@ app reads from the desktop's answer. A call made while the frame shows settles
with it. A frame that is refused, never ready, or `unavailable` resolves
`{ response: false }`, since there's no popup to fall back to.
## Custom domains
`GET /embed/attach-custom-domain` is the custom domain dialog of
`puter.hosting.attach()`, the popup at `/action/attach-custom-domain`, served
like the upgrade: the GUI shell with the same framing rules, redirects and
`no-store`, run only when framed by an allowed page. puter.js frames it on a
signed-in first-party page (the same pages as the pickers).
The frame covers the page with no box or close button, over a transparent GUI
page, so the dialog floats over the host with its own scrim as it does on the
desktop. The frame allows `clipboard-write`, for the dialog's copy buttons, as
well as `otp-credentials`. Escape on the page is left to the dialog, which
doesn't close while a request is in flight. Its fragment names `user` (the
page's signed-in user), `subdomain` (the site to attach a domain to) and
`msg_id`.
The GUI reports `unavailable` unless its session belongs to `user` and the
account has no verification or password change waiting. It then runs the
popup's action unchanged, with the framing page as the requester the dialog
names: it posts `ready` once the dialog is on the page, answers
`{ msg: 'customDomainAttached', domain, original_msg_id }` once the dialog has
closed and its exit has played, and then posts `closed`. `domain` is the domain the user chose, or
null. `attach()` resolves it. A frame that closes without an answer, or a
loading scrim the user dismisses, resolves null.
A call made while the frame shows resolves null without replacing it, as a
second call does on the desktop. A frame that is refused, never ready, or
`unavailable` falls back to the popup, which can sign in, through a consent
dialog when the click has expired.
## Checks
Run the GUI component/browser tests (with the SDK bundle built and Playwright
@@ -329,7 +359,7 @@ Chromium installed), and the backend route tests:
```sh
npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser src/feedbackEmbed
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/modules/UI.showTopUp.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/modules/UI.showTopUp.test.js src/puter-js/src/modules/UI.attachCustomDomainFrame.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
npx vitest run --config src/gui/vitest.config.js src/gui/src/helpers/popupFrame.test.js
npx vitest run --config src/backend/vitest.config.ts src/backend/server.test.ts
```
+6 -6
View File
@@ -16,7 +16,7 @@ Core subscribes the browser to a push service only when the server also publishe
`uid` is the notification's uid, a later push with the same `group` replaces the one showing, and `target` and `data` reach the app when the user taps it.
When the browser replaces the subscription (`pushsubscriptionchange`), `sw.js` posts `{ type: 'puter-push-subscription-changed' }` to every open window on Puter's origin. The worker has no token or session, so the page's push client registers the new subscription.
When the browser replaces the subscription (`pushsubscriptionchange`), `sw.js` keeps the new one, or subscribes again with Puter's stored key when the browser gave none and the event, the receipt or an earlier mark names the subscription that went. It writes `puter:subscription-changed:<scope>` to the `puter-push` IndexedDB as `{ at, oldEndpoint }`, the old endpoint kept from an earlier mark no page handled yet, else taken from the event or from the receipt, forgets the receipt kept for that scope, and posts `{ type: 'puter-push-subscription-changed' }` to every open window on Puter's origin. The worker has no token or session, so an open page runs `helpers/pushSync.js` on that message, after any run already in flight, and a later load does the same through the marker.
## Receipts
@@ -59,9 +59,9 @@ Apps on their own origin do the same through `push-sw.js`, which they import int
```ts
interface PushClient {
enable(): Promise<{ deviceUid: string }>;
enable(options?: { background?: boolean }): Promise<{ deviceUid: string }>;
disable(): Promise<void>;
status(): Promise<{
status(options?: { cached?: boolean }): Promise<{
permission: 'default' | 'granted' | 'denied';
registered: boolean;
deviceUid?: string;
@@ -69,12 +69,12 @@ interface PushClient {
}
```
- `enable()` may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includes `notifications:push` while this browser is off, and the "Turn on notifications" button of the `/action/enable-push` popup and the matching desktop dialog (`UIEnablePushDialog`). Core also calls it without a click, from `helpers/pushSync.js`, when `Notification.permission` is already `granted`, `status()` reports the browser as not registered, the user has not turned push off in this browser, and an app holds `notifications:push`; the browser does not prompt then. The built-in client runs `puter.notifications.enable()`, which subscribes with the `/sw.js` registration and registers the browser through the Web Push flow above (`platform: 'webpush'`). It then resolves the `uid` of this browser's `webpush` device from `listDevices()`. It rejects when permission is refused or the browser wasn't registered.
- `enable()` may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includes `notifications:push` while this browser is off, and the "Turn on notifications" button of the `/action/enable-push` popup and the matching desktop dialog (`UIEnablePushDialog`). Core also calls it without a click, from `helpers/pushSync.js`, when `Notification.permission` is already `granted`, `status()` reports the browser as not registered, the user has not turned push off in this browser, and an app may push to Puter's devices (`puterDevices` in `GET /push/apps`); the browser does not prompt then. That call passes `{ background: true }`: a browser the user removed in Puter stays removed, and the built-in client rejects with code `push_device_removed`, after which core records the browser as turned off. Before a background call the built-in client asks `POST /push/web/subscriptions/status` about every earlier endpoint it knows: the marker's `oldEndpoint` and the last endpoint the server answered `active` for (`puter.push.last-endpoint:<user uuid>` in `localStorage`). On a 429 it registers nothing and rejects with `too_many_requests`, which the sync reports as `later`, keeping the marker for the next run. When any answer is `removed`, it ends the current subscription in the browser, clears the marker and rejects with `push_device_removed`, so a subscription change or a dropped subscription never brings back a browser removed from another device. A click renews it as before. For a click the built-in client runs `puter.notifications.enable()`, which subscribes with the `/sw.js` registration and registers the browser through the Web Push flow above (`platform: 'webpush'`); in the background it runs the same registration without replacing a removed subscription or opening Puter's sign-in. It then resolves the `uid` of this browser's `webpush` device from `listDevices()` and clears the subscription-changed marker. It rejects when permission is refused or the browser wasn't registered.
- `disable()` runs `puter.notifications.disable()`, which sends `DELETE /push/web/subscriptions { endpoint }` and ends the browser's subscription. The tab calls it for Turn off, and for Remove on the device marked as this browser. Remove on any other device is `DELETE /push/devices/:uid`.
- `status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user, and `deviceUid` is that device's `uid`.
- `status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user and `POST /push/web/subscriptions/status` answers `active` for the current subscription's endpoint, and `deviceUid` is that device's `uid`. A server that can't answer leaves the device match alone, and so does an `unknown` for an endpoint `enable()` registered in this page in the last 5 minutes, since that check can trail a registration. While the subscription-changed marker is set, `registered` is false. With `{ cached: true }` a client may answer with the last registered state it read in this page. Core passes it for the silent registration on app launch and for an app's `puter.notifications.status()`; the Notifications tab and the push dialogs read fresh. The built-in client keeps that state in memory for the same user and subscription for up to 5 minutes, from `enable()` or once the server answered `active` for the endpoint, and answers from it without either request. Reads of the same user and endpoint share one real check while it is in flight. It drops it on `disable()`, a failed `enable()`, an unregistered answer, a set subscription-changed marker, and the worker's `puter-push-subscription-changed` message, so a new page load always asks the server. Whether the calling app may push is checked on every call, against a `GET /push/apps` answer the page reuses for 30 seconds and drops on `enable()`, `disable()`, a grant, a revoke and a user change. When it can't be read, or leaves the app out, the grant alone counts.
After `enable()` resolves, the tab calls `POST /push/devices/:uid/test` and asks the user whether a notification showed up.
The tab also reads `GET /push/apps` and `GET`/`POST /push/preferences`.
Core keeps its own record of a browser the user turned off, `puter.push.browser-off:<user uuid>` in `localStorage`, written by Turn off and by Remove on this browser's device, and cleared when `enable()` resolves. The silent registration above skips a browser with that record, so a device removed here stays removed while one removed from another device, or a new sign-in in a browser that already allows notifications, registers again.
Core keeps its own record of a browser the user turned off, `puter.push.browser-off:<user uuid>` in `localStorage`, written by Turn off, by Remove on this browser's device, and by a silent registration the server refuses with `push_device_removed`, and cleared when `enable()` resolves. The silent registration above skips a browser with that record, so a device removed here or from another device stays removed until the user turns it on from a click, while a new sign-in in a browser that already allows notifications registers again.
+14
View File
@@ -326,6 +326,20 @@ Used for password resets, email confirmation, and notifications. Without it thos
To require email confirmation before login, also set `"strict_email_verification_required": true`.
Account and notification mail is sent on a named stream: `auth` (password, address and 2FA changes), `signup` (address confirmation; a `puter.signup.validate` listener may name another: lowercase letters, digits, `:`, `_` and `-`, up to 64 characters, starting with a letter or digit) and `notify` (share, app feedback and contact form mail). Add `"streams"` inside `"email"` to configure one, for instance to let your mail provider route or report each kind of mail separately:
```json
"streams": {
"notify": {
"from": "\"Puter\" <[email protected]>",
"headers": { "X-Example-Stream": "notify" },
"tagsHeader": "X-Example-Tags"
}
}
```
`headers` are added to every message on the stream, `from` replaces the default sender, and `tagsHeader` names the header that carries a message's tags as `name=value` pairs joined by `, `. A stream that is not listed adds nothing: the message goes out with the default from address and the caller's headers. `streams` is never handed to the transport.
To read the mail yourself instead of sending it, point the transport at a local SMTP sink — [MailHog](https://github.com/mailhog/MailHog) catches everything and serves it at `http://localhost:8025`. Publish both ports; a container that only exposes them is unreachable from the host:
```sh
+40 -10
View File
@@ -257,8 +257,41 @@ describe('whoami extension — handleWhoami', () => {
expect(body.created_ts).toBeGreaterThan(0);
// `directories` is only sent to user actors — confirm it’s present.
expect(body.directories).toBeDefined();
// taskbar_items is only sent to user actors.
expect(body).toHaveProperty('taskbar_items');
});
it('leaves out taskbar items saved on the account', async () => {
const user = await seedUser();
await server.stores.user.update(user.id as number, {
taskbar_items: JSON.stringify([{ type: 'app', name: 'editor' }]),
});
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleWhoami(makeReq(), res),
);
expect(captured.body).not.toHaveProperty('taskbar_items');
});
it('leaves out a desktop background saved on the account', async () => {
const user = await seedUser();
await server.stores.user.update(user.id as number, {
desktop_bg_url: 'https://cdn.test/wallpaper.png',
desktop_bg_color: '#000000',
desktop_bg_fit: 'cover',
});
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleWhoami(makeReq(), res),
);
const body = captured.body as Record<string, unknown>;
expect(body).not.toHaveProperty('desktop_bg_url');
expect(body).not.toHaveProperty('desktop_bg_color');
expect(body).not.toHaveProperty('desktop_bg_fit');
});
it('reports the SMS-to-card fallback only once a send has opened it', async () => {
@@ -322,7 +355,7 @@ describe('whoami extension — handleWhoami', () => {
expect(flags.payment_bypass).toBeUndefined();
});
it('strips desktop_bg_*, created_ts and human_readable_age fields for app actors', async () => {
it('strips created_ts and human_readable_age fields for app actors', async () => {
const user = await seedUser();
const { res, captured } = makeRes();
@@ -340,9 +373,6 @@ describe('whoami extension — handleWhoami', () => {
expect(body.app_name).toBe('app-test-actor');
// is_user_token is stripped for app actors.
expect(body.is_user_token).toBeUndefined();
expect(body.desktop_bg_url).toBeUndefined();
expect(body.desktop_bg_color).toBeUndefined();
expect(body.desktop_bg_fit).toBeUndefined();
expect(body.human_readable_age).toBeUndefined();
// Account age, in either form, is not exposed to apps.
expect(body.created_ts).toBeUndefined();
@@ -432,7 +462,7 @@ describe('whoami extension — handleWhoami', () => {
});
});
it('cannot add taskbar_items for an app actor, which core omits', async () => {
it('cannot add directories for an app actor, which core omits', async () => {
const user = await seedUser();
const body = await whoamiWithListener(
makeActor({
@@ -440,11 +470,11 @@ describe('whoami extension — handleWhoami', () => {
app: { uid: 'app-test-actor' },
}),
(_key, event) => {
event.details.taskbar_items = [{ name: 'injected' }];
event.details.directories = { '/injected': 'uuid' };
},
);
expect(body).not.toHaveProperty('taskbar_items');
expect(body).not.toHaveProperty('directories');
});
});
@@ -525,7 +555,7 @@ describe('whoami extension — handleWhoami', () => {
expect(body.otp_secret).toBeUndefined();
expect(body.signup_ip).toBeUndefined();
// Not just absent as a key — the values must not appear anywhere
// in the payload (nested under metadata, taskbar items, …).
// in the payload (nested under metadata, …).
const serialized = JSON.stringify(body);
expect(serialized).not.toContain('5551234567');
expect(serialized).not.toContain('fp_ABC123');
+2 -16
View File
@@ -1,6 +1,5 @@
import { Context } from '@heyputer/backend/src/core';
import { extension } from '@heyputer/backend/src/extensions';
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
import {
buildUserDetails,
scrubSensitive,
@@ -54,19 +53,10 @@ export const handleWhoami = async (
return;
}
const rawIconSize =
typeof req.query?.icon_size === 'string'
? Number(req.query.icon_size)
: undefined;
const iconSize =
rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize)
? rawIconSize
: undefined;
const details = await buildUserDetails(
user,
{ config: extension.config, clients, stores, services },
{ isUser, iconSize, noIcons: !iconSize },
{ isUser },
);
details.human_readable_age = user.timestamp
? timeago.format(new Date(user.timestamp as string))
@@ -81,9 +71,6 @@ export const handleWhoami = async (
delete details.email;
delete details.unconfirmed_email;
}
delete details.desktop_bg_url;
delete details.desktop_bg_color;
delete details.desktop_bg_fit;
delete details.human_readable_age;
delete details.created_ts;
delete details.is_user_token;
@@ -121,8 +108,7 @@ export const handleWhoami = async (
}
const subscription = details.subscription as
| { offering?: Record<string, unknown> }
| undefined;
{ offering?: Record<string, unknown> } | undefined;
if (subscription?.offering) {
delete subscription.offering.group;
delete subscription.offering.benefits;
+7
View File
@@ -190,6 +190,13 @@ S3_BUCKET=puter-local
providers = [ordered]@{
ollama = [ordered]@{ enabled = $false }
}
# Off because a self-hosted install has no paid plans: every account
# is on the free policy, so the plan gates would 402 the surfaces that
# declare one (the OpenAI/Anthropic-compatible AI endpoints) for
# everybody. Same as install.sh.
meteringEnforcement = [ordered]@{
subscriptions = $false
}
trust_proxy = 1
}
$configJson = $config | ConvertTo-Json -Depth 10
+86 -77
View File
@@ -897,21 +897,21 @@
}
},
"node_modules/@babel/core": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.13.tgz",
"integrity": "sha512-j1nvOQCZYSgp7HLmisnCXFTpVvFODiijQ9QHkORfGKWH6YDtskTEeCJS7eV545GZ9UBzrq0e2xsOd5QQ5PjIqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/generator": "^7.29.13",
"@babel/helper-compilation-targets": "^7.29.13",
"@babel/helper-module-transforms": "^7.29.13",
"@babel/helpers": "^7.29.13",
"@babel/parser": "^7.29.13",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@babel/traverse": "^7.29.13",
"@babel/types": "^7.29.11",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -938,14 +938,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.13.tgz",
"integrity": "sha512-T5FRo+RzVHvGjWOuF1Ljc44b+NXAOazodHkGUHp4I3zCkQ4/sNVJm56h1S278LMR0bY5xBhztGziSbli8zObIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@babel/parser": "^7.29.13",
"@babel/types": "^7.29.11",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -955,9 +955,9 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.13.tgz",
"integrity": "sha512-8WxJ4/Vmes3ghacj8VOJy3NW8eyHiFY/4thU/LSBuBE470+igjR/j1ZTNn8fV5VdNrLx6iXUwig526UdOvfP0w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -982,22 +982,15 @@
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
"integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
@@ -1023,15 +1016,15 @@
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.13.tgz",
"integrity": "sha512-/U96wOz+SCoZ7Da/8kjDGltcNY280JPQcRNPK0Hk3PTbdpaiBDQImtC6JsbVwb1HGMr3usZQU2WsWVjhYNmRzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
"@babel/traverse": "^7.29.13"
},
"engines": {
"node": ">=6.9.0"
@@ -1051,9 +1044,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.13.tgz",
"integrity": "sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1081,27 +1074,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.13.tgz",
"integrity": "sha512-laoRpD/kO/iA4CDlgJbixHekg7oPvWALK0PQyK3Ob2I2njZRvVAD77RfWCIImtqWCq6KijoBoZK7bp/HlTWASQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
"@babel/types": "^7.29.11"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz",
"integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
"@babel/types": "^7.29.11"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1135,18 +1128,18 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"version": "7.29.13",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.13.tgz",
"integrity": "sha512-0ve0xMlFA73dgY1oyJRY6RoW+/bncdcOQqg6jjYcVfXBK90OdLXjm9R6lmRetdZVrPjXkKIoMVBqYFPjcAlZ4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/generator": "^7.29.13",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/parser": "^7.29.13",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"@babel/types": "^7.29.11",
"debug": "^4.3.1"
},
"engines": {
@@ -1154,13 +1147,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"version": "7.29.11",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz",
"integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-string-parser": "^7.29.11",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
@@ -7460,18 +7453,18 @@
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
"integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"content-type": "^2.1.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"iconv-lite": "^0.7.3",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"qs": "^6.16.1",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
@@ -7484,9 +7477,9 @@
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -9581,9 +9574,9 @@
}
},
"node_modules/fastify": {
"version": "5.12.5",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz",
"integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==",
"version": "5.12.7",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.7.tgz",
"integrity": "sha512-v4pEPsTRPuqlDIYiQFB+BHjVN4SdHX9u0ABk0ZDUKbbdY1vsNryUd1j4Z+jQEZajvYIVk047Kimva9/AJ79GYw==",
"funding": [
{
"type": "github",
@@ -16587,9 +16580,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.30.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
"integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
"version": "7.30.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.2.tgz",
"integrity": "sha512-mLyPQnHwUxaM/rideoK/+KL8DaAJLRl4aBCfmX444aCSLnIaomqPLtNRe2jdCYcsk5wZzXrDIQC8ZeYNRAYyIA==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -18141,6 +18134,13 @@
"node": ">=10"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
@@ -18233,9 +18233,9 @@
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"version": "4.6.7",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -18300,7 +18300,7 @@
"mime-types": "^2.1.35",
"murmurhash": "^2.0.1",
"mysql2": "^3.24.6",
"nodemailer": "^10.1.4",
"nodemailer": "^10.1.7",
"openai": "^6.34.0",
"otpauth": "^9.5.2",
"pg": "^8.21.0",
@@ -18312,10 +18312,10 @@
"svg-captcha": "^1.4.0",
"together-ai": "^0.33.0",
"ua-parser-js": "^1.0.41",
"undici": "^7.30.1",
"undici": "^7.30.2",
"uuid": "^14.0.3",
"validator": "^13.15.35",
"ws": "^8.22.0"
"ws": "^8.22.2"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
@@ -18343,10 +18343,19 @@
"dev": true,
"license": "MIT"
},
"src/backend/node_modules/nodemailer": {
"version": "10.1.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.7.tgz",
"integrity": "sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
}
},
"src/backend/node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"version": "8.22.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.2.tgz",
"integrity": "sha512-/rEZ2ejown2N5gxVTHUu9bVVC7ttu0/Tgzf5hbTOixR3Fs7mbbXgn2DbdbcKgJOmpQMtrIUWivtYuVMnp/W/yA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
-1
View File
@@ -125,7 +125,6 @@ describe('core without a teams registration', () => {
actor: { user: row },
} as never,
{ title: 'Puter' } as never,
{} as never,
);
const params = JSON.parse(
/gui\((\{.*?\})\);/s.exec(html)![1].replaceAll('\\u003c', '<'),
@@ -167,6 +167,38 @@ describe('AbstractDatabaseClient — replica-aware reads', () => {
expect(client.reads).toEqual(['SELECT 1']);
expect(client.preads).toEqual([]);
});
describe('tryHardReadAll', () => {
const reads = [{ query: 'SELECT 1' }, { query: 'SELECT 2' }];
it('keeps replica rows without reading the primary', async () => {
const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]);
expect(await client.tryHardReadAll(reads)).toEqual([
[{ id: 1 }],
[{ id: 1 }],
]);
expect(client.preads).toEqual([]);
});
it.each([[], new Error('replica unavailable')])(
'reads every query on the primary when the replica has none',
async (replica) => {
const client = new FixtureDatabaseClient(replica, [{ id: 2 }]);
expect(await client.tryHardReadAll(reads)).toEqual([
[{ id: 2 }],
[{ id: 2 }],
]);
expect(client.preads).toEqual(['SELECT 1', 'SELECT 2']);
},
);
it('issues one read per query when there is no replica', async () => {
const client = new FixtureDatabaseClient([], [{ id: 2 }], false);
expect(await client.tryHardReadAll(reads)).toEqual([[], []]);
expect(client.reads).toEqual(['SELECT 1', 'SELECT 2']);
expect(client.preads).toEqual([]);
});
});
});
describe('AbstractDatabaseClient — SQL generation', () => {
@@ -172,6 +172,34 @@ export class AbstractDatabaseClient extends PuterClient {
return result.value;
}
/** Reads the primary only when every replica read is empty or fails. */
@Span('db.tryHardReadAll', (reads: unknown[]) => ({
'db.batch_size': reads.length,
}))
async tryHardReadAll(
reads: { query: string; params?: unknown[] }[],
): Promise<Record<string, unknown>[][]> {
if (!this.hasReadReplica()) {
return Promise.all(
reads.map(({ query, params = [] }) => this.read(query, params)),
);
}
const onReplica = await Promise.all(
reads.map(async ({ query, params = [] }) => {
try {
return await this.replicaRead(query, params);
} catch {
// A failed replica read counts as a miss.
return [];
}
}),
);
if (onReplica.some((rows) => rows.length > 0)) return onReplica;
return Promise.all(
reads.map(({ query, params = [] }) => this.pread(query, params)),
);
}
/**
* Apply the `.sql` files named `<engineName>*` in each configured migration
* directory, in filename order. Files must be idempotent: nothing records
@@ -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 = 98;
const CURRENT_SCHEMA_VERSION = 101;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -705,6 +705,65 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
}
});
it('fills a share row missing its entry owner from 0103, invites left alone', async () => {
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-share-owner-'));
const path = join(dir, 'puter.sqlite');
try {
const before = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path }),
);
await before.onServerStart();
const [user] = (await before.read(
'SELECT MIN(`id`) AS id FROM `user`',
)) as { id: number }[];
await before.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `user_id`, `modified`) ' +
'VALUES (?, ?, ?, ?)',
['22222222-2222-4222-8222-222222222222', 'owned.txt', user.id, 0],
);
const [entry] = (await before.read(
'SELECT `id` FROM `fsentries` WHERE `uuid` = ?',
['22222222-2222-4222-8222-222222222222'],
)) as { id: number }[];
await before.write(
'INSERT INTO `share` (`uid`, `issuer_user_id`, ' +
'`recipient_email`, `fsentry_id`) VALUES (?, ?, ?, ?)',
['share-no-owner', user.id, '[email protected]', entry.id],
);
await before.write(
'INSERT INTO `share` (`uid`, `issuer_user_id`, ' +
'`recipient_email`) VALUES (?, ?, ?)',
['share-invite', user.id, '[email protected]'],
);
// The full schema as it stood before 0103, so 0103 reruns; 0104
// is undone so it can apply again after it.
await before.write('DROP INDEX idx_user_signup_tls_fingerprint');
await before.write(
'ALTER TABLE `user` DROP COLUMN `signup_tls_fingerprint`',
);
await before.write('PRAGMA user_version = 98');
before.onServerShutdown();
const after = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path }),
);
await after.onServerStart();
await expect(
after.read(
'SELECT `uid`, `entry_owner_user_id` FROM `share` ' +
'WHERE `uid` IN (?, ?) ORDER BY `uid`',
['share-invite', 'share-no-owner'],
),
).resolves.toEqual([
{ uid: 'share-invite', entry_owner_user_id: null },
{ uid: 'share-no-owner', entry_owner_user_id: user.id },
]);
after.onServerShutdown();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('indexes notification.created_at for the retention sweep', async () => {
const rows = await client.read('PRAGMA index_list(`notification`)');
expect(rows.map((r) => String(r.name))).toContain(
@@ -137,6 +137,9 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[95, ['0100_notification-send-key.sql']],
[96, ['0101_user-scoped-handlers.sql']],
[97, ['0102_notification-scope-id-index.sql']],
[98, ['0103_share-entry-owner-backfill.sql']],
[99, ['0104_signup-tls-fingerprint.sql']],
[100, ['0105_sessions_kind_debug_app.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -0,0 +1,25 @@
-- 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/>.
-- Fills `share`.`entry_owner_user_id` on any row that names an entry but was
-- written without the owner. Mirrors SQLite migration 0103. No per-file
-- applied-state tracking; a replay finds nothing to fill.
UPDATE `share` JOIN `fsentries` ON `fsentries`.`id` = `share`.`fsentry_id`
SET `share`.`entry_owner_user_id` = `fsentries`.`user_id`
WHERE `share`.`fsentry_id` IS NOT NULL
AND `share`.`entry_owner_user_id` IS NULL;
@@ -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/>.
-- `user`.`signup_tls_fingerprint`. See
-- sqlite/0104_signup-tls-fingerprint.sql for the rationale. No per-file
-- applied-state tracking, so the column and index are added only when missing
-- and a replay does nothing.
DROP PROCEDURE IF EXISTS _puter_add_user_signup_tls_fingerprint;
DELIMITER //
CREATE PROCEDURE _puter_add_user_signup_tls_fingerprint()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND COLUMN_NAME = 'signup_tls_fingerprint'
) THEN
ALTER TABLE `user`
ADD COLUMN `signup_tls_fingerprint` varchar(64) DEFAULT NULL,
ALGORITHM=INSTANT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND INDEX_NAME = 'idx_user_signup_tls_fingerprint'
) THEN
ALTER TABLE `user` ADD INDEX `idx_user_signup_tls_fingerprint`
(`signup_tls_fingerprint`), ALGORITHM=INPLACE, LOCK=NONE;
END IF;
END//
DELIMITER ;
CALL _puter_add_user_signup_tls_fingerprint();
DROP PROCEDURE IF EXISTS _puter_add_user_signup_tls_fingerprint;
@@ -0,0 +1,46 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Add 'debug_app' to the `sessions.kind` ENUM: a short-lived app session an
-- operator creates on a user's behalf. Mirrors SQLite migration 0105.
--
-- Idempotent: guarded against COLUMN_TYPE so re-running the directory is a
-- no-op once 'debug_app' is in the ENUM.
DROP PROCEDURE IF EXISTS _puter_sessions_kind_debug_app;
DELIMITER //
CREATE PROCEDURE _puter_sessions_kind_debug_app()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND COLUMN_NAME = 'kind'
AND FIND_IN_SET('debug_app', REPLACE(REPLACE(REPLACE(COLUMN_TYPE, 'enum(', ''), ')', ''), '''', '')) > 0
) THEN
ALTER TABLE `sessions`
MODIFY COLUMN `kind`
ENUM('web', 'app', 'access_token', 'asset', 'worker', 'debug_app')
NOT NULL DEFAULT 'web',
ALGORITHM=INSTANT;
END IF;
END//
DELIMITER ;
CALL _puter_sessions_kind_debug_app();
DROP PROCEDURE IF EXISTS _puter_sessions_kind_debug_app;
@@ -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/>.
-- Fills "share".entry_owner_user_id on any row that names an entry but was
-- written without the owner. Mirrors SQLite migration 0103. No per-file
-- applied-state tracking; a replay finds nothing to fill.
UPDATE "share"
SET entry_owner_user_id = f.user_id
FROM fsentries f
WHERE f.id = "share".fsentry_id
AND "share".fsentry_id IS NOT NULL
AND "share".entry_owner_user_id IS NULL;
@@ -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/>.
-- "user".signup_tls_fingerprint. Mirrors SQLite migration 0104. Idempotent
-- via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS signup_tls_fingerprint varchar(64);
CREATE INDEX IF NOT EXISTS idx_user_signup_tls_fingerprint ON "user" (signup_tls_fingerprint);
@@ -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/>.
-- Add 'debug_app' to the `sessions.kind` CHECK constraint: a short-lived app
-- session an operator creates on a user's behalf. Mirrors SQLite migration
-- 0105. Drop-then-add keeps it idempotent across directory replays.
ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_kind_check;
ALTER TABLE sessions ADD CONSTRAINT sessions_kind_check
CHECK (kind IN ('web', 'app', 'access_token', 'asset', 'worker', 'debug_app'));
@@ -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/>.
-- Fills `share`.`entry_owner_user_id` on any row that names an entry but was
-- written without the owner, so reads can rely on the column alone. Rows that
-- name no entry (invites) keep a NULL owner. A replay finds nothing to fill.
UPDATE `share`
SET `entry_owner_user_id` = (
SELECT `user_id` FROM `fsentries` WHERE `fsentries`.`id` = `share`.`fsentry_id`
)
WHERE `fsentry_id` IS NOT NULL
AND `entry_owner_user_id` IS NULL;
@@ -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/>.
-- `user`.`signup_tls_fingerprint`: the TLS fingerprint of the request that
-- created the account, when a fronting proxy supplies one. Indexed so the
-- accounts created from one TLS stack can be listed.
ALTER TABLE `user` ADD COLUMN `signup_tls_fingerprint` varchar(64) DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_user_signup_tls_fingerprint ON `user` (`signup_tls_fingerprint`);
@@ -0,0 +1,89 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Add 'debug_app' to the `kind` CHECK constraint on `sessions`. Mirrors the
-- MySQL ENUM extension in mysql_mig_59.
--
-- SQLite cannot ALTER a CHECK constraint in place, so we follow the same
-- table-rebuild as migration 0056: create `sessions_new` with the corrected
-- constraint, copy rows, drop the old table, rename, then recreate every
-- index that lived on the original.
CREATE TABLE `sessions_new` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL,
"uuid" TEXT NOT NULL,
"meta" JSON DEFAULT NULL,
"created_at" INTEGER DEFAULT 0,
"last_activity" INTEGER DEFAULT 0,
"kind" TEXT NOT NULL DEFAULT 'web'
CHECK (`kind` IN ('web', 'app', 'access_token', 'asset', 'worker', 'debug_app')),
"label" TEXT,
"parent_session_id" TEXT,
"last_ip" TEXT,
"last_user_agent" TEXT,
"revoked_at" INTEGER,
"expires_at" INTEGER,
"app_uid" TEXT,
"legacy_token_uid" TEXT,
"created_via" TEXT,
"auth_id" TEXT,
"access_token_uid" TEXT,
FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO `sessions_new` (
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
`created_via`, `auth_id`, `access_token_uid`
)
SELECT
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
`created_via`, `auth_id`, `access_token_uid`
FROM `sessions`;
DROP TABLE `sessions`;
ALTER TABLE `sessions_new` RENAME TO `sessions`;
CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked`
ON `sessions` (`user_id`, `revoked_at`);
CREATE INDEX IF NOT EXISTS `idx_sessions_parent`
ON `sessions` (`parent_session_id`);
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_app_active`
ON `sessions` (`user_id`, `app_uid`)
WHERE `kind` = 'app' AND `revoked_at` IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_legacy_token_active`
ON `sessions` (`legacy_token_uid`)
WHERE `legacy_token_uid` IS NOT NULL AND `revoked_at` IS NULL;
CREATE INDEX IF NOT EXISTS `idx_sessions_kind_user`
ON `sessions` (`kind`, `user_id`);
CREATE INDEX IF NOT EXISTS `idx_sessions_access_token_uid`
ON `sessions` (`access_token_uid`)
WHERE `access_token_uid` IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_worker_active`
ON `sessions` (`user_id`, IFNULL(`app_uid`, ''), json_extract(`meta`, '$.worker_name'))
WHERE `kind` = 'worker' AND `revoked_at` IS NULL;
@@ -168,6 +168,204 @@ describe('EmailClient — sending', () => {
});
});
describe('EmailClient — streams', () => {
const STREAMS = {
notify: {
from: '"Notices" <[email protected]>',
headers: { 'X-Example-Stream': 'notify', 'X-Example-Kind': 'n' },
tagsHeader: 'X-Example-Tags',
},
plain: { headers: { 'X-Example-Stream': 'plain' } },
};
const streamClient = () =>
startClient({
email: { jsonTransport: true, from: FROM, streams: STREAMS },
} as unknown as Partial<IConfig>);
const base = { to: '[email protected]', subject: 's', text: 't' };
it('adds the stream headers and from address to the message', async () => {
const info = await streamClient().sendRaw({
...base,
stream: 'notify',
});
const message = sentMessage(info);
expect(message.headers).toMatchObject({
'X-Example-Stream': 'notify',
'X-Example-Kind': 'n',
});
expect(message.from.address).toBe('[email protected]');
});
it('lets caller headers and from win over the stream', async () => {
const info = await streamClient().sendRaw({
...base,
stream: 'notify',
from: '[email protected]',
headers: { 'x-example-stream': 'mine' },
});
const message = sentMessage(info);
expect(message.from.address).toBe('[email protected]');
expect(message.headers?.['x-example-stream']).toBe('mine');
expect(message.headers).not.toHaveProperty('X-Example-Stream');
expect(message.headers?.['X-Example-Kind']).toBe('n');
});
it('keeps the default from address for a stream without one', async () => {
const info = await streamClient().sendRaw({ ...base, stream: 'plain' });
expect(sentMessage(info).from.address).toBe('[email protected]');
});
it('leaves the message unchanged for an unknown or missing stream', async () => {
const client = streamClient();
for (const stream of ['nope', undefined]) {
const message = sentMessage(
await client.sendRaw({ ...base, stream, tags: { a: 'b' } }),
);
expect(message.from.address).toBe('[email protected]');
expect(message.headers ?? {}).toEqual({});
}
});
it('renders valid tags into the stream tags header and drops the rest', async () => {
const info = await streamClient().sendRaw({
...base,
stream: 'notify',
tags: {
kind: 'share',
'app.id': 'a-1:x',
'bad key': 'v',
ok: 'bad value',
long: 'x'.repeat(129),
},
});
expect(sentMessage(info).headers?.['X-Example-Tags']).toBe(
'kind=share, app.id=a-1:x',
);
});
it('keeps a tags header the caller set', async () => {
const info = await streamClient().sendRaw({
...base,
stream: 'notify',
headers: { 'X-Example-Tags': 'own=1' },
tags: { kind: 'share' },
});
expect(sentMessage(info).headers?.['X-Example-Tags']).toBe('own=1');
});
it('sends no tags header when the stream names none or no tag survives', async () => {
const client = streamClient();
const plain = sentMessage(
await client.sendRaw({
...base,
stream: 'plain',
tags: { kind: 'share' },
}),
);
expect(plain.headers).toEqual({ 'X-Example-Stream': 'plain' });
const dropped = sentMessage(
await client.sendRaw({
...base,
stream: 'notify',
tags: { 'bad key': 'v' },
}),
);
expect(dropped.headers).not.toHaveProperty('X-Example-Tags');
});
it('does not hand stream or tags to the transport', async () => {
const info = await streamClient().sendRaw({
...base,
stream: 'notify',
tags: { kind: 'share' },
});
const raw = JSON.parse((info as { message: string }).message);
expect(raw).not.toHaveProperty('stream');
expect(raw).not.toHaveProperty('tags');
});
it('keeps streams out of the transport options', async () => {
const nodemailer = (await import('nodemailer')).default;
const create = vi.spyOn(nodemailer, 'createTransport');
try {
streamClient();
expect(create).toHaveBeenCalledTimes(1);
const [options] = create.mock.calls[0] as [Record<string, unknown>];
expect(options).not.toHaveProperty('streams');
expect(options).not.toHaveProperty('from');
expect(options).toMatchObject({ jsonTransport: true });
} finally {
create.mockRestore();
}
});
it('forwards the template send options to sendRaw', async () => {
const client = streamClient();
const sendRaw = vi.spyOn(client, 'sendRaw').mockResolvedValue(null);
await client.send(
'[email protected]',
'email_verification_code',
{ code: '1' },
{ stream: 'notify', replyTo: '[email protected]' },
);
expect(sendRaw).toHaveBeenCalledWith(
expect.objectContaining({
stream: 'notify',
replyTo: '[email protected]',
}),
);
});
it('applies the stream from address to a template send', async () => {
const info = await streamClient().send(
'[email protected]',
'email_verification_code',
{ code: '1' },
{ stream: 'notify' },
);
expect(sentMessage(info).from.address).toBe('[email protected]');
});
it('reads a stream only from its own config entry', async () => {
const inherited = Object.create({
base: { from: '[email protected]', headers: { 'X-Example-B': '1' } },
});
const client = startClient({
email: { jsonTransport: true, from: FROM, streams: inherited },
} as unknown as Partial<IConfig>);
const message = sentMessage(
await client.sendRaw({ ...base, stream: 'base' }),
);
expect(message.from.address).toBe('[email protected]');
expect(message.headers ?? {}).toEqual({});
});
it('prints the stream name and no tags when no transport is configured', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const client = new EmailClient({
port: 0,
extensions: [],
} as unknown as IConfig);
await client.sendRaw({
...base,
stream: 'notify',
tags: { kind: 'share' },
});
const printed = warn.mock.calls[0][1] as Record<string, unknown>;
expect(printed).not.toHaveProperty('tags');
expect(printed.stream).toBe('notify');
} finally {
warn.mockRestore();
}
});
});
describe('EmailClient — template rendering', () => {
it('renders the code into both subject and body', async () => {
const client = startClient();
+65 -8
View File
@@ -69,14 +69,50 @@ export interface SendMailOptions {
attachments?: EmailAttachment[];
/** Extra message headers (e.g. List-Unsubscribe), passed to the transport. */
headers?: Record<string, string>;
/** Names the `email.streams` entry whose from address and headers apply. */
stream?: string;
/** Rendered into the stream's `tagsHeader`; ignored when it has none. */
tags?: Record<string, string>;
}
/** Options a templated {@link EmailClient.send} forwards to `sendRaw`. */
export type SendTemplateOptions = Pick<SendMailOptions, 'replyTo' | 'stream'>;
const TAG_PART = /^[A-Za-z0-9_.:-]{1,128}$/;
interface CompiledTemplate {
subject: ReturnType<typeof template>;
html: ReturnType<typeof template>;
text?: ReturnType<typeof template>;
}
/** Pairs whose key or value falls outside `TAG_PART` are dropped. */
const renderTags = (tags: Record<string, string> | undefined): string =>
Object.entries(tags ?? {})
.filter(([name, value]) => TAG_PART.test(name) && TAG_PART.test(value))
.map(([name, value]) => `${name}=${value}`)
.join(', ');
const hasHeader = (
headers: Record<string, string> | undefined,
name: string,
): boolean =>
Object.keys(headers ?? {}).some(
(key) => key.toLowerCase() === name.toLowerCase(),
);
// Header names are case-insensitive: `x-a` from `over` replaces `X-A` in `base`.
const mergeHeaders = (
base: Record<string, string> | undefined,
over: Record<string, string> | undefined,
): Record<string, string> => {
const merged: Record<string, string> = {};
for (const [key, value] of Object.entries(base ?? {})) {
if (!hasHeader(over, key)) merged[key] = value;
}
return { ...merged, ...over };
};
// -- EmailClient ------------------------------------------------------
/**
@@ -111,7 +147,8 @@ export class EmailClient extends PuterClient {
return;
}
this.transport = nodemailer.createTransport(emailConf);
const { streams: _streams, from: _from, ...transportConf } = emailConf;
this.transport = nodemailer.createTransport(transportConf);
console.log('[email] transport configured');
}
@@ -125,7 +162,8 @@ export class EmailClient extends PuterClient {
/**
* Render a template and send it to `to`. `options.replyTo` sets the
* Reply-To header (e.g. so a recipient can respond to the originator of the
* message rather than the no-reply From address).
* message rather than the no-reply From address); `options.stream` is
* passed to {@link sendRaw}.
*
* Returns the transport's send result, or `null` when none is configured —
* {@link sendRaw} drops the message instead of throwing. A caller that
@@ -135,7 +173,7 @@ export class EmailClient extends PuterClient {
to: string,
template: T,
values: Record<string, unknown> = {},
options: { replyTo?: string } = {},
options: SendTemplateOptions = {},
) {
const compiled = this.compiledTemplates[template];
if (!compiled) {
@@ -143,12 +181,12 @@ export class EmailClient extends PuterClient {
}
return await this.sendRaw({
from: this.defaultFrom(),
to,
subject: compiled.subject(values),
html: compiled.html(values),
...(compiled.text ? { text: compiled.text(values) } : {}),
...(options.replyTo ? { replyTo: options.replyTo } : {}),
...(options.stream ? { stream: options.stream } : {}),
});
}
@@ -159,19 +197,38 @@ export class EmailClient extends PuterClient {
* Returns the transport's send result, or `null` when no transport is
* configured (the send is a no-op in that case — callers that must not
* silently drop mail should check `config.email` before composing, or the
* `null` return after).
* `null` return after). A configured `stream` supplies the from address
* when the caller gives none, and headers the caller's own override.
*/
async sendRaw(options: SendMailOptions) {
const { stream: streamName, tags, ...message } = options;
if (!this.transport) {
console.warn(
'[email] attempted to send email without transport. If you need to send email, configure an SMTP transport in your config file (see docs for details). Email content:',
options,
{ ...message, ...(streamName ? { stream: streamName } : {}) },
);
return null;
}
const streams = this.config.email?.streams;
const stream =
streamName && streams && Object.hasOwn(streams, streamName)
? streams[streamName]
: undefined;
let headers = mergeHeaders(stream?.headers, message.headers);
const tagsValue = stream?.tagsHeader ? renderTags(tags) : '';
if (
stream?.tagsHeader &&
tagsValue &&
!hasHeader(message.headers, stream.tagsHeader)
) {
headers = mergeHeaders(headers, {
[stream.tagsHeader]: tagsValue,
});
}
return await this.transport.sendMail({
...options,
from: options.from ?? this.defaultFrom(),
...message,
...(Object.keys(headers).length > 0 ? { headers } : {}),
from: message.from ?? stream?.from ?? this.defaultFrom(),
});
}
+38 -11
View File
@@ -20,6 +20,7 @@ import type {
Response as ExpressResponse,
} from 'express';
import { Actor } from '../../core';
import type { EmailTemplateName } from '../email/templates';
import type { UsageInput } from '../../services/metering/types';
import { FSEntry } from '../../stores/fs/FSEntry';
@@ -240,6 +241,8 @@ export type EventMap = {
trail_id?: string;
/** Device signal forwarded verbatim from the signup request body. */
fingerprint?: string | null;
/** TLS fingerprint of the signup request, when a proxy supplies one. */
tls_fingerprint?: string | null;
/**
* Set by the abuse harness — require SMS phone verification
* post-signup.
@@ -247,6 +250,12 @@ export type EventMap = {
requires_phone_verification?: boolean;
/** Set by the abuse harness — require card verification post-signup. */
requires_card_verification?: boolean;
/**
* The email stream the confirmation message is sent on: lowercase
* letters, digits, `:`, `_` and `-`, up to 64 characters, starting with
* a letter or digit. Any other value is ignored.
*/
email_stream?: string | null;
[key: string]: unknown;
};
'puter.signup.success': {
@@ -298,6 +307,8 @@ export type EventMap = {
message: string | null;
[key: string]: unknown;
};
/** Before notification emails; a listener clears `allow` per recipient. */
'email.notify.validate': EmailNotifyValidateEvent;
// Inbound mail to a feedback address (`abuse@`, `fbl@`, ...), emitted via
// `emitAndWait`. A listener that takes it sets `handled` and it reaches no
// mailbox; otherwise it is routed like any other message.
@@ -736,6 +747,8 @@ export type EventMap = {
'outer.permission.flatInvalidated': {
entries: Array<{ holderUserId: number; permission: string }>;
};
/** Links on these nodes were removed; read them from the primary. */
'outer.share.linksRevoked': { fsentryIds: number[]; uuids: string[] };
/**
* A user's subscription set changed, so every process must drop its cached
* "does this user have any subscriptions" answer. Carries the counter the
@@ -874,8 +887,11 @@ export type EventMap = {
session_uids: string[];
};
/** One access token was revoked; only its own connections should drop. */
'auth.access-token.revoked': { token_uid: string };
/**
* One access token was revoked; only its own connections should drop.
* `outer.*` for the same reason as `outer.auth.sessions.revoked`.
*/
'outer.auth.access-token.revoked': { token_uid: string };
/** Same-cluster eviction for raw realtime WebSockets. */
'pubsub.realtime.auth-revoked': {
@@ -974,6 +990,21 @@ export type EventMap = {
'outer.pubsub.metering.credits-changed': { userUuid: string };
} & IExtensionEventMap;
/** A union of `Names`, each of which has to be an email template. */
type TemplateNames<Names extends EmailTemplateName> = Names;
/** The emails that ask `email.notify.validate`, by template name. */
export type EmailNotifyKind = TemplateNames<
'file_shared_with_you' | 'file_shared_invite' | 'app-user-feedback'
>;
/** Payload for `email.notify.validate`: one kind of mail, all its recipients. */
export type EmailNotifyValidateEvent = {
kind: EmailNotifyKind;
/** `to` is the address as it will be sent; `allow` starts true. */
recipients: Array<{ to: string; allow: boolean }>;
};
/** Payload for `subscription.gate.<surface>` events. */
export type SubscriptionGateEvent = {
actor: Actor;
@@ -1083,11 +1114,10 @@ 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>;
@@ -1098,10 +1128,7 @@ export type MatchingEvents<P extends ListenKey> = P extends `${infer Prefix}.*`
/** Acked, not acked, no socket anywhere, or sockets only in a peer region. */
export type NotifDeliveryOutcome =
| 'received'
| 'unreceived'
| 'no-socket'
| 'remote-only';
'received' | 'unreceived' | 'no-socket' | 'remote-only';
/** One socket's receipt for a live notification. */
export interface NotifDeliveryResponder {
@@ -132,6 +132,28 @@ describe('PreludeClient', () => {
});
});
it('forwards a JA4 TLS fingerprint and drops any other shape', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
const ja4 = 't13d1516h2_8daaf6152771_02713d6af862';
await client.createVerification('+14155550123', {
tls_fingerprint: ja4,
});
await client.createVerification('+14155550123', {
ip: '203.0.113.7',
// A JA3 hash.
tls_fingerprint: 'e7d705a3286e19ea42f587b344ee6865',
});
expect(JSON.parse(fetchMock.mock.calls[0][1].body).signals).toEqual({
ja4_fingerprint: ja4,
});
expect(JSON.parse(fetchMock.mock.calls[1][1].body).signals).toEqual({
ip: '203.0.113.7',
});
});
it('omits the signals object entirely when none are supplied', async () => {
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
const client = makeClient('sk_test');
@@ -25,6 +25,8 @@ const PRELUDE_API_BASE = 'https://api.prelude.dev/v2';
const REQUEST_TIMEOUT_MS = 8000;
/** OTP length — matches the 6-box code UI. Prelude allows 4–8. */
const PRELUDE_CODE_SIZE = 6;
const JA4_SHAPE =
/^[tqd][0-9a-z]{2}[di]\d{4}[0-9a-z]{2}_[0-9a-f]{12}_[0-9a-f]{12}$/;
/**
* Channel Prelude prioritizes for delivery. RCS is far cheaper than SMS, so we
* prefer it by default; Prelude routes to the next reachable channel (SMS) when
@@ -134,6 +136,8 @@ export class PreludeClient extends PuterClient {
ip?: string;
device_id?: string;
user_agent?: string;
/** Forwarded only when it is a JA4 value. */
tls_fingerprint?: string;
dispatch_id?: string;
} = {},
): Promise<{
@@ -168,6 +172,8 @@ export class PreludeClient extends PuterClient {
if (signals.ip) sig.ip = signals.ip;
if (signals.device_id) sig.device_id = signals.device_id;
if (signals.user_agent) sig.user_agent = signals.user_agent;
if (signals.tls_fingerprint && JA4_SHAPE.test(signals.tls_fingerprint))
sig.ja4_fingerprint = signals.tls_fingerprint;
if (Object.keys(sig).length > 0) body.signals = sig;
// dispatch_id is a top-level field, not a member of `signals`.
if (signals.dispatch_id) body.dispatch_id = signals.dispatch_id;
@@ -52,8 +52,8 @@ const APP_READ_LIMIT = {
/**
* Unauthenticated icon serving; no actor to key on, so the bucket is the
* address — and an address is a NAT, a campus or a carrier gateway that can
* hold hundreds of desktops. Each desktop boot pulls the taskbar's icons at
* once, so a single burst from one network is already thousands of requests.
* hold hundreds of desktops. Each dashboard load pulls its app icons at once,
* so a single burst from one network is already thousands of requests.
* Responses are publicly cacheable and usually a redirect, so the ceiling is
* not protecting bandwidth; it is there so a client looping on a broken icon
* can't spin unbounded.
@@ -523,7 +523,7 @@ export class AppController extends PuterController {
};
// Icons are <img src> targets from the GUI (root) AND resolved via
// api_base_url in taskbar payloads. Register on both so either origin
// api_base_url in app summaries. Register on both so either origin
// works without a cross-subdomain redirect.
router.get(
'/app-icon/:app_uid',
@@ -1271,7 +1271,7 @@ describe('AppController app-icon rate limit', () => {
it('sizes the icon bucket for a whole network rather than one desktop', () => {
// Icons are unauthenticated `<img src>` targets, so the only key is
// the address — which one NAT shares across every desktop behind it,
// and each boot pulls the taskbar's icons in a burst.
// and each dashboard load pulls its app icons in a burst.
for (const path of ['/app-icon/:app_uid', '/app-icon/:app_uid/:size']) {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === path,
@@ -23,8 +23,10 @@ import { makeActor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { tokenClaimsDigest } from '../../../gui/src/helpers/guiToken.js';
import {
createTestUser,
lagReadReplica,
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
@@ -350,6 +352,98 @@ describe('revoke-own-access-token over HTTP', () => {
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
});
const ACCESS_TOKEN_QUERIES =
/FROM `access_token_permissions`|`access_token_uid` = \?/;
const tokenUidOf = (token: string) =>
(
env.server.services.token.verify('auth', token) as {
token_uid: string;
}
).token_uid;
it('serves a read URL fetched right after minting while the replica lags', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const replica = await lagReadReplica(env.server, ACCESS_TOKEN_QUERIES);
try {
const readToken = await mintReadToken(owner.token, file.uid);
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
const cached = await env.server.clients.redis.get(
`perms:token:${tokenUidOf(readToken)}`,
);
expect(cached).toBe(JSON.stringify([`fs:${file.uid}:read`]));
} finally {
replica.catchUp();
}
});
it('mints from a just-created personal API token while the replica lags', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const replica = await lagReadReplica(env.server, ACCESS_TOKEN_QUERIES);
try {
const pat = await mintPersonalToken(owner);
const readToken = await mintReadToken(pat, file.uid);
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
} finally {
replica.catchUp();
}
});
it('revokes a just-created token by its uuid while the replica lags', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const { actor } = await env.server.services.auth.authenticate(
owner.token,
);
const replica = await lagReadReplica(env.server, ACCESS_TOKEN_QUERIES);
let readToken: string;
try {
readToken = await mintReadToken(owner.token, file.uid);
await env.server.services.auth.revokeAccessToken(
actor!,
tokenUidOf(readToken),
);
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
} finally {
replica.catchUp();
}
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
expect(await whoamiStatus(readToken)).toBe(401);
});
it('keeps a revoked token dead while the replica still shows it live', async ({
skip,
}) => {
// The frozen replica is a sqlite copy.
skip(process.env.PUTER_TEST_DB_ENGINE?.toLowerCase() === 'postgres');
const owner = env.users.user;
const file = await makeFile(owner);
const { actor } = await env.server.services.auth.authenticate(
owner.token,
);
const readToken = await mintReadToken(owner.token, file.uid);
const tokenUid = tokenUidOf(readToken);
const replica = await lagReadReplica(env.server, ACCESS_TOKEN_QUERIES, {
mode: 'frozen',
});
try {
await env.server.services.auth.revokeAccessToken(actor!, tokenUid);
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
expect(
await env.server.stores.permission.listAccessTokenPerms(
tokenUid,
),
).toEqual([]);
expect(
await env.server.clients.redis.get(`perms:token:${tokenUid}`),
).toBeNull();
} finally {
replica.catchUp();
}
expect(await whoamiStatus(readToken)).toBe(401);
});
// These three run last in the file's shared `env.users.user`: each one
// revokes a session belonging to `owner.token` / `owner.workerToken`, so
// later tests can't rely on those credentials still being live.
@@ -393,7 +487,10 @@ describe('revoke-own-access-token over HTTP', () => {
it('a revoked GUI session still gets a reauth_token', async () => {
const owner = env.users.user;
const decoded = env.server.services.token.verify('auth', owner.token) as {
const decoded = env.server.services.token.verify(
'auth',
owner.token,
) as {
session_uid: string;
};
await env.server.stores.session.removeByUuid(decoded.session_uid);
@@ -559,7 +656,6 @@ describe('2FA enrollment over HTTP', () => {
expect(enabled.status).toBe(200);
expect((await otpState(account.userId)).enabled).toBe(true);
});
});
/**
@@ -714,4 +810,67 @@ describe('session token helpers over HTTP', () => {
'puter_auth_token=',
);
});
describe("the shell's guiTokenDigest", () => {
/** The session cookie `/session/sync-cookie` sets for `token`. */
const sessionCookie = async (token: string) => {
const sync = await get('/session/sync-cookie', token);
expect(sync.status).toBe(204);
const value = /puter_auth_token=([^;]+)/.exec(
sync.headers.get('set-cookie') ?? '',
)?.[1];
expect(value).toBeTruthy();
return `puter_auth_token=${value}`;
};
/** What the shell at `path` renders for `guiTokenDigest`. */
const shellDigest = async (path: string, cookie?: string) => {
const res = await fetch(new URL(path, env.origin), {
headers: cookie ? { cookie } : {},
});
expect(res.status).toBe(200);
const html = await res.text();
const match = /"guiTokenDigest":(null|"[0-9a-f]{32}")/.exec(html);
if (!match) {
expect(html).not.toContain('guiTokenDigest');
return undefined;
}
return JSON.parse(match[1]) as string | null;
};
it("names the token /get-gui-token hands over for the session cookie", async () => {
const { token } = await signupTemp();
const cookie = await sessionCookie(token);
const gui = await fetch(new URL('/get-gui-token', env.origin), {
headers: { cookie },
});
expect(gui.status).toBe(200);
const { token: guiToken } = (await gui.json()) as { token: string };
const digest = await shellDigest('/', cookie);
expect(digest).toBe(await tokenClaimsDigest(guiToken));
// The token the browser stored at sign-up is for the same session.
expect(await tokenClaimsDigest(token)).toBe(digest);
});
it('is null without a session cookie, even with a token in the URL', async () => {
const { token } = await signupTemp();
expect(await shellDigest('/')).toBe(null);
expect(
await shellDigest(`/?auth_token=${encodeURIComponent(token)}`),
).toBe(null);
});
it('is left out when the session cookie does not resolve to a session', async () => {
const { token } = await signupTemp();
const cookie = await sessionCookie(token);
await env.server.services.auth.revokeSession(claims(token).session_uid);
expect(await shellDigest('/', cookie)).toBe(undefined);
expect(await shellDigest('/', 'puter_auth_token=garbage')).toBe(
undefined,
);
});
});
});
@@ -30,6 +30,7 @@
import { installSeatStub } from '../../testFixtures/seatStub.js';
import bcrypt from 'bcrypt';
import { inspect } from 'node:util';
import jwt from 'jsonwebtoken';
import { TOTP } from 'otpauth';
import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
@@ -721,6 +722,36 @@ describe('AuthController.handleSignup', () => {
expect(audit.ip_fwd).toBe(forged);
});
it('records the request TLS fingerprint on the validate hook and the user row', async () => {
const username = `s_${uniq()}`;
const tlsFingerprint = 't13d1516h2_8daaf6152771_02713d6af862';
let seenTlsFingerprint: unknown;
await withSignupValidateOverride(
(event) => {
seenTlsFingerprint = (event as { tls_fingerprint?: unknown })
.tls_fingerprint;
},
async () => {
await controller.handleSignup(
{
...makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
tlsFingerprint,
},
makeRes(),
);
},
);
expect(seenTlsFingerprint).toBe(tlsFingerprint);
const persisted = await server.stores.user.getByUsername(username);
expect(persisted!.signup_tls_fingerprint).toBe(tlsFingerprint);
});
it('forces phone verification on every signup when always_require_phone_verification is set', async () => {
const cfg = (controller as { config: Record<string, unknown> }).config;
const prev = cfg.always_require_phone_verification;
@@ -1416,7 +1447,7 @@ describe('AuthController.handleLogin', () => {
is_user_token: true,
});
expect(body.user.directories).toBeTypeOf('object');
expect(Array.isArray(body.user.taskbar_items)).toBe(true);
expect(body.user).not.toHaveProperty('taskbar_items');
});
// A deleted row still answering from cache reaches the session INSERT.
@@ -4765,14 +4796,24 @@ describe('AuthController.handleSendConfirmEmail', () => {
).rejects.toMatchObject({ statusCode: 403 });
});
it('rotates the email_confirm_code and returns {} on success', async () => {
it('rotates the email_confirm_code and reports the send', async () => {
const { user, actor } = await makeUserAndActor();
const before = await server.stores.user.getById(user.id, {
force: true,
});
const send = vi
.spyOn(server.clients.email, 'send')
.mockResolvedValue({ messageId: 'm' } as never);
const res = makeRes();
await controller.handleSendConfirmEmail(makeReq({}, { actor }), res);
expect(res.body).toEqual({});
try {
await controller.handleSendConfirmEmail(
makeReq({}, { actor }),
res,
);
} finally {
send.mockRestore();
}
expect(res.body).toEqual({ sent: true });
const after = await server.stores.user.getById(user.id, {
force: true,
});
@@ -4782,7 +4823,7 @@ describe('AuthController.handleSendConfirmEmail', () => {
// The resend is the recovery path for a lost code, so it must not fail
// silently either.
it('alarms — and still returns {} — when the resend send throws', async () => {
it('alarms, and still answers 200 with sent: false, when the resend send throws', async () => {
const { actor } = await makeUserAndActor();
const alarm = vi
.spyOn(server.clients.alarm, 'create')
@@ -4797,7 +4838,8 @@ describe('AuthController.handleSendConfirmEmail', () => {
res,
);
// Not the caller's fault; still a 200.
expect(res.body).toEqual({});
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ sent: false });
const raised = alarm.mock.calls.find(
(c) => c[0] === 'auth:confirmation-email-send-failed:resend',
@@ -7518,6 +7560,46 @@ describe('AuthController.handleSaveAccount', () => {
};
};
it('sends the confirmation code on the account confirmation stream', async () => {
const { user, actor } = await makeTempActor();
await server.stores.user.updateMetadata(user.id, {
emailStream: 'review',
});
const send = vi
.spyOn(server.clients.email, 'send')
.mockResolvedValue(null);
const alarm = vi
.spyOn(server.clients.alarm, 'create')
.mockImplementation(() => undefined);
try {
const username = `s_${uniq()}`;
await controller.handleSaveAccount(
makeReq(
{
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
},
{ actor },
),
makeRes(),
);
expect(send).toHaveBeenCalledWith(
`${username}@test.local`,
'email_verification_code',
expect.anything(),
{ stream: 'review' },
);
// The dropped send is reported under its own stage.
expect(alarm.mock.calls.map((c) => c[0])).toEqual([
'auth:confirmation-email-send-failed:save-account',
]);
} finally {
send.mockRestore();
alarm.mockRestore();
}
});
it('rejects non-temp accounts with 400', async () => {
const { actor } = await makeUserAndActor();
await expect(
@@ -8293,11 +8375,10 @@ describe('AuthController session endpoints', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('revoke-session: 403 when revoking someone else’s session', async () => {
it('revoke-session: 404 when revoking someone else’s session', async () => {
const { user: u1 } = await makeUserAndActor();
const { actor: a2 } = await makeUserAndActor();
// Create a real session for u1 so the lookup succeeds, then attempt
// to revoke it as a2 — must 403.
// A real session of u1, revoked as a2: answers as a missing one.
const sessionRes = await server.services.auth.createSessionToken(
u1,
{},
@@ -8310,7 +8391,7 @@ describe('AuthController session endpoints', () => {
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
).rejects.toMatchObject({ statusCode: 404 });
});
it('rename-session: 400 when uuid param is missing', async () => {
@@ -9510,14 +9591,18 @@ describe('AuthController.handleSignup additional branches', () => {
},
async () => {
const username = `cefail_${uniq()}`;
const res = makeRes();
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
res,
);
expect(res.body).toMatchObject({
confirmation_email_sent: false,
});
},
);
@@ -9538,6 +9623,93 @@ describe('AuthController.handleSignup additional branches', () => {
});
});
describe('AuthController.handleSignup confirmation email outcome', () => {
const signupWithSend = async (send: () => Promise<unknown>) => {
const spy = vi
.spyOn(server.clients.email, 'send')
.mockImplementation(send as never);
const alarm = vi
.spyOn(server.clients.alarm, 'create')
.mockImplementation(() => undefined);
const res = makeRes();
const username = `ceo_${uniq()}`;
let alarms: string[] = [];
try {
await withSignupValidateOverride(
(event) => {
event.requires_email_confirmation = true;
},
() =>
controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
res,
),
);
alarms = alarm.mock.calls.map((c) => String(c[0]));
} finally {
spy.mockRestore();
alarm.mockRestore();
}
return {
body: res.body as {
proceed: boolean;
confirmation_email_sent?: boolean;
user: { requires_email_confirmation: boolean };
},
alarms,
};
};
it('reports a send that throws and still completes the signup', async () => {
const { body, alarms } = await signupWithSend(async () => {
throw new Error('refused');
});
expect(body.proceed).toBe(true);
expect(body.confirmation_email_sent).toBe(false);
expect(body.user.requires_email_confirmation).toBeTruthy();
expect(alarms).toContain('auth:confirmation-email-send-failed:signup');
});
it('reports a send the transport accepted', async () => {
const { body } = await signupWithSend(async () => ({ messageId: 'm' }));
expect(body.confirmation_email_sent).toBe(true);
});
it('keeps the confirmation stream out of the returned user details', async () => {
const res = makeRes();
const username = `ces_${uniq()}`;
await withSignupValidateOverride(
(event) => {
(event as Record<string, unknown>).email_stream = 'review';
},
() =>
controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
res,
),
);
const stored = await server.stores.user.getByUsername(username);
expect(stored!.metadata?.emailStream).toBe('review');
const details = (res.body as { user: { metadata?: object } }).user;
expect(details.metadata ?? {}).not.toHaveProperty('emailStream');
});
it('leaves the field out when no confirmation email is due', async () => {
const res = makeRes();
await controller.handleSignup(makeReq({ is_temp: true }), res);
expect(res.body).toMatchObject({ proceed: true });
expect(res.body).not.toHaveProperty('confirmation_email_sent');
});
});
describe('AuthController.handleSendPassRecoveryEmail additional branches', () => {
it('rejects an invalid email format with 400 (when no username supplied)', async () => {
await expect(
@@ -10082,6 +10254,77 @@ describe('AuthController.handleRevokeSession additional branches', () => {
expect(Array.isArray(body.sessions)).toBe(true);
});
const expiredSessionOf = async (user: { id: number }) => {
const row = await server.stores.session.create(user.id, {
expires_at: Math.floor(Date.now() / 1000) - 1,
});
return row.uuid as string;
};
it('revokes an expired session the caller owns', async () => {
const { user, actor } = await makeUserAndActor();
const uuid = await expiredSessionOf(user);
const res = makeRes();
await controller.handleRevokeSession(makeReq({ uuid }, { actor }), res);
const body = res.body as { sessions: Array<{ uuid: string }> };
expect(body.sessions.map((s) => s.uuid)).not.toContain(uuid);
const rows = await server.clients.db.read(
'SELECT `revoked_at` FROM `sessions` WHERE `uuid` = ?',
[uuid],
);
expect(rows[0].revoked_at).not.toBeNull();
});
it('revoking an expired session ends its live child token', async () => {
const { user, actor } = await makeUserAndActor();
const parent = await expiredSessionOf(user);
const child = await server.stores.session.getOrCreateGodmodeToken(
user.id,
{
parentSessionUid: parent,
appUid: `app-${uuidv4()}`,
expiresAt: Math.floor(Date.now() / 1000) + 3600,
maxAgeSeconds: 3600,
label: 'Godmode app',
auth_id: user.uuid,
},
);
const childUuid = child!.row.uuid as string;
expect(await server.stores.session.getByUuid(childUuid)).toBeTruthy();
await controller.handleRevokeSession(
makeReq({ uuid: parent }, { actor }),
makeRes(),
);
expect(await server.stores.session.getByUuid(childUuid)).toBeNull();
});
it('answers 404 for an expired session of another user', async () => {
const { user: owner } = await makeUserAndActor();
const { actor } = await makeUserAndActor();
const uuid = await expiredSessionOf(owner);
await expect(
controller.handleRevokeSession(
makeReq({ uuid }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('answers 404 for a session that is already revoked', async () => {
const { user, actor } = await makeUserAndActor();
const row = await server.stores.session.create(user.id);
await server.stores.session.removeByUuid(row.uuid);
await expect(
controller.handleRevokeSession(
makeReq({ uuid: row.uuid }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('refuses to revoke the caller’s OWN current session row (400)', async () => {
// Invariant: a self-revoke leaves the client in an ambiguous
// identity state because the response can't write fresh auth
@@ -10562,10 +10805,10 @@ describe('AuthController.loginWait audience binding', () => {
// party holding the session id is somewhere else entirely.
const token = await mintAppToken(actor, OPENER);
const { res, outcome } = await waitWithRelay(
uuidv4(),
{ origin: 'https://evil.test' },
token,
const session = uuidv4();
const [{ res, outcome }] = await runWaits(
[{ session, origin: 'https://evil.test' }],
{ during: () => setToken(session, token) },
);
// Same 408 the empty path returns — a mismatched caller must not be
// able to tell "nothing arrived" from "something arrived for someone
@@ -10641,10 +10884,10 @@ describe('AuthController.loginWait audience binding', () => {
);
const guiToken = (loginRes.body as { token: string }).token;
const { res, outcome } = await waitWithRelay(
uuidv4(),
{ origin: OPENER },
guiToken,
const session = uuidv4();
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{ during: () => setToken(session, guiToken) },
);
expect(outcome).toMatchObject({ statusCode: 408 });
expect(res.body).toBeUndefined();
@@ -10659,14 +10902,406 @@ describe('AuthController.loginWait audience binding', () => {
{ keyid: 'v2' },
);
const { res, outcome } = await waitWithRelay(
uuidv4(),
{ origin: OPENER },
forged,
const session = uuidv4();
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{ during: () => setToken(session, forged) },
);
expect(outcome).toMatchObject({ statusCode: 408 });
expect(res.body).toBeUndefined();
});
/** Post a token to `/login/set` with no wait running. */
const setToken = (session: string, token: string) =>
controller.loginSet(makeReq({ session, auth_token: token }), makeRes());
/**
* Run waits on fake timers so the 10 s window passes without real waiting.
* `during` runs once every wait has subscribed; `now` pins the clock.
*/
const runWaits = async (
calls: Array<{ session: string; origin: string; clientGone?: boolean }>,
opts: { now?: number; during?: () => Promise<unknown> } = {},
) => {
const subscribed = vi.spyOn(eventClient, 'on');
vi.useFakeTimers({
toFake:
opts.now === undefined
? ['setTimeout']
: ['setTimeout', 'Date'],
});
if (opts.now !== undefined) vi.setSystemTime(opts.now);
const tick = () => new Promise((r) => setImmediate(r));
try {
let pending = calls.length;
const runs = calls.map(({ session, origin, clientGone }) => {
const res = makeRes();
const req = makeReq({ session }, { headers: { origin } });
if (clientGone) {
Object.assign(req.socket, { destroyed: true });
}
const outcome = controller
.loginWait(req, res)
.then(
() => 'ok' as const,
(e: unknown) => e,
)
.finally(() => {
pending--;
});
return { res, outcome };
});
const allSubscribed = () =>
calls.every(({ session }) =>
subscribed.mock.calls.some(
([key]) => key === `pubsub.login.${session}`,
),
);
for (let i = 0; i < 200 && pending > 0 && !allSubscribed(); i++) {
await tick();
}
for (let i = 0; i < 20; i++) await tick();
await opts.during?.();
for (let i = 0; i < 200 && pending > 0; i++) {
await tick();
await vi.advanceTimersByTimeAsync(1000);
}
return await Promise.all(
runs.map(async ({ res, outcome }) => ({
res,
outcome: await outcome,
})),
);
} finally {
vi.useRealTimers();
subscribed.mockRestore();
}
};
const waitAlone = async (session: string, origin: string, now?: number) =>
(await runWaits([{ session, origin }], { now }))[0];
it('delivers a token set before the wait subscribes', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
await setToken(session, token);
const { res, outcome } = await waitAlone(session, OPENER);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it('delivers a token set between one wait timing out and the next', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const first = await waitAlone(session, OPENER);
expect(first.outcome).toMatchObject({ statusCode: 408 });
await setToken(session, token);
const { res, outcome } = await waitAlone(session, OPENER);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it('hands a token out only once', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
await setToken(session, token);
expect((await waitAlone(session, OPENER)).outcome).toBe('ok');
const again = await waitAlone(session, OPENER);
expect(again.outcome).toMatchObject({ statusCode: 408 });
expect(again.res.body).toBeUndefined();
});
it('withholds a stored token from a mismatched Origin and keeps it for the matching one', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
await setToken(session, token);
const other = await waitAlone(session, 'https://other.test');
expect(other.outcome).toMatchObject({ statusCode: 408 });
expect(other.res.body).toBeUndefined();
const { res, outcome } = await waitAlone(session, OPENER);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it('drops a stored token after a couple of minutes', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const early = uuidv4();
const late = uuidv4();
await setToken(early, token);
await setToken(late, token);
const start = Date.now();
expect((await waitAlone(early, OPENER, start + 110_000)).outcome).toBe(
'ok',
);
const expired = await waitAlone(late, OPENER, start + 121_000);
expect(expired.outcome).toMatchObject({ statusCode: 408 });
});
it('keeps waiting past a stored token for another app and delivers the real one', async () => {
const { actor } = await makeUserAndActor();
const otherToken = await mintAppToken(actor, 'https://other.test');
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
await setToken(session, otherToken);
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{
during: () => setToken(session, token),
},
);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it('still relays to a subscribed wait when storing the token fails', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const failingSet = vi
.spyOn(server.clients.redis, 'set')
.mockRejectedValueOnce(new Error('unavailable'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
let setRes: MockRes | undefined;
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{
during: async () => {
setRes = makeRes();
await controller.loginSet(
makeReq({ session, auth_token: token }),
setRes,
);
},
},
);
expect(setRes?.body).toEqual({ success: true });
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
} finally {
failingSet.mockRestore();
warn.mockRestore();
}
});
it('removes the stored copy once a subscribed wait takes the token', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const { outcome } = await waitWithRelay(
session,
{ origin: OPENER },
token,
);
expect(outcome).toBe('ok');
const later = await waitAlone(session, OPENER);
expect(later.outcome).toMatchObject({ statusCode: 408 });
});
it('hands a stored token to only one of two concurrent waits', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
await setToken(session, token);
const results = await runWaits([
{ session, origin: OPENER },
{ session, origin: OPENER },
]);
const outcomes = results.map((r) => r.outcome);
expect(outcomes.filter((o) => o === 'ok')).toHaveLength(1);
expect(outcomes.filter((o) => o !== 'ok')).toEqual([
expect.objectContaining({ statusCode: 408 }),
]);
});
it('relays a token to every wait already subscribed', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const results = await runWaits(
[
{ session, origin: OPENER },
{ session, origin: OPENER },
],
{ during: () => setToken(session, token) },
);
expect(results.map((r) => r.outcome)).toEqual(['ok', 'ok']);
for (const { res } of results) {
expect((res.body as { auth_token: string }).auth_token).toBe(token);
}
});
it('keeps the stored copy when the subscribed wait has lost its client', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
await runWaits([{ session, origin: OPENER, clientGone: true }], {
during: () => setToken(session, token),
});
const { res, outcome } = await waitAlone(session, OPENER);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it('still relays to a subscribed wait when the stored copy cannot be read or removed', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const failingGet = vi
.spyOn(server.clients.redis, 'get')
.mockRejectedValue(new Error('unavailable'));
const failingEval = vi
.spyOn(server.clients.redis, 'eval')
.mockRejectedValue(new Error('unavailable'));
try {
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{ during: () => setToken(session, token) },
);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
} finally {
failingGet.mockRestore();
failingEval.mockRestore();
}
});
it('answers a subscribed wait when a second token is set right after the first', async () => {
const { actor } = await makeUserAndActor();
const first = await mintAppToken(actor, OPENER);
// A later `iat` makes the second token differ from the first.
await new Promise((r) => setTimeout(r, 1100));
const second = await mintAppToken(actor, OPENER);
expect(second).not.toBe(first);
const session = uuidv4();
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{
during: async () => {
await setToken(session, first);
await setToken(session, second);
},
},
);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(first);
});
it('keeps a subscribed wait listening past a token for another app', async () => {
const { actor } = await makeUserAndActor();
const otherToken = await mintAppToken(actor, 'https://other.test');
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const [{ res, outcome }] = await runWaits(
[{ session, origin: OPENER }],
{
during: async () => {
await setToken(session, otherToken);
await setToken(session, token);
},
},
);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it("keeps a stored token when a later set carries another app's token or an unsigned one", async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const otherToken = await mintAppToken(actor, 'https://other.test');
const session = uuidv4();
await setToken(session, token);
await setToken(session, otherToken);
await setToken(session, 'x'.repeat(100_000));
const { res, outcome } = await waitAlone(session, OPENER);
expect(outcome).toBe('ok');
expect((res.body as { auth_token: string }).auth_token).toBe(token);
});
it('stores only a token this server signed for an app', async () => {
const { actor } = await makeUserAndActor();
const real = await mintAppToken(actor, OPENER);
const forged = jwt.sign(
jwt.decode(real) as object,
'not-the-server-secret',
{ keyid: 'v2' },
);
const set = vi.spyOn(server.clients.redis, 'set');
try {
const session = uuidv4();
await setToken(session, forged);
await setToken(session, 'x'.repeat(100_000));
expect(set).not.toHaveBeenCalled();
await setToken(session, real);
expect(set).toHaveBeenCalledTimes(1);
} finally {
set.mockRestore();
}
});
it('leaves the token out of the warning when storing it fails', async () => {
const { actor } = await makeUserAndActor();
const token = await mintAppToken(actor, OPENER);
const session = uuidv4();
const failure = Object.assign(new Error('ERR unavailable'), {
command: { name: 'set', args: [session, token, 'EX', '120'] },
});
const failingSet = vi
.spyOn(server.clients.redis, 'set')
.mockRejectedValueOnce(failure);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const res = makeRes();
await controller.loginSet(
makeReq({ session, auth_token: token }),
res,
);
expect(res.body).toEqual({ success: true });
expect(warn).toHaveBeenCalled();
const logged = warn.mock.calls
.flat()
.map((arg) => inspect(arg, { depth: 10 }))
.join(' ');
expect(logged).not.toContain(token);
} finally {
failingSet.mockRestore();
warn.mockRestore();
}
});
it('answers 400 when auth_token is not a string', async () => {
await expect(
controller.loginSet(
makeReq({ session: uuidv4(), auth_token: { nested: true } }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
});
});
// -- Batch grant / revoke + cross-app data grants -----------------------
@@ -11023,3 +11658,100 @@ describe('AuthController — a seat address nobody confirmed', () => {
expect(await emailOf(seat.id)).toBeNull();
});
});
describe('AuthController account mail streams', () => {
const spySend = () =>
vi.spyOn(server.clients.email, 'send').mockResolvedValue(null);
it('sends password recovery on the auth stream', async () => {
const { user } = await makeUserAndActor();
const send = spySend();
try {
await controller.handleSendPassRecoveryEmail(
makeReq({ email: user.email! }),
makeRes(),
);
expect(send).toHaveBeenCalledWith(
user.email,
'email_password_recovery',
expect.anything(),
{ stream: 'auth' },
);
} finally {
send.mockRestore();
}
});
it('sends the password change notice on the auth stream', async () => {
const { user, actor } = await makeUserAndActor();
const req = makeReq({ new_pass: 'a-fresh-password-456' }, { actor });
(req as unknown as { userProtected: unknown }).userProtected = { user };
const send = spySend();
try {
await controller.handleChangePassword(req, makeRes());
expect(send).toHaveBeenCalledWith(
user.email,
'password_change_notification',
expect.anything(),
{ stream: 'auth' },
);
} finally {
send.mockRestore();
}
});
it('sends both change-email messages on the auth stream', async () => {
const { user, actor } = await makeUserAndActor();
const newEmail = `cs_${uniq()}@test.local`;
const send = spySend();
const sendRaw = vi
.spyOn(server.clients.email, 'sendRaw')
.mockResolvedValue(null);
try {
await controller.handleChangeEmail(
makeReq({ new_email: newEmail }, { actor }),
makeRes(),
);
expect(send).toHaveBeenCalledWith(
newEmail,
'email_verification_link',
expect.anything(),
{ stream: 'auth' },
);
expect(sendRaw).toHaveBeenCalledWith(
expect.objectContaining({ to: user.email, stream: 'auth' }),
);
} finally {
send.mockRestore();
sendRaw.mockRestore();
}
});
it('sends the 2FA enable and disable notices on the auth stream', async () => {
const { user, actor } = await makeUserAndActor({ email_confirmed: 1 });
const send = spySend();
try {
const { secret } = await setup2fa(actor);
await enable2fa(actor, { code: liveTotp(user.username, secret) });
expect(send).toHaveBeenCalledWith(
user.email,
'enabled_2fa',
{ username: user.username },
{ stream: 'auth' },
);
await controller.handleDisable2fa(
makeReq({ code: liveTotp(user.username, secret) }, { actor }),
makeRes(),
);
expect(send).toHaveBeenCalledWith(
user.email,
'disabled_2fa',
{ username: user.username },
{ stream: 'auth' },
);
} finally {
send.mockRestore();
}
});
});
+182 -86
View File
@@ -71,6 +71,7 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isStorableEmail } from '../../util/email.js';
import { isGodmodeApp } from '../../util/godmodeApps.js';
import { parsePhone } from '../../util/phone.js';
import { deleteIfEqual } from '../../util/redisLock.js';
import {
bonusCodeInvalidError,
checkSignupBonus,
@@ -143,6 +144,12 @@ const DISPATCH_ID_MAX_LENGTH = 128;
// One name for the flag, so the write and the read cannot drift apart.
const APP_AUTHENTICATED_FLAG = 'flag:app-is-authenticated';
// Long enough to span the gap between one `/login/wait` and the client's next.
const LOGIN_RELAY_TTL_SECONDS = 120;
// Keyed by app too, so a token for one app never replaces another app's.
const loginRelayKey = (session: string, appUid: string) =>
`login:relay:${session}:${appUid}`;
// -- Post-login route limits -----------------------------------------
//
// The credential legs above (login, signup, recovery, confirmation) each
@@ -345,36 +352,43 @@ export class AuthController extends PuterController {
const expectedAppUid =
await this.services.auth.appUidFromOrigin(reqOrigin);
const { resolve, promise } = Promise.withResolvers<void>();
let token: string | null = null;
const listener = (_key: string, value: { authtoken: string }) => {
token = value.authtoken;
resolve();
};
this.clients.event.on(`pubsub.login.${session}`, listener);
const timeout = new Promise<void>((resolve) =>
setTimeout(resolve, 10000),
);
await Promise.race([promise, timeout]);
this.clients.event.off(`pubsub.login.${session}`, listener);
if (!token) {
throw new HttpError(408, 'Request timeout.', {
legacyCode: 'request_timeout',
});
}
const relayKey = loginRelayKey(session, expectedAppUid);
const { resolve, promise } = Promise.withResolvers<string>();
// Audience check. The postMessage hand-off this relay stands in for
// is origin-bound for free — it posts with `targetOrigin`, so a page
// can only ever receive a token minted for *itself*. Delivering
// server-side dropped that binding; this restores it. Without it a
// popup talked into minting for app X (see `trustsOpenerOriginParam`
// in the GUI) hands X's token to whoever holds the session id.
if (!this.#tokenIsForApp(token, expectedAppUid)) {
// Deliberately the same 408 the no-token path returns: a caller
// learns only that nothing arrived for them, not that a token
// for a different app went past.
// server-side dropped that binding; this restores it. Any other token
// is passed over and the wait runs on, so a caller learns only that
// nothing arrived for them.
const listener = (_key: string, value: { authtoken: string }) => {
if (this.#relayedTokenAppUid(value.authtoken) === expectedAppUid) {
resolve(value.authtoken);
}
};
this.clients.event.on(`pubsub.login.${session}`, listener);
let token: string | null;
try {
// Read after subscribing, so a token set before this wait is still delivered.
token = await this.#takeStoredLoginToken(relayKey, expectedAppUid);
if (!token) {
const timeout = new Promise<null>((resolve) =>
setTimeout(resolve, 10000, null),
);
token = await Promise.race([promise, timeout]);
// A wait whose client went away leaves the copy for the next one.
if (token && !req.socket?.destroyed) {
await deleteIfEqual(
this.clients.redis,
relayKey,
token,
).catch(() => false);
}
}
} finally {
this.clients.event.off(`pubsub.login.${session}`, listener);
}
if (!token) {
throw new HttpError(408, 'Request timeout.', {
legacyCode: 'request_timeout',
});
@@ -386,24 +400,46 @@ export class AuthController extends PuterController {
}
/**
* Whether a relayed token is an app-under-user token minted for
* `expectedAppUid`. Verifies the signature — an unverified decode would let
* a caller relay a token whose claims it wrote itself.
* Take the copy `/login/set` kept for this app, once. Null when there is
* none, another wait took it, or Redis fails; the wait then just listens.
*/
#tokenIsForApp(token: string, expectedAppUid: string): boolean {
async #takeStoredLoginToken(
relayKey: string,
expectedAppUid: string,
): Promise<string | null> {
try {
const kept = await this.clients.redis.get(relayKey);
if (!kept || this.#relayedTokenAppUid(kept) !== expectedAppUid) {
return null;
}
const took = await deleteIfEqual(
this.clients.redis,
relayKey,
kept,
);
return took ? kept : null;
} catch {
return null;
}
}
/**
* The app a relayed app-under-user token was minted for, or null. Verifies
* the signature — an unverified decode would let a caller relay a token
* whose claims it wrote itself.
*/
#relayedTokenAppUid(token: string): string | null {
try {
const payload = this.services.token.verify<{
type?: string;
app_uid?: string;
}>('auth', token);
return (
payload.type === 'app-under-user' &&
!!payload.app_uid &&
payload.app_uid === expectedAppUid
);
return payload.type === 'app-under-user' && payload.app_uid
? payload.app_uid
: null;
} catch {
// Malformed, expired, or signed with a key we don't hold.
return false;
return null;
}
}
@Post('/login/set', {
@@ -418,12 +454,34 @@ export class AuthController extends PuterController {
})
async loginSet(req: Request, res: Response) {
const { session, auth_token } = req.body ?? {};
if (!session || !auth_token || !validateUuid(session)) {
if (
!session ||
typeof auth_token !== 'string' ||
!auth_token ||
!validateUuid(session)
) {
throw new HttpError(400, 'session and auth_token are required.', {
legacyCode: 'bad_request',
});
}
// Only a token a wait could deliver is kept: one this server signed for an app.
const appUid = this.#relayedTokenAppUid(auth_token);
if (appUid) {
try {
await this.clients.redis.set(
loginRelayKey(session, appUid),
auth_token,
'EX',
LOGIN_RELAY_TTL_SECONDS,
);
} catch (e) {
console.warn(
'[login-set] storing the relayed token failed:',
e instanceof Error ? e.message : String(e),
);
}
}
this.clients.event.emit(
`pubsub.login.${session}`,
{
@@ -882,14 +940,19 @@ export class AuthController extends PuterController {
if (!bonusCode) throw bonusCodeInvalidError();
}
const user = await this.services.signup.signup({
req,
body,
isTemp: is_temp,
fingerprint,
bonusCode,
const { user, confirmationEmailSent } =
await this.services.signup.signup({
req,
body,
isTemp: is_temp,
fingerprint,
bonusCode,
});
await this.#completeLogin(req, res, user, undefined, {
...(confirmationEmailSent !== undefined
? { confirmation_email_sent: confirmationEmailSent }
: {}),
});
await this.#completeLogin(req, res, user);
}
/**
@@ -1028,10 +1091,12 @@ export class AuthController extends PuterController {
email_confirm_code: code,
});
await this.services.signup.sendConfirmationEmail(user, 'resend', {
code,
});
res.json({});
const sent = await this.services.signup.sendConfirmationEmail(
user,
'resend',
{ code },
);
res.json({ sent });
}
@Post('/confirm-email', {
@@ -1445,6 +1510,7 @@ export class AuthController extends PuterController {
ip,
device_id: req.deviceFingerprint ?? undefined,
user_agent: userAgent,
tls_fingerprint: req.tlsFingerprint,
dispatch_id: dispatchId,
},
);
@@ -2019,6 +2085,7 @@ export class AuthController extends PuterController {
user.email,
'email_password_recovery',
{ link },
{ stream: 'auth' },
);
} catch (e) {
console.warn('[send-pass-recovery-email] send failed:', e);
@@ -2282,6 +2349,7 @@ export class AuthController extends PuterController {
{
username: user.username,
},
{ stream: 'auth' },
);
} catch (e) {
console.warn('[change-password] notification send failed:', e);
@@ -2507,6 +2575,7 @@ export class AuthController extends PuterController {
new_email,
'email_verification_link',
{ link },
{ stream: 'auth' },
);
} catch (e) {
console.warn('[change-email] new-address email failed:', e);
@@ -2514,15 +2583,8 @@ export class AuthController extends PuterController {
// Notify the old address too
if (user.email) {
try {
await (
this.clients.email as unknown as {
sendRaw: (opts: {
to: string;
subject: string;
text: string;
}) => Promise<unknown>;
}
).sendRaw({
await this.clients.email.sendRaw({
stream: 'auth',
to: user.email,
subject: 'Your Puter email change was requested',
text: `A change to ${new_email} was requested on your account. If this wasn't you, please contact support.`,
@@ -2811,18 +2873,11 @@ export class AuthController extends PuterController {
username,
});
// Send confirmation email
if (this.clients.email) {
try {
await this.clients.email.send(
email,
'email_verification_code',
{ code: email_confirm_code },
);
} catch (e) {
console.warn('[save-account] confirmation email failed:', e);
}
}
await this.services.signup.sendConfirmationEmail(
{ ...user, username, email },
'save-account',
{ code: email_confirm_code },
);
try {
this.clients.event?.emit(
@@ -3585,21 +3640,18 @@ export class AuthController extends PuterController {
{ legacyCode: 'bad_request' },
);
}
// `getByUuid` returns null when the row is missing, already
// soft-revoked, or past `expires_at` — surface as 404 so a stale
// manage-sessions UI doesn't 500 when it clicks revoke on a row
// that already went away.
const session = await this.stores.session.getByUuid(uuid);
if (!session) {
// Missing, already revoked and other users' rows all answer 404; an
// expired row is still revoked so its children go with it.
const session = await this.stores.session.getByUuidAny(uuid);
if (
!session ||
session.revoked_at != null ||
session.user_id !== req.actor!.user.id
) {
throw new HttpError(404, 'Session not found', {
legacyCode: 'not_found',
});
}
if (session.user_id !== req.actor!.user.id) {
throw new HttpError(403, 'Can only revoke your own sessions', {
legacyCode: 'unauthorized',
});
}
await this.services.auth.revokeSession(uuid);
const sessions = await this.services.auth.listSessions(req.actor!);
res.json({ sessions });
@@ -4275,9 +4327,12 @@ export class AuthController extends PuterController {
if (this.clients.email && user.email) {
try {
await this.clients.email.send(user.email, 'enabled_2fa', {
username: user.username,
});
await this.clients.email.send(
user.email,
'enabled_2fa',
{ username: user.username },
{ stream: 'auth' },
);
} catch (e) {
console.warn('[configure-2fa] email send failed:', e);
}
@@ -4351,9 +4406,12 @@ export class AuthController extends PuterController {
if (this.clients.email && user.email) {
try {
await this.clients.email.send(user.email, 'disabled_2fa', {
username: user.username,
});
await this.clients.email.send(
user.email,
'disabled_2fa',
{ username: user.username },
{ stream: 'auth' },
);
} catch (e) {
console.warn('[disable-2fa] email send failed:', e);
}
@@ -4429,6 +4487,34 @@ export class AuthController extends PuterController {
res.json({ token: guiToken });
}
@Post('/auth/first-party-session', {
subdomain: 'api',
rateLimit: SESSION_LIMIT,
// Hands out a session, so only the GUI's own page script may ask, and
// only as the user's sign-in itself.
guiOriginOnly: true,
requireUserActor: true,
})
async handleFirstPartySession(req: Request, res: Response): Promise<void> {
const { origin } = req.body ?? {};
if (typeof origin !== 'string' || !origin) {
throw new HttpError(400, 'Missing `origin`', {
legacyCode: 'bad_request',
});
}
const token = await this.services.auth.createFirstPartySessionToken(
req.actor!,
origin,
{
ip: req.ip || req.socket?.remoteAddress,
user_agent: req.headers?.['user-agent'],
origin: req.headers?.origin,
host: req.headers?.host,
},
);
res.json({ token });
}
@Get('/session/sync-cookie', {
rateLimit: SESSION_LIMIT,
// Installs the session cookie. Only page script on our own origin
@@ -4453,6 +4539,14 @@ export class AuthController extends PuterController {
res.status(400).end();
return;
}
// A session handed to a first-party page never gets a cookie.
if (req.actor.session.parentUid) {
throw new HttpError(
403,
'This session cannot install a session cookie',
{ legacyCode: 'forbidden' },
);
}
// As in `/get-gui-token`: the actor is the user's row as auth read it.
const sessionToken = this.services.auth.createSessionTokenForSession(
req.actor.user as UserRow,
@@ -4769,6 +4863,7 @@ export class AuthController extends PuterController {
res: Response,
user: UserRow,
oidcContinuation?: unknown,
extra: { confirmation_email_sent?: boolean } = {},
): Promise<void> {
const redirectUrl =
oidcContinuation === undefined
@@ -4803,6 +4898,7 @@ export class AuthController extends PuterController {
token: gui_token,
user: details,
...(redirectUrl ? { redirectUrl } : {}),
...extra,
});
}
}
@@ -0,0 +1,282 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { IConfig } from '../../types.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
const PAGE = 'https://desk.first-party.test';
// Seeded with godmode on.
const DEV_CENTER = 'app-0b37f054-07d4-4627-8765-11bd23e889d4';
/**
* One of the deployment's own pages, served apart from the GUI, signs in with a
* `web` session of its own under the user's sign-in.
*/
describe('first-party page sessions', () => {
let env: PuterTestEnv;
let userId = 0;
beforeAll(async () => {
env = await setupPuterTestEnv({
first_party_session_origins: [`${PAGE}/`],
} as IConfig);
const row = await env.server.stores.user.getByUsername(
env.users.user.username,
);
userId = row!.id;
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const call = (
method: string,
path: string,
token: string,
body?: unknown,
headers: Record<string, string> = {},
) =>
fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
...headers,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
/** A sign-in of its own, so revoking it leaves the shared one up. */
const signIn = async () => {
const user = await env.server.stores.user.getById(userId);
const { gui_token, session } =
await env.server.services.auth.createSessionToken(user!);
return { token: gui_token, uuid: session.uuid as string };
};
const mint = async (token: string, origin = PAGE) => {
const res = await call('POST', '/auth/first-party-session', token, {
origin,
});
expect(res.status).toBe(200);
return ((await res.json()) as { token: string }).token;
};
const actorOf = async (token: string) =>
(await env.server.services.auth.authenticate(token)).actor;
const sessions = async (token: string) => {
const res = await call('GET', '/auth/list-sessions', token);
expect(res.status).toBe(200);
return (await res.json()) as Array<Record<string, unknown>>;
};
it('hands the page a session of its own under the sign-in', async () => {
const root = await signIn();
const token = await mint(root.token);
const actor = await actorOf(token);
expect(actor?.app ?? null).toBeNull();
expect(actor?.accessToken ?? null).toBeNull();
expect(actor?.session).toMatchObject({
kind: 'web',
parentUid: root.uuid,
});
expect(actor?.session?.uid).not.toBe(root.uuid);
const row = (await sessions(root.token)).find(
(s) => s.uuid === actor!.session!.uid,
);
expect(row).toMatchObject({
kind: 'web',
parent_session_id: root.uuid,
first_party_origin: PAGE,
current: false,
});
});
it('reuses the session on a second ask', async () => {
const root = await signIn();
const first = await actorOf(await mint(root.token));
const second = await actorOf(await mint(root.token));
expect(second?.session?.uid).toBe(first?.session?.uid);
});
it('acts as the account: launches apps, godmode ones included', async () => {
const root = await signIn();
const token = await mint(root.token);
const res = await call('POST', '/auth/get-user-app-token', token, {
app_uid: DEV_CENTER,
});
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ godmode: true });
});
it('refuses an origin that is not listed', async () => {
const root = await signIn();
const res = await call(
'POST',
'/auth/first-party-session',
root.token,
{
origin: 'https://elsewhere.test',
},
);
expect(res.status).toBe(403);
});
it('refuses a page on another origin, the listed page included', async () => {
const root = await signIn();
for (const origin of [PAGE, 'https://elsewhere.test']) {
const res = await call(
'POST',
'/auth/first-party-session',
root.token,
{ origin: PAGE },
{ origin },
);
expect(res.status).toBe(403);
}
});
it('refuses app tokens, access tokens and a first-party session', async () => {
const root = await signIn();
const page = await mint(root.token);
const app = (await (
await call('POST', '/auth/get-user-app-token', root.token, {
origin: 'https://some-app.test',
})
).json()) as { token: string };
const godmode = (await (
await call('POST', '/auth/get-user-app-token', root.token, {
app_uid: DEV_CENTER,
})
).json()) as { token: string };
for (const token of [page, app.token, godmode.token]) {
const res = await call('POST', '/auth/first-party-session', token, {
origin: PAGE,
});
expect(res.status).toBe(403);
}
});
it('never gets a session cookie', async () => {
const root = await signIn();
const page = await mint(root.token);
const syncCookie = (token: string) =>
fetch(new URL('/session/sync-cookie', env.origin), {
headers: { authorization: `Bearer ${token}` },
});
expect((await syncCookie(root.token)).status).toBe(204);
const res = await syncCookie(page);
expect(res.status).toBe(403);
expect(res.headers.get('set-cookie')).toBeNull();
});
it('ends with the sign-in it was handed out under', async () => {
const root = await signIn();
const page = await mint(root.token);
expect(await actorOf(page)).toBeDefined();
await env.server.services.auth.revokeSession(root.uuid);
const result = await env.server.services.auth.authenticate(page);
expect(result.actor).toBeUndefined();
expect(result.reauth?.reason).toBe('session_revoked');
});
it('takes the tokens minted under it along when its sign-in ends', async () => {
const root = await signIn();
const page = await mint(root.token);
const godmode = (await (
await call('POST', '/auth/get-user-app-token', page, {
app_uid: DEV_CENTER,
})
).json()) as { token: string };
const scoped = (await (
await call('POST', '/auth/create-access-token', godmode.token, {
permissions: ['service:foo:ii:read'],
expiresIn: '7d',
})
).json()) as { token: string };
expect(await actorOf(scoped.token)).toBeDefined();
await env.server.services.auth.revokeSession(root.uuid);
for (const token of [godmode.token, scoped.token]) {
expect(
(await env.server.services.auth.authenticate(token)).actor,
).toBeUndefined();
}
});
it('is revoked on its own without ending the sign-in', async () => {
const root = await signIn();
const page = await mint(root.token);
const pageUuid = (await actorOf(page))!.session!.uid;
await env.server.services.auth.revokeSession(pageUuid);
expect(
(await env.server.services.auth.authenticate(page)).actor,
).toBeUndefined();
expect(await actorOf(root.token)).toBeDefined();
});
it('keeps the whole current sign-in when revoking all others', async () => {
const root = await signIn();
const page = await mint(root.token);
const other = await signIn();
const otherPage = await mint(other.token);
// Asked from the page: its sign-in stays, and so does the page.
await env.server.services.auth.revokeAllSessions(
(await actorOf(page))!,
);
expect(await actorOf(page)).toBeDefined();
expect(await actorOf(root.token)).toBeDefined();
expect(
(await env.server.services.auth.authenticate(other.token)).actor,
).toBeUndefined();
expect(
(await env.server.services.auth.authenticate(otherPage)).actor,
).toBeUndefined();
// Asked from the sign-in: the page under it stays too.
await env.server.services.auth.revokeAllSessions(
(await actorOf(root.token))!,
);
expect(await actorOf(page)).toBeDefined();
});
it('is not counted as a sign-in', async () => {
const root = await signIn();
const before = await env.server.stores.session.listSignIns(userId, {
limit: 100,
});
await mint(root.token);
const after = await env.server.stores.session.listSignIns(userId, {
limit: 100,
});
expect(after.length).toBe(before.length);
});
});
@@ -1,253 +0,0 @@
/*
* 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 { HttpError } from '../../core/http/HttpError.js';
import { PuterController } from '../types.js';
const ALLOWED_LAYOUTS = ['icons', 'details', 'list'];
const ALLOWED_SORT_BY = ['name', 'size', 'modified', 'type'];
const ALLOWED_SORT_ORDER = ['asc', 'desc'];
/**
* Desktop/UI preference routes.
*
* Two categories:
*
* - User-level: desktop background, taskbar items (UserStore)
* - Folder-level: layout, sort_by/sort_order (fsentries table)
*/
/**
* Desktop preference writes — background, taskbar, layout, sort order. All four
* persist to the user row on every call, and the GUI fires them on direct user
* action, so a per-minute ceiling well above human speed is enough to catch a
* stuck client.
*/
const PREFERENCE_WRITE_LIMIT = {
scope: 'desktop-preference',
limit: 120,
window: 60_000,
key: 'user',
};
export class DesktopController extends PuterController {
registerRoutes(router) {
// -- Desktop background --------------------------------------
router.post(
'/set-desktop-bg',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { url, color, fit } = req.body ?? {};
const patch = {};
if (url !== undefined) {
if (url !== null && typeof url !== 'string') {
throw new HttpError(
400,
'`url` must be a string or null',
{ legacyCode: 'bad_request' },
);
}
patch.desktop_bg_url = url;
}
if (color !== undefined) {
if (color !== null && typeof color !== 'string') {
throw new HttpError(
400,
'`color` must be a string or null',
{ legacyCode: 'bad_request' },
);
}
patch.desktop_bg_color = color;
}
if (fit !== undefined) {
if (fit !== null && typeof fit !== 'string') {
throw new HttpError(
400,
'`fit` must be a string or null',
{ legacyCode: 'bad_request' },
);
}
patch.desktop_bg_fit = fit;
}
if (Object.keys(patch).length === 0) {
throw new HttpError(400, 'No fields provided', {
legacyCode: 'bad_request',
});
}
await this.stores.user.update(req.actor.user.id, patch);
res.json({});
},
);
// -- Taskbar items -------------------------------------------
router.post(
'/update-taskbar-items',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { items } = req.body ?? {};
if (!Array.isArray(items)) {
throw new HttpError(
400,
'Missing or invalid `items` array',
{ legacyCode: 'bad_request' },
);
}
await this.stores.user.update(req.actor.user.id, {
taskbar_items: JSON.stringify(items),
});
res.json({});
},
);
// -- Folder layout -------------------------------------------
router.post(
'/set_layout',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { item_uid, item_path, layout } = req.body ?? {};
if (!layout || !ALLOWED_LAYOUTS.includes(layout)) {
throw new HttpError(
400,
`\`layout\` must be one of: ${ALLOWED_LAYOUTS.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
await this.#updateFSEntry(
req.actor,
{ item_uid, item_path },
{ layout },
);
res.json({});
},
);
// -- Folder sort ---------------------------------------------
router.post(
'/set_sort_by',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { item_uid, item_path, sort_by, sort_order } =
req.body ?? {};
if (!sort_by || !ALLOWED_SORT_BY.includes(sort_by)) {
throw new HttpError(
400,
`\`sort_by\` must be one of: ${ALLOWED_SORT_BY.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
const resolvedOrder = sort_order ?? 'asc';
if (!ALLOWED_SORT_ORDER.includes(resolvedOrder)) {
throw new HttpError(
400,
`\`sort_order\` must be one of: ${ALLOWED_SORT_ORDER.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
await this.#updateFSEntry(
req.actor,
{ item_uid, item_path },
{
sort_by,
sort_order: resolvedOrder,
},
);
res.json({});
},
);
}
// -- Helpers ------------------------------------------------------
/**
* Update columns on an actor-owned fsentry. Accepts either `item_uid` (fast
* path) or `item_path` (path lookups in FSEntryStore now have a
* recursive-CTE fallback for legacy rows with a NULL `path` column, so this
* works for old accounts too).
*
* Ownership: `entry.user_id === actor.user.id`. Kept as added validation
* against bad paths — the previous "drop user_id entirely" theory turned
* out to be wrong (the actual legacy issue was NULL paths, not user_id
* drift), so this filter doesn't lock out old accounts in practice.
*/
async #updateFSEntry(actor, { item_uid, item_path }, patch) {
if (!item_uid && !item_path) {
throw new HttpError(400, 'Missing `item_uid` or `item_path`', {
legacyCode: 'bad_request',
});
}
const entry = item_uid
? await this.stores.fsEntry.getEntryByUuid(item_uid)
: await this.stores.fsEntry.getEntryByPath(item_path);
if (!entry) {
throw new HttpError(404, 'Item not found', {
legacyCode: 'not_found',
});
}
const actorUserId = actor?.user?.id;
if (typeof actorUserId !== 'number' || entry.userId !== actorUserId) {
throw new HttpError(403, 'Not allowed to update this item', {
legacyCode: 'forbidden',
});
}
const keys = Object.keys(patch);
const setClause = keys.map((k) => `\`${k}\` = ?`).join(', ');
const values = keys.map((k) => patch[k]);
await this.clients.db.write(
`UPDATE \`fsentries\` SET ${setClause} WHERE \`id\` = ?`,
[...values, entry.id],
);
await this.stores.fsEntry.invalidateEntryCacheByUuid(entry.uuid);
}
onServerStart() {}
onServerPrepareShutdown() {}
onServerShutdown() {}
}
@@ -1,511 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Request, 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 { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
// ── Test harness ────────────────────────────────────────────────────
//
// Boots one real PuterServer (in-memory sqlite + dynamo + s3 + mock
// redis) and re-registers DesktopController's inline lambda routes
// onto a fresh PuterRouter so each handler is reachable. Each test
// makes its own user via `makeUser` and exercises the live controller
// against the real wired stores (user, fsEntry) and DB client.
let server: PuterServer;
let router: PuterRouter;
beforeAll(async () => {
server = await setupTestServer();
router = new PuterRouter();
server.controllers.desktop.registerRoutes(router);
});
afterAll(async () => {
await server?.shutdown();
});
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
const username = `dc-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
created,
);
const refreshed = (await server.stores.user.getById(created.id))!;
return {
userId: refreshed.id,
actor: {
user: {
id: refreshed.id,
uuid: refreshed.uuid,
username: refreshed.username,
email: refreshed.email ?? null,
email_confirmed: true,
} as Actor['user'],
},
};
};
interface CapturedResponse {
statusCode: number;
body: unknown;
}
const makeReq = (init: {
body?: unknown;
actor?: Actor;
}): Request => {
return {
body: init.body ?? {},
query: {},
headers: {},
actor: init.actor,
} as unknown as Request;
};
const makeRes = () => {
const captured: CapturedResponse = { statusCode: 200, body: undefined };
const res = {
json: vi.fn((value: unknown) => {
captured.body = value;
return res;
}),
status: vi.fn((code: number) => {
captured.statusCode = code;
return res;
}),
setHeader: vi.fn(() => res),
};
return { res: res as unknown as Response, captured };
};
const findHandler = (method: string, path: string): RequestHandler => {
const route = router.routes.find(
(r) => r.method === method && r.path === path,
);
if (!route) throw new Error(`No ${method.toUpperCase()} ${path} route`);
return route.handler;
};
const callRoute = async (
method: string,
path: string,
req: Request,
res: Response,
) => {
const handler = findHandler(method, path);
await handler(req, res, () => {
throw new Error('handler called next() unexpectedly');
});
};
// ── /set-desktop-bg ─────────────────────────────────────────────────
describe('DesktopController POST /set-desktop-bg', () => {
it('persists url/color/fit on the user row', async () => {
const { actor, userId } = await makeUser();
const { res, captured } = makeRes();
await callRoute(
'post',
'/set-desktop-bg',
makeReq({
body: {
url: 'https://cdn.test/wallpaper.png',
color: '#000000',
fit: 'cover',
},
actor,
}),
res,
);
expect(captured.body).toEqual({});
const refreshed = await server.stores.user.getById(userId);
expect(refreshed?.desktop_bg_url).toBe(
'https://cdn.test/wallpaper.png',
);
expect(refreshed?.desktop_bg_color).toBe('#000000');
expect(refreshed?.desktop_bg_fit).toBe('cover');
});
it('persists only the supplied fields (partial update)', async () => {
const { actor, userId } = await makeUser();
const { res } = makeRes();
await callRoute(
'post',
'/set-desktop-bg',
makeReq({ body: { color: '#ffffff' }, actor }),
res,
);
const refreshed = await server.stores.user.getById(userId);
expect(refreshed?.desktop_bg_color).toBe('#ffffff');
// Untouched fields remain at their column default.
expect(refreshed?.desktop_bg_url).toBeFalsy();
expect(refreshed?.desktop_bg_fit).toBeFalsy();
});
it('passes through `null` to clear a field', async () => {
const { actor, userId } = await makeUser();
// First populate, then clear.
const populate = makeRes();
await callRoute(
'post',
'/set-desktop-bg',
makeReq({
body: { url: 'https://cdn.test/x.png' },
actor,
}),
populate.res,
);
const before = await server.stores.user.getById(userId);
expect(before?.desktop_bg_url).toBe('https://cdn.test/x.png');
const clear = makeRes();
await callRoute(
'post',
'/set-desktop-bg',
makeReq({ body: { url: null }, actor }),
clear.res,
);
const after = await server.stores.user.getById(userId);
expect(after?.desktop_bg_url).toBeNull();
});
it('throws 400 when url is not a string or null', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set-desktop-bg',
makeReq({ body: { url: 123 }, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 when no fields are provided', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set-desktop-bg',
makeReq({ body: {}, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
// ── /update-taskbar-items ───────────────────────────────────────────
describe('DesktopController POST /update-taskbar-items', () => {
it('persists items as a JSON-encoded string', async () => {
const { actor, userId } = await makeUser();
const items = [{ name: 'editor' }, { name: 'browser' }];
const { res } = makeRes();
await callRoute(
'post',
'/update-taskbar-items',
makeReq({ body: { items }, actor }),
res,
);
const refreshed = await server.stores.user.getById(userId);
expect(refreshed?.taskbar_items).toBe(JSON.stringify(items));
});
it('throws 400 when items is missing', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/update-taskbar-items',
makeReq({ body: {}, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 when items is not an array', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/update-taskbar-items',
makeReq({ body: { items: 'oops' }, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
// ── /set_layout ─────────────────────────────────────────────────────
describe('DesktopController POST /set_layout', () => {
it('persists the new layout on the matching fsentry', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
expect(documents).not.toBeNull();
const { res, captured } = makeRes();
await callRoute(
'post',
'/set_layout',
makeReq({
body: { item_uid: documents!.uuid, layout: 'icons' },
actor,
}),
res,
);
expect(captured.body).toEqual({});
const refreshed = await server.stores.fsEntry.getEntryByUuid(
documents!.uuid,
);
expect(refreshed?.layout).toBe('icons');
});
it('resolves by item_path when item_uid is omitted', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const path = `/${username}/Pictures`;
const { res } = makeRes();
await callRoute(
'post',
'/set_layout',
makeReq({
body: { item_path: path, layout: 'list' },
actor,
}),
res,
);
const refreshed = await server.stores.fsEntry.getEntryByPath(path);
expect(refreshed?.layout).toBe('list');
});
it('throws 400 for an unknown layout value', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({
body: { item_uid: documents!.uuid, layout: 'gallery' },
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 when neither item_uid nor item_path is supplied', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({ body: { layout: 'icons' }, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 404 when the fsentry cannot be resolved', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({
body: {
item_uid: '00000000-0000-0000-0000-000000000000',
layout: 'icons',
},
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 404 });
});
it("throws 403 when the fsentry belongs to another user", async () => {
const owner = await makeUser();
const intruder = await makeUser();
const ownerUsername = owner.actor.user!.username!;
const ownerEntry = await server.stores.fsEntry.getEntryByPath(
`/${ownerUsername}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_layout',
makeReq({
body: { item_uid: ownerEntry!.uuid, layout: 'icons' },
actor: intruder.actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 403 });
// Owner's entry remains untouched.
const refreshed = await server.stores.fsEntry.getEntryByUuid(
ownerEntry!.uuid,
);
expect(refreshed?.layout).toBeFalsy();
});
});
// ── /set_sort_by ────────────────────────────────────────────────────
describe('DesktopController POST /set_sort_by', () => {
it('persists sort_by + sort_order on the matching fsentry', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res, captured } = makeRes();
await callRoute(
'post',
'/set_sort_by',
makeReq({
body: {
item_uid: documents!.uuid,
sort_by: 'name',
sort_order: 'desc',
},
actor,
}),
res,
);
expect(captured.body).toEqual({});
const refreshed = await server.stores.fsEntry.getEntryByUuid(
documents!.uuid,
);
expect(refreshed?.sortBy).toBe('name');
expect(refreshed?.sortOrder).toBe('desc');
});
it('defaults sort_order to "asc" when omitted', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const pictures = await server.stores.fsEntry.getEntryByPath(
`/${username}/Pictures`,
);
const { res } = makeRes();
await callRoute(
'post',
'/set_sort_by',
makeReq({
body: { item_uid: pictures!.uuid, sort_by: 'modified' },
actor,
}),
res,
);
const refreshed = await server.stores.fsEntry.getEntryByUuid(
pictures!.uuid,
);
expect(refreshed?.sortBy).toBe('modified');
expect(refreshed?.sortOrder).toBe('asc');
});
it('throws 400 for an unknown sort_by value', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const docs = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_sort_by',
makeReq({
body: { item_uid: docs!.uuid, sort_by: 'random' },
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 for an invalid sort_order', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
const docs = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set_sort_by',
makeReq({
body: {
item_uid: docs!.uuid,
sort_by: 'name',
sort_order: 'sideways',
},
actor,
}),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
+2 -1
View File
@@ -18,6 +18,7 @@
*/
import type { Response } from 'express';
import { onResponseClose } from '../../core/http/onResponseClose.js';
// Under the concurrency store's one-hour orphan TTL.
const SLOT_RENEW_MS = 10 * 60 * 1000;
@@ -46,7 +47,7 @@ export class CallSlot {
this.#maybeRelease();
};
res.once('finish', responded);
res.once('close', responded);
onResponseClose(res, responded);
if (res.destroyed || res.writableFinished) responded();
if (!this.#released && this.#work.size > 0) this.#startRenewing();
}
@@ -154,6 +154,26 @@ describe('GET /fs/readdir over HTTP', () => {
).toEqual(['a', 'a/b']);
});
it('answers 404 for a directory whose parent chain is broken', async () => {
const { username, token } = env.users.user;
const path = `/${username}/Documents/http-orphan-${Date.now()}`;
const created = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, auth_token: token }),
});
expect(created.status).toBe(200);
const { uid } = (await created.json()) as { uid: string };
await env.server.clients.db.write(
'UPDATE fsentries SET path = NULL, parent_uid = ? WHERE uuid = ?',
['does-not-exist', uid],
);
await env.server.stores.fsEntry.invalidateEntryCacheByUuid(uid);
const response = await fetch(readdirUrl({ uid, auth_token: token }));
expect(response.status).toBe(404);
});
it('mkdir does not return internal ids, storage columns or tokens', async () => {
const { username, token } = env.users.user;
const response = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
+3 -1
View File
@@ -1850,7 +1850,9 @@ export class FSController extends PuterController {
}
async #assertCanDedupeCreate(actor: Actor, targetPath: string) {
const existing = await this.stores.fsEntry.getEntryByPath(targetPath);
const existing = await this.stores.fsEntry.getEntryByPath(targetPath, {
foldedNames: true,
});
if (!existing) return;
const parent = pathPosix.dirname(targetPath);
await this.#assertAccess(
@@ -168,6 +168,27 @@ describe('legacy file downloads over HTTP', () => {
},
);
it('GET /token-read types a file by its name, and one with no known extension as a byte stream', async () => {
const typed = await requestFor(
'/token-read',
await writeFile('notes.txt', Buffer.from('hello')),
);
expect(typed.headers.get('content-type')).toBe(
'text/plain; charset=utf-8',
);
for (const name of ['README', 'archive.unknownext']) {
const response = await requestFor(
'/token-read',
await writeFile(name, Buffer.from('hello')),
);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toBe(
'application/octet-stream',
);
}
});
it('GET /read answers byte_count on an empty file with an empty 200', async () => {
const file = await writeFile('empty-byte-count.bin', Buffer.alloc(0));
for (const query of ['byte_count=512', 'offset=0']) {
@@ -5111,9 +5111,8 @@ describe('LegacyFSController.updateFsentryThumbnail', () => {
// ── GET /get-launch-apps ────────────────────────────────────────────
//
// `recent` is a launch-metadata producer: it returns `index_url` for
// apps the user has opened before. The taskbar happens to launch these
// by name (so AppDriver's hosted-backing guard applies), but the field
// is served regardless, so the guard is applied here too.
// apps the user has opened before. The field is served whoever reads it,
// so AppDriver's hosted-backing guard is applied here too.
describe('LegacyFSController GET /get-launch-apps', () => {
const hostedUrl = (sub: string) => `https://${sub}.site.puter.localhost/`;
@@ -401,11 +401,9 @@ export class LegacyFSController extends PuterController {
.filter(Boolean);
// Don't hand out an index_url whose puter-hosted backing is
// gone or reclaimed. The taskbar launches recents by name (so
// AppDriver's guard applies), but this list is a
// launch-metadata producer like any other — a future consumer
// reading index_url straight off it shouldn't inherit a stale
// origin.
// gone or reclaimed. This list is a launch-metadata producer
// like any other — a consumer reading index_url straight off
// it shouldn't inherit a stale origin.
let backingGoneFlags: boolean[];
try {
backingGoneFlags =
@@ -767,8 +765,10 @@ export class LegacyFSController extends PuterController {
normalizedTarget,
);
if (dedupeName) {
const existing =
await this.stores.fsEntry.getEntryByPath(normalizedTarget);
const existing = await this.stores.fsEntry.getEntryByPath(
normalizedTarget,
{ foldedNames: true },
);
if (existing) {
const parent = pathPosix.dirname(normalizedTarget);
await assertAccess(
@@ -950,9 +950,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 overwrittenEntry = replacedByMove(overwriteTarget, moved, name);
const oldPath = source.path;
@@ -1324,7 +1322,7 @@ export class LegacyFSController extends PuterController {
if (options.realMime) {
res.setHeader(
'Content-Type',
contentTypeFromMime(entry.name) as string,
contentTypeFromMime(entry.name) || 'application/octet-stream',
);
} else {
res.setHeader('Content-Type', 'application/octet-stream');
@@ -1426,8 +1424,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: [],
};
@@ -2054,10 +2051,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',
@@ -277,4 +277,124 @@ describe('writes inside a folder shared with write', () => {
expect(JSON.stringify(res.json)).not.toContain(s.realDir);
}
}, 180_000);
describe('a file the recipient opened with an app', () => {
const makeApp = (ownerUserId: number) =>
(
env.server.stores.app.create as unknown as (
fields: Record<string, unknown>,
opts: { ownerUserId: number },
) => Promise<{ uid: string; id: number }>
)(
{
name: `sfw-app-${Math.random().toString(36).slice(2, 9)}`,
title: 'Shared folder app',
index_url: `https://sfw-${Math.random().toString(36).slice(2, 9)}.test/`,
},
{ ownerUserId },
);
/** Opens `note.txt` with a new app, as a launch does. */
const openWithApp = async (
s: Awaited<ReturnType<typeof shareAFolder>>,
) => {
const recipientId = (await env.server.stores.user.getByUsername(
s.recipient.username,
))!.id;
const app = await makeApp(recipientId);
const signed = await post('/sign', s.recipient.token, {
items: [{ uid: s.noteUid, action: 'write' }],
app_uid: app.uid,
});
expect(signed.status).toBe(200);
return { app, appToken: signed.json!.token as string };
};
const read = async (token: string, query: Record<string, string>) => {
const res = await fetch(
new URL(`/read?${new URLSearchParams(query)}`, env.apiOrigin),
{ headers: { authorization: `Bearer ${token}` } },
);
return { status: res.status, text: await res.text() };
};
const write = (token: string, path: string, content: string) =>
post('/fs/write', token, {
fileMetadata: {
path,
size: content.length,
contentType: 'text/plain',
overwrite: true,
},
fileContent: content,
});
it('reads and saves it by path and uid', async () => {
const s = await shareAFolder();
const { appToken } = await openWithApp(s);
const masked = `${s.maskedDir}/note.txt`;
const real = `${s.realDir}/note.txt`;
expect((await write(appToken, masked, 'one')).status).toBe(200);
expect(await read(appToken, { uid: s.noteUid })).toEqual({
status: 200,
text: 'one',
});
expect((await write(appToken, real, 'two')).status).toBe(200);
expect(await read(appToken, { file: real })).toEqual({
status: 200,
text: 'two',
});
expect(
(await post('/stat', appToken, { uid: s.noteUid })).status,
).toBe(200);
}, 180_000);
it('only reads it once the share drops to read', async () => {
const s = await shareAFolder();
const { appToken } = await openWithApp(s);
const dir = await post('/stat', s.owner.token, { path: s.realDir });
const reshared = await post('/share', s.owner.token, {
recipients: [s.recipient.username],
items: [{ uid: dir.json!.uid }],
mode: 'read',
});
expect(reshared.status).toBe(200);
expect((await read(appToken, { uid: s.noteUid })).status).toBe(200);
expect(
(await write(appToken, `${s.maskedDir}/note.txt`, 'x')).status,
).toBe(403);
}, 180_000);
it('gives nothing to another app of the recipient', async () => {
const s = await shareAFolder();
const { appToken } = await openWithApp(s);
const recipientId = (await env.server.stores.user.getByUsername(
s.recipient.username,
))!.id;
const other = await makeApp(recipientId);
const minted = await post(
'/auth/get-user-app-token',
s.recipient.token,
{ app_uid: other.uid },
);
expect(minted.status).toBe(200);
const otherToken = minted.json!.token as string;
// The same requests the opened app gets through.
const real = `${s.realDir}/note.txt`;
expect((await read(appToken, { uid: s.noteUid })).status).toBe(200);
expect((await write(appToken, real, 'one')).status).toBe(200);
expect((await read(otherToken, { uid: s.noteUid })).status).toBe(
404,
);
expect((await write(otherToken, real, 'x')).status).toBe(404);
expect(await read(appToken, { uid: s.noteUid })).toEqual({
status: 200,
text: 'one',
});
}, 180_000);
});
});
@@ -79,6 +79,7 @@ interface CapturedResponse {
statusCode: number;
body: unknown;
contentType?: string;
location?: string;
}
const makeReq = (init: {
@@ -87,6 +88,7 @@ const makeReq = (init: {
actor?: Actor;
hostname?: string;
protocol?: string;
originalUrl?: string;
}): Request => {
return {
body: {},
@@ -94,6 +96,7 @@ const makeReq = (init: {
headers: {},
params: init.params ?? {},
path: init.path ?? '/',
originalUrl: init.originalUrl ?? init.path ?? '/',
hostname: init.hostname ?? 'test.local',
protocol: init.protocol ?? 'http',
actor: init.actor,
@@ -131,6 +134,11 @@ const makeRes = () => {
captured.contentType = value;
return res;
}),
redirect: vi.fn((location: string) => {
captured.statusCode = 302;
captured.location = location;
return res;
}),
};
return { res: res as unknown as Response, captured };
};
@@ -214,23 +222,72 @@ describe('HomepageController shell routes', () => {
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
});
it('serves the shell on /settings, /dashboard, /desktop, /action, /@:username', async () => {
for (const path of [
'/settings',
'/settings/*splat',
'/dashboard',
'/dashboard/',
'/desktop',
'/desktop/',
'/action/*splat',
'/@:username',
]) {
it('leaves guiTokenDigest out for a session cookie that is not a user session', async () => {
const { actor } = await makeUser();
const appActor = {
...actor,
app: { id: 1, uid: 'app-test' },
session: { uid: uuidv4() },
} as unknown as Actor;
const req = Object.assign(makeReq({ path: '/', actor: appActor }), {
tokenSource: 'cookie',
cookies: { puter_token_v2: 'legacy' },
});
const { res, captured } = makeRes();
await callRoute('get', '/', req, res);
expect(String(captured.body)).not.toContain('guiTokenDigest');
});
it('serves the shell on /dashboard and /action', async () => {
for (const path of ['/dashboard', '/dashboard/', '/action/*splat']) {
const { res, captured } = makeRes();
await callRoute('get', path, makeReq({ path }), res);
expect(typeof captured.body).toBe('string');
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
}
});
it('redirects /desktop to the root, keeping the query', async () => {
for (const path of ['/desktop', '/desktop/']) {
const { res, captured } = makeRes();
await callRoute(
'get',
path,
makeReq({ path, originalUrl: `${path}?ref=abc&x=1` }),
res,
);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/?ref=abc&x=1');
expect(captured.body).toBeUndefined();
}
const { res, captured } = makeRes();
await callRoute('get', '/desktop', makeReq({ path: '/desktop' }), res);
expect(captured.location).toBe('/');
});
it('redirects /settings to the dashboard Home tab', async () => {
for (const path of ['/settings', '/settings/*splat']) {
const { res, captured } = makeRes();
await callRoute('get', path, makeReq({ path }), res);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/dashboard#home');
expect(captured.body).toBeUndefined();
}
});
it('redirects /@:username to the dashboard', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/@:username',
makeReq({ path: '/@alice', params: { username: 'alice' } }),
res,
);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/');
expect(captured.body).toBeUndefined();
});
});
// ── /app/:name ──────────────────────────────────────────────────────
@@ -360,47 +417,21 @@ describe('HomepageController GET /app/:name', () => {
expect(html).not.toContain('owner_user_id');
});
it('serves the same app shell under /desktop/app/:name', async () => {
const { userId } = await makeUser();
const name = `desk-${Math.random().toString(36).slice(2, 10)}`;
await server.stores.app.create(
{
name,
title: 'Desktop App',
description: 'opens on the desktop instead of the dashboard',
index_url: `https://example.com/${name}/`,
approved_for_listing: 1,
},
{ ownerUserId: userId },
);
const { res, captured } = makeRes();
await callRoute(
'get',
'/desktop/app/:name',
makeReq({ params: { name }, path: `/desktop/app/${name}` }),
res,
);
expect(captured.statusCode).toBe(200);
const html = String(captured.body);
expect(html).toMatch(/<!DOCTYPE html>/i);
expect(html).toContain('Desktop App');
});
it('returns 404 under /desktop/app/:name when the app is unknown', async () => {
it('redirects /desktop/app/:name to /app/:name, keeping the query', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/desktop/app/:name',
makeReq({
params: { name: 'no-such-app' },
path: '/desktop/app/no-such-app',
params: { name: 'editor' },
path: '/desktop/app/editor',
originalUrl: '/desktop/app/editor?file=%2Falice%2Fa.txt&x=1',
}),
res,
);
expect(captured.statusCode).toBe(404);
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/app/editor?file=%2Falice%2Fa.txt&x=1');
expect(captured.body).toBeUndefined();
});
it('omits index_url even for a public app — the shell is not the launch authority', async () => {
@@ -434,40 +465,39 @@ describe('HomepageController GET /app/:name', () => {
// -- app landing rate limit --
describe('HomepageController app landing rate limit', () => {
it('rate-limits /app/:name and /desktop/app/:name per network', () => {
for (const path of ['/app/:name', '/desktop/app/:name']) {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === path,
);
if (!route) throw new Error(`No GET ${path} route`);
expect(route.options.rateLimit).toEqual({
scope: 'app-landing',
limit: 600,
window: 60_000,
key: 'ip',
});
}
it('rate-limits /app/:name per network', () => {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === '/app/:name',
);
if (!route) throw new Error('No GET /app/:name route');
expect(route.options.rateLimit).toEqual({
scope: 'app-landing',
limit: 600,
window: 60_000,
key: 'ip',
});
});
});
// ── /show/* ─────────────────────────────────────────────────────────
describe('HomepageController GET /show/*splat', () => {
it('emits a launch_app explorer call with the post-/show path', async () => {
it('redirects to the path in the dashboard Files tab, keeping the query', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/show/*splat',
makeReq({ path: '/show/alice/Documents' }),
makeReq({
path: '/show/alice/My%20Documents',
originalUrl: '/show/alice/My%20Documents?ref=abc',
}),
res,
);
// The launch payload is JSON-serialized into the rendered HTML.
// We assert the rendered shell included the explorer launch hint
// pointing at the expected (slashed) path.
const html = String(captured.body);
expect(html).toContain('launch_app');
expect(html).toContain('explorer');
expect(html).toContain('/alice/Documents');
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe(
'/dashboard?ref=abc#files/alice/My%20Documents',
);
expect(captured.body).toBeUndefined();
});
});
@@ -20,6 +20,7 @@
import express from 'express';
import path from 'node:path';
import { PuterController } from '../types.js';
import { isPlainUserActor } from '../../core/actor.js';
import { toAppShellView } from '../../util/appShellView.js';
import type { PuterRouter } from '../../core/http/PuterRouter';
import {
@@ -27,11 +28,12 @@ import {
subdomainLabelOf,
} from '../../core/http/middleware/hostRedirects.js';
import type { IConfig } from '../../types';
import type { UserRow } from '../../stores/user/UserStore';
import type {
PuterHomepageService,
PageMeta,
LaunchOptions,
PopupFrame,
ShellContext,
} from '../../services/homepage/PuterHomepageService';
/**
@@ -48,6 +50,7 @@ export const FRAMED_POPUP_ACTIONS = [
'upgrade',
'top-up',
'request-email-confirmation',
'attach-custom-domain',
] as const;
/** Longest description/og:description/twitter:description on `/app/<name>`. */
@@ -169,7 +172,6 @@ export class HomepageController extends PuterController {
req: express.Request,
res: express.Response,
metaOverrides: Partial<PageMeta> = {},
launch: LaunchOptions = {},
popupFrame?: PopupFrame,
) => {
const meta = { ...defaultMeta(req), ...metaOverrides };
@@ -179,7 +181,16 @@ export class HomepageController extends PuterController {
actor?: Parameters<typeof homepage.send>[0]['actor'];
}
).actor ?? null;
await homepage.send({ req, res, actor }, meta, launch, popupFrame);
await homepage.send(
{
req,
res,
actor,
guiTokenDigest: this.#guiTokenDigest(req, actor),
},
meta,
popupFrame,
);
};
// -- Root + path-aliased shell routes ------------------------
@@ -251,7 +262,6 @@ export class HomepageController extends PuterController {
req,
res,
{},
{},
{
hostOrigin: embedHostOrigin(
this.config,
@@ -262,23 +272,42 @@ export class HomepageController extends PuterController {
});
}
router.get('/settings', {}, (req, res) => sendShell(req, res));
router.get('/settings/*splat', {}, (req, res) => sendShell(req, res));
// Old settings links land on the dashboard's Home tab.
router.get('/settings', {}, (_req, res) =>
res.redirect('/dashboard#home'),
);
router.get('/settings/*splat', {}, (_req, res) =>
res.redirect('/dashboard#home'),
);
router.get('/dashboard', {}, (req, res) => sendShell(req, res));
router.get('/dashboard/', {}, (req, res) => sendShell(req, res));
router.get('/desktop', {}, (req, res) => sendShell(req, res));
router.get('/desktop/', {}, (req, res) => sendShell(req, res));
const queryOf = (req: express.Request) => {
const separator = req.originalUrl.indexOf('?');
return separator === -1 ? '' : req.originalUrl.slice(separator);
};
// Old desktop links land on the dashboard, query intact.
router.get('/desktop', {}, (req, res) =>
res.redirect(`/${queryOf(req)}`),
);
router.get('/desktop/', {}, (req, res) =>
res.redirect(`/${queryOf(req)}`),
);
router.get('/action/*splat', {}, (req, res) => sendShell(req, res));
router.get('/@:username', {}, (req, res) => sendShell(req, res));
// Old links to a user's public folder land on the dashboard.
router.get('/@:username', {}, (_req, res) => res.redirect('/'));
// Old desktop app links open the app in the dashboard, query intact.
router.get('/desktop/app/:name', {}, (req, res) => {
const name = encodeURIComponent(String(req.params.name ?? ''));
res.redirect(`/app/${name}${queryOf(req)}`);
});
// -- /app/:name - app metadata baked into the shell ----------
// `/desktop/app/:name` is the same landing booted on the desktop
// instead of the dashboard; the GUI strips the prefix and treats the
// rest of the path identically, so both render the same shell.
const sendAppShell = async (
req: express.Request,
@@ -333,26 +362,11 @@ export class HomepageController extends PuterController {
{ rateLimit: APP_LANDING_LIMIT },
sendAppShell,
);
router.get(
'/desktop/app/:name',
{ rateLimit: APP_LANDING_LIMIT },
sendAppShell,
);
// -- /show/* - launch explorer with the requested file path --
// Old /show links open the path in the dashboard's Files tab, query intact.
router.get('/show/*splat', {}, (req, res) => {
const filePath = req.path.slice('/show'.length);
const launch: LaunchOptions = {
on_initialized: [
{
$: 'window-call',
fn_name: 'launch_app',
args: [{ name: 'explorer', path: filePath }],
},
],
};
return sendShell(req, res, {}, launch);
res.redirect(`/dashboard${queryOf(req)}#files${filePath}`);
});
// -- /sw.js - the service worker, scoped to the whole origin --
@@ -381,6 +395,38 @@ export class HomepageController extends PuterController {
);
}
}
/**
* Lets the GUI skip `/get-gui-token`, which reads the session cookie: the
* digest of the token it would hand over, null when there is no session
* cookie (either name the auth probe reads), and undefined (the GUI asks)
* when a cookie didn't resolve to a session.
*/
#guiTokenDigest(
req: express.Request,
actor: ShellContext['actor'],
): string | null | undefined {
if (req.tokenSource === 'cookie') {
if (!actor || !isPlainUserActor(actor) || !actor.session?.uid) {
return undefined;
}
return this.services.auth.guiTokenDigest(
actor.user as UserRow,
actor.session.uid,
);
}
const cookies = (req.cookies ?? {}) as Record<string, unknown>;
const hasSessionCookie = [
this.config.cookie_name,
'puter_token_v2',
].some(
(name) =>
!!name &&
typeof cookies[name] === 'string' &&
cookies[name] !== '',
);
return hasSessionCookie ? undefined : null;
}
}
const safeJsonParse = (s: string): Record<string, unknown> | null => {
-2
View File
@@ -21,7 +21,6 @@ import { AppController } from './apps/AppController.js';
import { AppFeedbackController } from './feedback/AppFeedbackController.js';
import { AuthController } from './auth/AuthController.js';
import { BroadcastController } from './broadcast/BroadcastController.js';
import { DesktopController } from './desktop/DesktopController.js';
import { DriverController } from './drivers/DriverController.js';
import { EventsController } from './events/EventsController.js';
import { FSController } from './fs/FSController.js';
@@ -49,7 +48,6 @@ export const puterControllers = {
profile: ProfileController,
apps: AppController,
appFeedback: AppFeedbackController,
desktop: DesktopController,
hosting: HostingController,
system: SystemController,
fs: FSController,
@@ -372,26 +372,6 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
);
});
it('bakes a whitelisted desktop app landing (/desktop/app/<name>) into the state redirect_uri', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/:provider/start',
makeReq({
params: { provider: 'custom' },
query: { return_to: '/desktop/app/my-App_2' },
}),
res,
);
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
'state',
);
const decoded = oidc().verifyState(state!);
expect(String(decoded?.redirect_uri)).toBe(
`${TEST_ORIGIN}/desktop/app/my-App_2`,
);
});
it('ignores a non-whitelisted return_to', async () => {
const bad_values = [
'/app/evil/extra',
@@ -400,6 +380,8 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
'//evil.test',
'/settings',
`/app/${'a'.repeat(101)}`,
'/desktop',
'/desktop/app/my-App_2',
'/desktop/app/evil/extra',
'/desktop/app/',
'/dashboard/app/name',
@@ -476,11 +458,11 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
sharedPath('b.txt'),
sharedPath('a.txt'),
],
'/desktop',
'/dashboard',
),
);
const url = new URL(uri);
expect(url.origin + url.pathname).toBe(`${TEST_ORIGIN}/desktop`);
expect(url.origin + url.pathname).toBe(`${TEST_ORIGIN}/dashboard`);
expect(url.searchParams.getAll('shared')).toEqual([
sharedPath('a.txt'),
sharedPath('b.txt'),
@@ -102,16 +102,14 @@ function signupOptionsFromState(
}
// GUI pages an OIDC flow may return to: the root (where a share email lands),
// /desktop, /dashboard, and direct app landings (/app/<name> and its
// desktop-booted twin /desktop/app/<name>, mirroring APP_NAME_REGEX in
// AppDriver). Strict whitelist — never a client-supplied URL (no open
// redirect).
// /dashboard, and direct app landings (/app/<name>, mirroring
// APP_NAME_REGEX in AppDriver). Strict whitelist — never a client-supplied URL
// (no open redirect).
function isWhitelistedReturnPath(path: string): boolean {
return (
path === '/' ||
path === '/desktop' ||
path === '/dashboard' ||
/^(\/desktop)?\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path)
/^\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path)
);
}
@@ -438,9 +436,8 @@ export class OIDCController extends PuterController {
let appRedirectUri = flowRedirects[flow] ?? (origin || '/');
// Optional GUI return path so login started from /desktop,
// /dashboard, an /app/<name> landing, or a share link lands
// back there.
// Optional GUI return path so login started from /dashboard,
// an /app/<name> landing, or a share link lands back there.
const rawReturnTo = Array.isArray(req.query.return_to)
? req.query.return_to[0]
: req.query.return_to;
@@ -314,8 +314,8 @@ describe('OIDC account second-factor login', () => {
it('preserves ordinary OIDC login for accounts without 2FA', async () => {
await account(false);
const response = await callback('login', { return_to: '/desktop' });
expect(response.headers.get('location')).toBe(`${env.origin}/desktop`);
const response = await callback('login', { return_to: '/dashboard' });
expect(response.headers.get('location')).toBe(`${env.origin}/dashboard`);
expect(
response.headers.get('set-cookie')?.includes('puter_auth_token='),
).toBe(true);
+20 -13
View File
@@ -24,11 +24,7 @@ import { computeNetworkFingerprint } from '../../core/http/middleware/rateLimit.
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterController } from '../types.js';
import { PEER_COSTS } from './costs.js';
import {
readClaimedGrantIdentifier,
signGuestGrant,
verifyGuestGrant,
} from './guestGrant.js';
import { signGuestGrant, verifyGuestGrant } from './guestGrant.js';
import {
DEFAULT_FREE_SUBSCRIPTION,
DEFAULT_TEMP_SUBSCRIPTION,
@@ -191,19 +187,30 @@ export class PeerController extends PuterController {
// guests share one bucket and no host can be relayed for by
// more guests per minute than this — the only ceiling on guest
// spend we can apply before the bytes are already spent.
// A grant that doesn't parse can't name a bucket, so those
// requests fall back to the caller's own network.
// The limiter counts a request when it admits it, so only a
// grant that would redeem names a host; otherwise anyone
// holding the host's identifier could drain the bucket and
// lock out every real guest. Anything else - forged, expired,
// malformed, or no secret configured - is charged to the
// caller's own network.
rateLimit: {
scope: 'peer-guest-turn',
limit: 60,
window: 60_000,
key: (req: Request) => {
const claimed = readClaimedGrantIdentifier(
(req.body as { grant?: unknown } | undefined)
?.grant,
);
return claimed
? `host:${claimed}`
const secret =
this.config.peers?.guest_turn?.grant_secret;
const verified = secret
? verifyGuestGrant({
grant: (
req.body as
{ grant?: unknown } | undefined
)?.grant,
secret,
})
: null;
return verified?.status === 'ok'
? `host:${verified.customIdentifier}`
: `net:${computeNetworkFingerprint(req)}`;
},
},
+1 -1
View File
@@ -19,7 +19,7 @@
import { toMicroCents } from '../../services/metering/utils';
// Microcents per byte of TURN egress ($0.05/GB).
// Microcents per byte of TURN egress ($0.10/GB).
export const PEER_COSTS = {
'turn:egress-bytes': toMicroCents(0.1 / 1000 ** 3),
} as const;
@@ -1,7 +1,6 @@
import { createHmac } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import {
readClaimedGrantIdentifier,
signGuestGrant,
verifyGuestGrant,
} from './guestGrant.js';
@@ -243,59 +242,3 @@ describe('signGuestGrant / verifyGuestGrant', () => {
});
});
});
describe('readClaimedGrantIdentifier', () => {
it('reads the identifier from a valid grant', () => {
const { grant } = signGuestGrant({
customIdentifier: USER_ID,
ttlSeconds: 60,
secret: SECRET,
});
expect(readClaimedGrantIdentifier(grant)).toBe(USER_ID);
});
it('reads the claimed identifier without checking the signature', () => {
// Bucketing runs before verification, so this is expected: a forged
// grant still names a bucket, and the handler still rejects it.
const grant = forgeGrant(
{ id: USER_ID, exp: 9_999_999_999, n: 'x' },
{ secret: 'wrong-secret' },
);
expect(readClaimedGrantIdentifier(grant)).toBe(USER_ID);
});
it('reads an expired grant, which the handler then rejects', () => {
const { grant } = signGuestGrant({
customIdentifier: USER_ID,
ttlSeconds: 60,
secret: SECRET,
now: 1_000_000_000_000,
});
expect(readClaimedGrantIdentifier(grant)).toBe(USER_ID);
expect(verifyGuestGrant({ grant, secret: SECRET })).toEqual({
status: 'expired',
});
});
it.each([
['a non-string', 42],
['undefined', undefined],
['a wrong-shaped string', 'not-a-grant'],
['an unknown version', 'pg2.abc.def'],
['an over-long string', `pg1.${'a'.repeat(600)}.sig`],
[
'a non-JSON payload',
`pg1.${Buffer.from('x').toString('base64url')}.sig`,
],
])('returns null for %s', (_label, grant) => {
expect(readClaimedGrantIdentifier(grant)).toBeNull();
});
it('returns null for an identifier of the wrong shape', () => {
const grant = forgeGrant({ id: 'nope', exp: 9_999_999_999 });
expect(readClaimedGrantIdentifier(grant)).toBeNull();
});
});
@@ -171,37 +171,3 @@ export const verifyGuestGrant = ({
expiresAt: payload.exp,
};
};
/**
* Read the claimed identifier out of a grant _without_ verifying it, for
* rate-limit bucketing only.
*
* Bucketing has to happen before the handler runs, and a forged grant is
* rejected there without reaching the upstream API — so an unverified read is
* enough to put a host's guests in one bucket, and the worst a forger achieves
* is choosing which bucket their own rejections land in. Never use this to
* decide attribution.
*
* @returns The claimed identifier, or null if the grant doesn't parse.
*/
export const readClaimedGrantIdentifier = (grant: unknown): string | null => {
if (typeof grant !== 'string' || grant.length > MAX_GRANT_LENGTH) {
return null;
}
const parts = grant.split('.');
if (parts.length !== 3 || parts[0] !== GRANT_VERSION) return null;
try {
const payload = JSON.parse(
Buffer.from(parts[1]!, 'base64url').toString('utf8'),
) as { id?: unknown };
if (
typeof payload?.id !== 'string' ||
!IDENTIFIER_RE.test(payload.id)
) {
return null;
}
return payload.id;
} catch {
return null;
}
};
+2 -1
View File
@@ -26,6 +26,7 @@
*/
import type { Response } from 'express';
import { onResponseClose } from '../../core/http/onResponseClose.js';
import type { UsageDetails } from '../../drivers/ai-chat/types.js';
import { promoteStopForToolCalls } from '../../drivers/ai-chat/utils/stopReason.js';
import { anthropicUsage } from './anthropicWire.js';
@@ -79,7 +80,7 @@ export const startSse = (res: Response, opts: { ping?: boolean } = {}): Sse => {
timer.unref?.();
}
res.once('close', () => {
onResponseClose(res, () => {
ended = true;
if (timer) clearInterval(timer);
});
@@ -301,6 +301,7 @@ export class SystemController extends PuterController {
if (this.clients.email && req.actor.user?.email) {
try {
await this.clients.email.sendRaw({
stream: 'notify',
to: supportEmail,
replyTo: req.actor.user.email,
subject: `Contact from ${req.actor.user.username}`,
@@ -547,6 +547,7 @@ describe('SystemController POST /contactUs — attachments', () => {
expect(sendRaw).toHaveBeenCalledTimes(1);
const sent = sendRaw.mock.calls[0][0] as {
stream: string;
text: string;
attachments: Array<{
filename: string;
@@ -555,6 +556,7 @@ describe('SystemController POST /contactUs — attachments', () => {
contentDisposition: string;
}>;
};
expect(sent.stream).toBe('notify');
expect(sent.attachments).toHaveLength(1);
expect(sent.attachments[0].filename).toBe('payload.png');
expect(sent.attachments[0].contentType).toBe('image/png');
@@ -0,0 +1,210 @@
/*
* 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 { GetObjectCommand } from '@aws-sdk/client-s3';
import { randomBytes } from 'node:crypto';
import { Readable } from 'node:stream';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
RESPONSE_CLOSE_LISTENER_BUDGET,
setupPuterTestEnv,
TEST_USER_CREDENTIALS,
trackResponseCloseListeners,
type PuterTestEnv,
} from '../../testUtil.js';
/** File downloads over WebDAV: the bytes, ranges, and either end failing. */
describe('WebDAV GET over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 60_000);
afterAll(async () => {
await env?.shutdown();
});
const dav = (path: string, init: RequestInit = {}) => {
const port = new URL(env.apiOrigin).port;
const auth = Buffer.from(
`${TEST_USER_CREDENTIALS.username}:${TEST_USER_CREDENTIALS.password}`,
).toString('base64');
return fetch(`http://dav.puter.localhost:${port}${encodeURI(path)}`, {
...init,
headers: {
Authorization: `Basic ${auth}`,
// Uncompressed, so the bytes on the wire are the stored ones.
'Accept-Encoding': 'identity',
...(init.headers as Record<string, string> | undefined),
},
});
};
const writeFile = async (name: string, content: Buffer) => {
const { username } = env.users.user;
const user = await env.server.stores.user.getByUsername(username);
const path = `/${username}/Documents/${name}`;
const { fsEntry } = await env.server.services.fs.write(user!.id, {
fileMetadata: {
path,
size: content.byteLength,
contentType: 'application/octet-stream',
},
fileContent: content,
});
return { path, uuid: fsEntry.uuid };
};
it('returns the stored bytes, whole or as a range', async () => {
const content = randomBytes(128 * 1024 + 3);
const file = await writeFile('dav-get.bin', content);
const whole = await dav(file.path);
expect(whole.status).toBe(200);
expect(whole.headers.get('content-length')).toBe(
String(content.byteLength),
);
expect(Buffer.from(await whole.arrayBuffer())).toEqual(content);
const range = await dav(file.path, {
headers: { Range: 'bytes=10-19' },
});
expect(range.status).toBe(206);
expect(range.headers.get('content-range')).toBe(
`bytes 10-19/${content.byteLength}`,
);
expect(Buffer.from(await range.arrayBuffer())).toEqual(
content.subarray(10, 20),
);
});
/**
* Storage hands over one chunk of `file` and then goes quiet, as a large
* object does while the client is still reading.
*/
const stallStorage = (file: { uuid: string }) => {
const s3 = env.server.stores.s3Object;
const client = env.server.clients.s3.get(s3.resolveRegion(null));
const send = client.send.bind(client);
const bodies: Readable[] = [];
const spy = vi.spyOn(client, 'send').mockImplementation(((
command: unknown,
) => {
if (
!(command instanceof GetObjectCommand) ||
command.input.Key !== file.uuid
) {
return send(command as never);
}
let pushed = false;
const body = new Readable({
read() {
if (!pushed) {
pushed = true;
this.push(Buffer.alloc(16 * 1024, 1));
}
},
});
bodies.push(body);
return Promise.resolve({
Body: body,
ContentLength: 1024 * 1024,
ContentType: 'application/octet-stream',
});
}) as never);
return { bodies, restore: () => spy.mockRestore() };
};
const expectFullDownload = async (path: string) => {
const response = await dav(path);
expect(response.status).toBe(200);
expect((await response.arrayBuffer()).byteLength).toBe(1024 * 1024);
};
it('releases the storage stream when the client aborts', async () => {
const file = await writeFile(
'dav-abort.bin',
Buffer.alloc(1024 * 1024, 1),
);
const storage = stallStorage(file);
try {
const abort = new AbortController();
const response = await dav(file.path, { signal: abort.signal });
expect(response.status).toBe(200);
await response.body!.getReader().read();
abort.abort();
expect(storage.bodies).toHaveLength(1);
await vi.waitFor(() =>
expect(storage.bodies[0]!.destroyed).toBe(true),
);
} finally {
storage.restore();
}
await expectFullDownload(file.path);
});
it('cuts the download off when storage fails mid-stream', async () => {
const file = await writeFile(
'dav-reset.bin',
Buffer.alloc(1024 * 1024, 1),
);
const storage = stallStorage(file);
try {
const response = await dav(file.path);
expect(response.status).toBe(200);
const reader = response.body!.getReader();
await reader.read();
storage.bodies[0]!.destroy(new Error('upstream reset'));
const outcome = await (async () => {
try {
while (!(await reader.read()).done);
return 'ended';
} catch {
return 'cut off';
}
})();
expect(outcome).toBe('cut off');
} finally {
storage.restore();
}
await expectFullDownload(file.path);
});
it('stays within the close listener budget per response', async () => {
const file = await writeFile('dav-listeners.bin', randomBytes(4096));
const tracker = trackResponseCloseListeners();
try {
const response = await dav(file.path);
expect(response.status).toBe(200);
await response.arrayBuffer();
} finally {
tracker.restore();
}
const peak = tracker.peak(
(req) => req.method === 'GET' && req.url === file.path,
);
expect(peak).toBeGreaterThan(0);
expect(peak).toBeLessThanOrEqual(RESPONSE_CLOSE_LISTENER_BUDGET);
});
});
@@ -1365,6 +1365,7 @@ describe('WebDAVController verbs', () => {
callback();
},
});
sink.on('error', () => {});
sink.on('finish', () => {
captured.ended = true;
});
@@ -21,7 +21,6 @@ import type { Team2faLookup } from '../../core/http/middleware/gates.js';
import { compare as bcryptCompare } from 'bcrypt';
import type { Request, Response } from 'express';
import { posix as pathPosix } from 'node:path';
import { pipeline } from 'node:stream/promises';
import type { Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import { pipeToResponse } from '../../core/http/pipeToResponse.js';
@@ -551,12 +550,7 @@ export class WebDAVController extends PuterController {
'Content-Length': String(result.contentLength ?? 0),
});
}
try {
await pipeline(result.body, res);
} catch {
// Client disconnect or upstream stream error: pipeline already
// tore down both ends, so nothing is left dangling to crash on.
}
await pipeToResponse(result.body, res);
}
// -- PROPFIND ----------------------------------------------------
@@ -813,7 +807,10 @@ export class WebDAVController extends PuterController {
const userId = actor.user!.id as number;
await this.#assertCreate(actor, davPath);
const existing = await this.stores.fsEntry.getEntryByPath(davPath);
// A name only the collation matches is taken too.
const existing = await this.stores.fsEntry.getEntryByPath(davPath, {
foldedNames: true,
});
if (existing)
throw new HttpError(405, 'Already exists', {
legacyCode: 'bad_request',
@@ -937,7 +934,9 @@ export class WebDAVController extends PuterController {
await this.#assertCreate(actor, destPath);
const overwrite = req.headers.overwrite !== 'F';
const destExists = await this.stores.fsEntry.getEntryByPath(destPath);
const destExists = await this.stores.fsEntry.getEntryByPath(destPath, {
foldedNames: true,
});
if (destExists && !overwrite)
throw new HttpError(412, 'Destination exists and Overwrite=F', {
legacyCode: 'conflict',
@@ -985,7 +984,9 @@ export class WebDAVController extends PuterController {
await this.#assertCreate(actor, destPath);
const overwrite = req.headers.overwrite !== 'F';
const destExists = await this.stores.fsEntry.getEntryByPath(destPath);
const destExists = await this.stores.fsEntry.getEntryByPath(destPath, {
foldedNames: true,
});
if (destExists && !overwrite)
throw new HttpError(412, 'Destination exists and Overwrite=F', {
legacyCode: 'conflict',
@@ -243,6 +243,33 @@ describe('WispController', () => {
expect(await verifyStatus(token)).toBe(403);
});
it('binds a token minted by an access token to that token while the replica lags', async () => {
const { sessionActor, apiTokenActor, apiToken } = await freshUser();
const db = server.clients.db;
const original = db.read.bind(db);
const spy = vi
.spyOn(db, 'read')
.mockImplementation(async (sql: string, params?: unknown[]) =>
sql.includes("FROM `sessions` WHERE `kind` = 'access_token'")
? []
: original(sql, params),
);
let token: string;
try {
token = await relayTokenFor(apiTokenActor);
} finally {
spy.mockRestore();
}
expect(await verifyStatus(token)).toBe(200);
await server.services.auth.revokeAccessToken(
sessionActor,
apiToken,
);
expect(await verifyStatus(token)).toBe(403);
});
it('applies the account checks to a token with no session claim', async () => {
const { row } = await freshUser();
const token = server.services.token.sign(
@@ -151,11 +151,12 @@ export class WispController extends PuterController {
/**
* The session a relay token lives no longer than. An access token's is its
* own session row; the actor doesn't carry it.
* own session row.
*/
async #sessionUidFor(actor: Actor): Promise<string | undefined> {
if (actor.session?.uid) return actor.session.uid;
if (!actor.accessToken) return undefined;
if (actor.accessToken.sessionUid) return actor.accessToken.sessionUid;
const row = await this.stores.session.findActiveByAccessTokenUid(
actor.accessToken.uid,
);
+12 -1
View File
@@ -47,6 +47,11 @@ export interface ActorAccessToken {
* reach while revokes, audits and gate hooks can still name the app.
*/
godmodeApp?: ActorApp | null;
/**
* The token's own session row, checked live when it authenticated. Unset
* when the token carries no `session_uid` naming its row.
*/
sessionUid?: string;
}
export interface Actor {
@@ -71,8 +76,14 @@ export interface Actor {
/**
* Set when authenticated by a session token or an app-under-user token
* carrying one. `kind` mirrors the session row (`web`, `app`, `worker`).
* `parentUid` is the sign-in a `web` session was handed out under, set only
* on a first-party page's session.
*/
session?: { uid: string; kind?: string | null } | null;
session?: {
uid: string;
kind?: string | null;
parentUid?: string | null;
} | null;
/**
* Events handler runs behind this actor's writes; set only from an events
* handler token or a token one minted. Provenance, not authority, so
+2 -1
View File
@@ -19,6 +19,7 @@
import type { Response } from 'express';
import { HttpError } from './HttpError.js';
import { onResponseClose } from './onResponseClose.js';
/**
* A signal that fires when the caller hangs up before its response is done, so
@@ -38,6 +39,6 @@ export const abortOnDisconnect = (res: Response): AbortSignal => {
}
};
if (res.destroyed) onClose();
else res.once('close', onClose);
else onResponseClose(res, onClose);
return abort.signal;
};
@@ -50,6 +50,13 @@ declare global {
tokenAuthFailed?: boolean;
/**
* What `authenticate` threw while checking the token (a database
* failure, say). The token may be fine; gates rethrow this instead
* of answering 401.
*/
tokenAuthError?: unknown;
/**
* Set when a token authenticated but its app is on the origin
* blocklist. The auth probe leaves `actor` unset; gates translate
@@ -97,6 +104,13 @@ declare global {
*/
deviceFingerprint?: string;
/**
* The client's TLS fingerprint, read from the header named by
* `tlsFingerprintHeader`. Shared by every client on the same TLS
* stack, so it identifies software, not a person.
*/
tlsFingerprint?: string;
/**
* Parsed cookies, populated by the global `cookie-parser`
* middleware.
@@ -0,0 +1,110 @@
/*
* 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 { setupPuterTestEnv, type PuterTestEnv } from '../../../testUtil.js';
const loadShed = () =>
Object.assign(new Error('Database operation failed'), {
code: 'dbBatchFailed',
});
/**
* A token whose lookup fails is not a dead token: clients discard a token on
* 401 `token_auth_failed`, so that answer is kept for tokens that checked out
* invalid.
*/
describe('auth probe when the token lookup fails', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const get = (path: string, token: string) =>
fetch(new URL(path, env.apiOrigin), {
headers: { authorization: `Bearer ${token}` },
});
/** Run `fn` while the next user lookup rejects with `error`. */
const withFailedUserLookup = async <T>(
error: Error,
fn: () => Promise<T>,
): Promise<T> => {
const spy = vi
.spyOn(env.server.stores.user, 'getByUuid')
.mockRejectedValueOnce(error);
try {
return await fn();
} finally {
spy.mockRestore();
}
};
it.each([
['a session token', () => env.users.user.token],
['an access token', () => env.users.user.apiToken],
])(
'answers %s with 503 db_unavailable during a database outage',
async (_kind, tokenOf) => {
const token = tokenOf();
const res = await withFailedUserLookup(loadShed(), () =>
get('/whoami', token),
);
expect(res.status).toBe(503);
expect(res.headers.get('retry-after')).toBe('5');
expect(await res.json()).toEqual({
error: 'Service temporarily unavailable',
message: 'Service temporarily unavailable',
code: 'db_unavailable',
});
expect((await get('/whoami', token)).status).toBe(200);
},
);
it('answers an unexpected lookup failure with a generic 500, not a 401', async () => {
const res = await withFailedUserLookup(new Error('boom'), () =>
get('/whoami', env.users.user.token),
);
expect(res.status).toBe(500);
expect((await res.json()).code).toBe('internal_error');
});
it('serves a route that does not require auth anonymously', async () => {
const res = await withFailedUserLookup(loadShed(), () =>
get('/version', env.users.user.token),
);
expect(res.status).toBe(200);
});
it('still answers an invalid token with 401 token_auth_failed', async () => {
const res = await get('/whoami', 'not-a-real-jwt');
expect(res.status).toBe(401);
expect(await res.json()).toEqual({
error: 'Authentication failed',
message: 'Authentication failed',
code: 'token_auth_failed',
});
});
});
@@ -555,17 +555,18 @@ describe('createAuthProbe — actor attachment + failure tracking', () => {
expect(req.tokenAuthFailed).toBe(true);
});
it('never rejects — sets tokenAuthFailed even when AuthService throws', async () => {
it('never rejects — records the error, not tokenAuthFailed, when AuthService throws', async () => {
const stub = makeStubAuth();
stub.setNext('throw');
const probe = createAuthProbe({ authService: stub.service });
const { req, next } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer bad-tok' } }),
makeReq({ headers: { authorization: 'Bearer some-tok' } }),
);
// Critical invariant: the probe NEVER rejects, no matter what.
expect(next).toHaveBeenCalledWith();
expect(req.tokenAuthFailed).toBe(true);
expect(req.tokenAuthFailed).toBeUndefined();
expect(req.tokenAuthError).toBeInstanceOf(Error);
expect(req.actor).toBeUndefined();
});
@@ -880,4 +881,27 @@ describe('createAuthProbe (integration) — real session token → real actor',
expect(req.actor).toBeUndefined();
expect(req.tokenAuthFailed).toBe(true);
});
it('records a failed user lookup on a valid token without marking it invalid', async () => {
const user = await makeUser();
const { token } = await authService.createSessionToken(user);
const lookupError = Object.assign(new Error('db down'), {
code: 'dbBatchFailed',
});
const spy = vi
.spyOn(server.stores.user, 'getByUuid')
.mockRejectedValueOnce(lookupError);
try {
const probe = createAuthProbe({ authService });
const { req } = await runProbe(
probe,
makeReq({ headers: { authorization: `Bearer ${token}` } }),
);
expect(req.actor).toBeUndefined();
expect(req.tokenAuthFailed).toBeUndefined();
expect(req.tokenAuthError).toBe(lookupError);
} finally {
spy.mockRestore();
}
});
});
@@ -45,9 +45,9 @@ interface AuthProbeOptions {
* and valid — attaches an `Actor` to `req.actor`.
*
* Key property: this middleware **never rejects**. Missing tokens, malformed
* tokens, expired tokens, tokens pointing at deleted users — all result in
* `req.actor` being left undefined. Per-route gates decide whether absence is
* acceptable.
* tokens, expired tokens, tokens pointing at deleted users, and lookups that
* fail outright all result in `req.actor` being left undefined. Per-route gates
* decide whether absence is acceptable.
*
* Token lookup order:
*
@@ -153,8 +153,10 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
} else if (result.invalid) {
req.tokenAuthFailed = true;
}
} catch {
req.tokenAuthFailed = true;
} catch (err) {
// Not a verdict on the token: routes that require auth answer
// with this error, routes that don't serve the request anonymously.
req.tokenAuthError = err;
}
next();
};
@@ -224,7 +226,7 @@ const extractToken = (
}
}
// 5. Query string (used by e.g. QR login, asset URLs).
// 5. Query string (used by e.g. asset URLs).
const queryToken = (req.query as { auth_token?: unknown } | undefined)
?.auth_token;
if (typeof queryToken === 'string' && queryToken.length > 0) {
@@ -28,6 +28,7 @@ import {
} from '../../../services/metering/costs.js';
import type { UsageInput } from '../../../services/metering/types';
import '../expressAugmentation';
import { onResponseClose } from '../onResponseClose';
/**
* Subset of the metering service this middleware needs. Metering is optional
@@ -195,7 +196,7 @@ export const createEgressMeteringMiddleware = (
// 'close' rather than 'finish': it fires for a response that completed
// AND for one whose connection died mid-stream, and an aborted download
// still sent whatever it sent.
res.once('close', () => {
onResponseClose(res, () => {
try {
if (!isMeteredHost(req)) return;
const actor = resolveEgressActor(req);
@@ -23,8 +23,11 @@ import { createFingerprintMiddleware } from './fingerprint';
const VALID_FP = 'dev-fingerprint-abc123';
const run = (reqPartial: Partial<Request> & Record<string, unknown>) => {
const middleware = createFingerprintMiddleware();
const run = (
reqPartial: Partial<Request> & Record<string, unknown>,
opts?: Parameters<typeof createFingerprintMiddleware>[0],
) => {
const middleware = createFingerprintMiddleware(opts);
const req = {
headers: {},
...reqPartial,
@@ -90,4 +93,21 @@ describe('createFingerprintMiddleware', () => {
const { req } = run({ ip: '203.0.113.1' });
expect(req.deviceFingerprint).toBeUndefined();
});
it('reads the TLS fingerprint from the configured header only', () => {
const ja4 = 't13d1516h2_8daaf6152771_02713d6af862';
const headers = { 'x-tls-fp': ja4 };
expect(run({ headers }).req.tlsFingerprint).toBeUndefined();
expect(
run({ headers }, { tlsFingerprintHeader: 'X-TLS-FP' }).req
.tlsFingerprint,
).toBe(ja4);
expect(
run(
{ headers: { 'x-tls-fp': 'not a fingerprint!' } },
{ tlsFingerprintHeader: 'x-tls-fp' },
).req.tlsFingerprint,
).toBeUndefined();
});
});
@@ -33,6 +33,20 @@ const DEVICE_FINGERPRINT_SHAPE = /^[A-Za-z0-9._-]{8,128}$/;
/** Header the GUI may send the device fingerprint on for non-signup requests. */
const DEVICE_FINGERPRINT_HEADER = 'x-puter-device-fingerprint';
/** JA3 / JA4-style values; anything else is dropped. */
const TLS_FINGERPRINT_SHAPE = /^[A-Za-z0-9_]{10,64}$/;
function readTlsFingerprint(
req: { headers?: Record<string, unknown> },
header: string | undefined,
): string | undefined {
if (!header) return undefined;
const value = req.headers?.[header];
return typeof value === 'string' && TLS_FINGERPRINT_SHAPE.test(value)
? value
: undefined;
}
function readDeviceFingerprint(req: {
body?: unknown;
headers?: Record<string, unknown>;
@@ -65,15 +79,22 @@ function readDeviceFingerprint(req: {
* and spoofable, but stable per real device across IP rotation. The rate
* limiter's 'fingerprint' strategy appends it to the network hash so each
* device behind a shared network gets its own bucket.
* - `req.tlsFingerprint` — set only when `tlsFingerprintHeader` is configured and
* the request carries a well-shaped value in that header. It names the
* client's TLS stack, so every client on the same build shares it.
*
* Install AFTER the body parsers (so the body fingerprint is readable) and
* before `requestContext` (so the snapshot into ALS already carries them).
* Never rejects — a missing/invalid device fingerprint is simply absent.
*/
export const createFingerprintMiddleware = (): RequestHandler => {
export const createFingerprintMiddleware = (
opts: { tlsFingerprintHeader?: string } = {},
): RequestHandler => {
const tlsHeader = opts.tlsFingerprintHeader?.toLowerCase();
return (req, _res, next) => {
req.networkFingerprint = computeNetworkFingerprint(req);
req.deviceFingerprint = readDeviceFingerprint(req);
req.tlsFingerprint = readTlsFingerprint(req, tlsHeader);
next();
};
};
@@ -288,6 +288,22 @@ describe('requireAuthGate', () => {
});
expectHttpError(got, 401, 'reauth_required');
});
it('passes on the error when the token could not be checked', () => {
const lookupError = Object.assign(new Error('down'), {
code: 'dbBatchFailed',
});
const got = runGate(requireAuthGate(), { tokenAuthError: lookupError });
expect(got).toBe(lookupError);
});
it('still answers token_auth_failed when a check found the token invalid', () => {
const got = runGate(requireAuthGate(), {
tokenAuthFailed: true,
tokenAuthError: new Error('down'),
});
expectHttpError(got, 401, 'token_auth_failed');
});
});
// ── requireUserActorGate ────────────────────────────────────────────
@@ -330,6 +346,14 @@ describe('requireUserActorGate', () => {
expectHttpError(got, 401, 'token_missing');
});
it('passes on the error when the token could not be checked', () => {
const lookupError = new Error('down');
const got = runGate(requireUserActorGate(), {
tokenAuthError: lookupError,
});
expect(got).toBe(lookupError);
});
it('rejects a FULL-ACCESS access token too — account routes stay closed', () => {
// The account wall is actor-type based: even a full-access PAT (which
// the resource wall lets through) is rejected here, so it can never
+4 -1
View File
@@ -50,7 +50,7 @@ import { assertVerifiedEmail } from '../verifiedEmail';
// Make sure the `Express.Request.actor` augmentation is in scope.
import '../expressAugmentation';
const rejectAuth = (req: Request): HttpError => {
const rejectAuth = (req: Request): unknown => {
if (req.requiresReauth) {
return new HttpError(401, 'Re-authentication required', {
legacyCode: 'reauth_required',
@@ -71,6 +71,9 @@ const rejectAuth = (req: Request): HttpError => {
legacyCode: 'token_auth_failed',
});
}
// The token couldn't be checked, so a 401 would wrongly tell the client
// to discard it; the error handler answers (503 for a database outage).
if (req.tokenAuthError !== undefined) return req.tokenAuthError;
return new HttpError(401, 'Missing authentication token', {
legacyCode: 'token_missing',
});
@@ -20,6 +20,7 @@
import type { Request, RequestHandler, Response } from 'express';
import { actorUid } from '../../actor';
import { HttpError } from '../HttpError';
import { onResponseClose } from '../onResponseClose';
import { createJsonBodyParser } from './jsonBody';
/** The global JSON parser's limit, which applies before auth. */
@@ -152,7 +153,7 @@ export const createLargeJsonBodyParser = ({
}
inFlightBytes += declared;
inFlightByUser.set(user, userBytes + declared);
res.once('close', () => {
onResponseClose(res, () => {
inFlightBytes -= declared;
const left = (inFlightByUser.get(user) ?? 0) - declared;
if (left > 0) inFlightByUser.set(user, left);
@@ -26,7 +26,7 @@ import type { IConfig } from '../../../types';
* Browsers send none of those variations, but config values do — a hand-edited
* `origin` with a trailing slash would otherwise reject every real request.
*/
const normalizeOrigin = (raw: string | undefined): string =>
export const normalizeOrigin = (raw: string | undefined): string =>
(raw ?? '').trim().replace(/\/+$/, '').toLowerCase();
/**
@@ -18,7 +18,7 @@
*/
import type { Request } from 'express';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { makeActor } from '../../actor';
import {
@@ -1132,14 +1132,150 @@ describe('resolvePublicHostedIdentity', () => {
const reqOf = (init: {
cookies?: Record<string, string>;
actor?: unknown;
headers?: Record<string, string>;
query?: Record<string, string>;
}): Request =>
({
cookies: init.cookies ?? {},
actor: init.actor,
headers: {},
query: {},
headers: init.headers ?? {},
query: init.query ?? {},
}) as unknown as Request;
describe('with the launch token in the Referer', () => {
const host = 'launch.site.puter.localhost';
const cookieName = HOSTED_ASSET_COOKIES.public.name;
const launch = async () => {
const user = await makeUser();
const sub = `launch-${uuidv4().slice(0, 8)}`;
const app = await server.stores.app.create(
{ name: sub, title: sub, index_url: `http://${host}/` },
{ ownerUserId: user.id },
);
const token = await authService.getUserAppToken(
{ user } as unknown as Parameters<
typeof authService.getUserAppToken
>[0],
app.uid,
);
const actor = (await authService.authenticateFromToken(token))!;
const cookie = await authService.createHostedAssetToken('public', {
appUid: app.uid,
userUid: user.uuid,
sessionUuid: actor.session?.uid,
subdomain: 'launch',
host,
});
return { user, appUid: app.uid, token, cookie, actor };
};
const resolve = (
appUid: string,
init: Parameters<typeof reqOf>[0],
expectedHost = host,
) =>
resolvePublicHostedIdentity({
req: reqOf(init),
authService,
sessionCookieName: 'puter_auth_token',
expectedAppUid: appUid,
expectedSubdomain: 'launch',
expectedHost,
});
const refererFor = (token: string) =>
`http://${host}/?puter.auth.token=${encodeURIComponent(token)}`;
it('takes the cookie when the token names its user and session', async () => {
const { user, appUid, token, cookie } = await launch();
const authenticate = vi.spyOn(authService, 'authenticate');
try {
const out = await resolve(appUid, {
cookies: { [cookieName]: cookie },
headers: { referer: refererFor(token) },
});
expect(out).toMatchObject({
source: 'public-cookie',
userUid: user.uuid,
hasValidPublicCookie: true,
});
expect(authenticate).not.toHaveBeenCalled();
} finally {
authenticate.mockRestore();
}
});
it('authenticates a query token even when it names the cookie identity', async () => {
const { user, appUid, token, cookie } = await launch();
const out = await resolve(appUid, {
cookies: { [cookieName]: cookie },
query: { 'puter.auth.token': token },
});
expect(out.source).toBe('query');
expect(out.userUid).toBe(user.uuid);
expect(
(out as { hasValidPublicCookie?: boolean })
.hasValidPublicCookie,
).toBeUndefined();
});
it('authenticates the token when the cookie was minted for another host', async () => {
const { user, appUid, token, cookie } = await launch();
const out = await resolve(
appUid,
{
cookies: { [cookieName]: cookie },
headers: { referer: refererFor(token) },
},
'other.site.puter.localhost',
);
expect(out.source).toBe('referrer');
expect(out.userUid).toBe(user.uuid);
});
it('authenticates a token that names another app under the same session', async () => {
const { user, appUid, cookie, actor } = await launch();
const otherAppToken = server.services.token.sign('auth', {
type: 'app-under-user',
version: '2',
user_uid: user.uuid,
app_uid: `app-${uuidv4()}`,
session_uid: actor.session!.uid,
});
const authenticate = vi.spyOn(authService, 'authenticate');
try {
const out = await resolve(appUid, {
cookies: { [cookieName]: cookie },
headers: { referer: refererFor(otherAppToken) },
});
expect(authenticate).toHaveBeenCalledTimes(1);
expect(out.source).toBe('public-cookie');
} finally {
authenticate.mockRestore();
}
});
it('never resolves a scoped access token, with or without the cookie', async () => {
const { user, appUid, cookie } = await launch();
const scoped = await authService.createAccessToken(
makeActor({
user: { id: user.id, uuid: user.uuid, username: user.username },
}),
[[`user:${user.uuid}:email:read`]],
);
const withCookie = await resolve(appUid, {
cookies: { [cookieName]: cookie },
headers: { referer: refererFor(scoped) },
});
expect(withCookie.source).toBe('public-cookie');
const withoutCookie = await resolve(appUid, {
headers: { referer: refererFor(scoped) },
});
expect(withoutCookie.source).toBe('none');
});
});
it('returns the cookie identity when present and valid', async () => {
const user = await makeUser();
const appUid = `app-${uuidv4()}`;
@@ -86,6 +86,10 @@ export interface PrivateIdentity {
hasValidPrivateCookie?: boolean;
}
type PublicCookieClaims = Awaited<
ReturnType<AuthService['verifyHostedAssetToken']>
>;
interface SubdomainLike {
user_id?: number | null;
}
@@ -533,8 +537,63 @@ export async function resolvePublicHostedIdentity(opts: {
}
}
const { name: publicCookieNameV2, legacyName: publicCookieNameLegacy } =
HOSTED_ASSET_COOKIES.public;
const publicCookieToken =
(typeof cookies?.[publicCookieNameV2] === 'string'
? cookies[publicCookieNameV2]
: null) ??
(typeof cookies?.[publicCookieNameLegacy] === 'string'
? cookies[publicCookieNameLegacy]
: null);
let publicCookieClaims: Promise<PublicCookieClaims | null> | undefined;
const verifyPublicCookie = () =>
(publicCookieClaims ??= (async () => {
if (!publicCookieToken) return null;
try {
return await authService.verifyHostedAssetToken(
'public',
publicCookieToken,
{
appUid: expectedAppUid,
subdomain: expectedSubdomain,
host: expectedHost,
},
);
} catch {
// v1-signed cookies land here and get re-minted.
return null;
}
})());
const publicCookieIdentity = (claims: PublicCookieClaims) => ({
source: 'public-cookie' as const,
userUid: claims.userUid,
sessionUuid: claims.sessionUuid,
hasValidPublicCookie: true,
});
const bootstrap = getBootstrapToken(req);
if (bootstrap) {
// A launched app's own assets carry its launch token in the Referer.
// When that token names the cookie's user and session, the cookie
// already stands for it and nothing needs re-authenticating or minting.
if (
bootstrap.source === 'referrer' &&
publicCookieToken &&
expectedAppUid
) {
const named = authService.signedSessionClaims(bootstrap.token);
if (named && (!named.appUid || named.appUid === expectedAppUid)) {
const claims = await verifyPublicCookie();
if (
claims &&
claims.userUid === named.userUid &&
claims.sessionUuid === named.sessionUuid
) {
return publicCookieIdentity(claims);
}
}
}
try {
const actor = await authService.authenticateFromToken(
bootstrap.token,
@@ -554,36 +613,8 @@ export async function resolvePublicHostedIdentity(opts: {
}
}
const { name: publicCookieNameV2, legacyName: publicCookieNameLegacy } =
HOSTED_ASSET_COOKIES.public;
const publicCookieToken =
(typeof cookies?.[publicCookieNameV2] === 'string'
? cookies[publicCookieNameV2]
: null) ??
(typeof cookies?.[publicCookieNameLegacy] === 'string'
? cookies[publicCookieNameLegacy]
: null);
if (publicCookieToken) {
try {
const claims = await authService.verifyHostedAssetToken(
'public',
publicCookieToken,
{
appUid: expectedAppUid,
subdomain: expectedSubdomain,
host: expectedHost,
},
);
return {
source: 'public-cookie',
userUid: claims.userUid,
sessionUuid: claims.sessionUuid,
hasValidPublicCookie: true,
};
} catch {
/* fall through — v1-signed cookies land here and get re-minted */
}
}
const claims = await verifyPublicCookie();
if (claims) return publicCookieIdentity(claims);
return { source: 'none' };
}
@@ -2763,6 +2763,321 @@ describe('public hosted asset billing identity', () => {
});
});
describe('public hosted assets requested with the launch token in the Referer', () => {
const cookieName = HOSTED_ASSET_COOKIES.public.name;
const makeLaunchedApp = async () => {
const owner = await makeUserWithHome();
const home = (await server.stores.fsEntry.getEntryByPath(
`/${owner.username}`,
))!;
const sub = `ref-${uuidv4().slice(0, 8)}`;
const host = `${sub}.site.puter.localhost`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: home.id,
});
await writeFile(
owner.id,
`/${owner.username}/index.html`,
Buffer.from('<html></html>'),
);
await writeFile(
owner.id,
`/${owner.username}/app.css`,
Buffer.from('body{}'),
);
const app = await server.stores.app.create(
{ name: sub, title: sub, index_url: `http://${host}/` },
{ ownerUserId: owner.id },
);
return { owner, sub, host, appUid: app.uid };
};
const cookieFor = async (
token: string,
site: { sub: string; host: string; appUid: string },
) => {
const actor = (await server.services.auth.authenticateFromToken(
token,
))!;
return server.services.auth.createHostedAssetToken('public', {
appUid: site.appUid,
userUid: actor.user.uuid,
sessionUuid: actor.session?.uid,
subdomain: site.sub,
host: site.host,
});
};
const refererFor = (host: string, token: string) =>
`http://${host}/?puter.app_instance_id=x&puter.auth.token=${encodeURIComponent(token)}`;
const serve = async (
host: string,
init: {
path?: string;
cookies?: Record<string, string>;
headers?: Record<string, string>;
query?: Record<string, string>;
} = {},
) => {
const req = makeReq({ hostname: host, path: '/app.css', ...init });
const { res, out } = makeRes();
await buildMiddleware()(req, res, vi.fn());
await new Promise<void>((resolve) => setImmediate(resolve));
expect(out.statusCode).toBe(200);
return { req, out };
};
const claimsOf = (cookie: string, appUid: string, host: string) =>
server.services.auth.verifyHostedAssetToken('public', cookie, {
appUid,
host,
});
const REDIS_METHODS = [
'get',
'mget',
'exists',
'hget',
'hgetall',
'smembers',
'set',
'del',
'expire',
'incr',
'incrby',
'hset',
'sadd',
'pipeline',
'multi',
];
/** Redis calls, JWT signs and full authentications made by `fn`. */
const measure = async <T>(fn: () => Promise<T>) => {
const redis = server.clients.redis as unknown as Record<
string,
unknown
>;
const redisSpies = REDIS_METHODS.filter(
(m) => typeof redis[m] === 'function',
).map((m) => [m, vi.spyOn(redis as never, m as never)] as const);
const sign = vi.spyOn(server.services.token, 'sign');
const authenticate = vi.spyOn(server.services.auth, 'authenticate');
try {
const result = await fn();
const redisCalls: Record<string, number> = {};
for (const [m, spy] of redisSpies) {
const n = (spy as unknown as { mock: { calls: unknown[] } })
.mock.calls.length;
if (n) redisCalls[m] = n;
}
return {
result,
redisCalls,
signs: sign.mock.calls.length,
authentications: authenticate.mock.calls.length,
};
} finally {
for (const [, spy] of redisSpies) spy.mockRestore();
sign.mockRestore();
authenticate.mockRestore();
}
};
it('costs the same as the cookie alone when the token names the cookie user and session', async () => {
const site = await makeLaunchedApp();
const visitor = await makeUser();
const token = await server.services.auth.getUserAppToken(
actorFor(visitor),
site.appUid,
);
const cookies = { [cookieName]: await cookieFor(token, site) };
const headers = { referer: refererFor(site.host, token) };
// Warm the caches both paths read.
await serve(site.host, { cookies });
await serve(site.host, { cookies, headers });
const cookieOnly = await measure(() => serve(site.host, { cookies }));
const withReferer = await measure(() =>
serve(site.host, { cookies, headers }),
);
expect(withReferer.result.req.egressActor?.user?.uuid).toBe(
visitor.uuid,
);
expect(withReferer.result.out.cookies).toEqual({});
expect(withReferer.signs).toBe(0);
expect(withReferer.authentications).toBe(0);
expect(withReferer.redisCalls).toEqual(cookieOnly.redisCalls);
expect(cookieOnly.signs).toBe(0);
});
it('bills the token account when the launch could not replace an older cookie', async () => {
const site = await makeLaunchedApp();
const previous = await makeUser();
const current = await makeUser();
const previousToken = await server.services.auth.getUserAppToken(
actorFor(previous),
site.appUid,
);
const currentToken = await server.services.auth.getUserAppToken(
actorFor(current),
site.appUid,
);
const oldCookies = {
[cookieName]: await cookieFor(previousToken, site),
};
const mint = vi
.spyOn(server.services.auth, 'createHostedAssetToken')
.mockRejectedValueOnce(new Error('mint failed'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
let launch;
try {
launch = await serve(site.host, {
path: '/index.html',
cookies: oldCookies,
query: { 'puter.auth.token': currentToken },
});
} finally {
mint.mockRestore();
warn.mockRestore();
}
expect(launch.out.cookies).toEqual({});
expect(launch.req.egressActor?.user?.uuid).toBe(current.uuid);
const asset = await serve(site.host, {
cookies: oldCookies,
headers: { referer: refererFor(site.host, currentToken) },
});
expect(asset.req.egressActor?.user?.uuid).toBe(current.uuid);
const replaced = asset.out.cookies[cookieName];
expect(replaced).toBeTruthy();
expect((await claimsOf(replaced, site.appUid, site.host)).userUid).toBe(
current.uuid,
);
});
it('bills the token account when a cached page of another account loads assets', async () => {
// The document never reaches the server (served from an app's own
// cache), so only the assets show which account launched it.
const site = await makeLaunchedApp();
const previous = await makeUser();
const current = await makeUser();
const previousToken = await server.services.auth.getUserAppToken(
actorFor(previous),
site.appUid,
);
const currentToken = await server.services.auth.getUserAppToken(
actorFor(current),
site.appUid,
);
const oldCookies = {
[cookieName]: await cookieFor(previousToken, site),
};
for (const path of ['/app.css', '/index.html']) {
const asset = await serve(site.host, {
path,
cookies: oldCookies,
headers: { referer: refererFor(site.host, currentToken) },
});
expect(asset.req.egressActor?.user?.uuid).toBe(current.uuid);
const replaced = asset.out.cookies[cookieName];
expect(
(await claimsOf(replaced, site.appUid, site.host)).userUid,
).toBe(current.uuid);
}
});
it('re-mints the cookie when the token names the same user under another session', async () => {
const site = await makeLaunchedApp();
const visitor = await makeUser();
const appToken = await server.services.auth.getUserAppToken(
actorFor(visitor),
site.appUid,
);
const cookies = { [cookieName]: await cookieFor(appToken, site) };
const { session, token: sessionToken } =
await server.services.auth.createSessionToken(visitor as never);
const asset = await serve(site.host, {
cookies,
headers: { referer: refererFor(site.host, sessionToken) },
});
expect(asset.req.egressActor?.user?.uuid).toBe(visitor.uuid);
const replaced = asset.out.cookies[cookieName];
expect(replaced).toBeTruthy();
expect(
(await claimsOf(replaced, site.appUid, site.host)).sessionUuid,
).toBe(session.uuid);
});
it('falls back to the cookie when the Referer token does not verify', async () => {
const site = await makeLaunchedApp();
const visitor = await makeUser();
const token = await server.services.auth.getUserAppToken(
actorFor(visitor),
site.appUid,
);
const cookies = { [cookieName]: await cookieFor(token, site) };
const tampered = `${token.slice(0, -4)}AAAA`;
const asset = await serve(site.host, {
cookies,
headers: { referer: refererFor(site.host, tampered) },
});
expect(asset.req.egressActor?.user?.uuid).toBe(visitor.uuid);
expect(asset.out.cookies).toEqual({});
});
it('bills the site owner once the shared session is revoked', async () => {
const site = await makeLaunchedApp();
const visitor = await makeUser();
const token = await server.services.auth.getUserAppToken(
actorFor(visitor),
site.appUid,
);
const cookies = { [cookieName]: await cookieFor(token, site) };
await server.services.auth.revokeAllSessionsForUserId(visitor.id);
const asset = await serve(site.host, {
cookies,
headers: { referer: refererFor(site.host, token) },
});
expect(asset.req.egressActor?.user?.uuid).toBe(site.owner.uuid);
expect(asset.out.cookies).toEqual({});
});
it('keeps responses that set the cookie out of shared caches', async () => {
const site = await makeLaunchedApp();
const visitor = await makeUser();
const token = await server.services.auth.getUserAppToken(
actorFor(visitor),
site.appUid,
);
const launch = await serve(site.host, {
path: '/index.html',
query: { 'puter.auth.token': token },
});
const cookie = launch.out.cookies[cookieName];
expect(cookie).toBeTruthy();
expect(launch.out.headers['Cache-Control']).toBe('private');
const asset = await serve(site.host, {
cookies: { [cookieName]: cookie },
headers: { referer: refererFor(site.host, token) },
});
expect(asset.out.cookies).toEqual({});
expect(asset.out.headers['Cache-Control']).toBeUndefined();
});
});
describe('site rooted in the owner tree, root moved to Trash', () => {
it('stops serving a trashed folder and resumes when restored', async () => {
const owner = await makeUserWithHome();
@@ -548,6 +548,8 @@ export const createPuterSiteMiddleware = (
requestHostname: host,
}),
);
// A shared cache must not hand this visitor's cookie to others.
res.set('Cache-Control', 'private');
}
} catch (e) {
// Best-effort — don't block the public file serve.
@@ -21,6 +21,7 @@
import crypto from 'node:crypto';
import { withSpan } from '../../../util/span.js';
import { HttpError } from '../HttpError.js';
import { onResponseClose } from '../onResponseClose.js';
import { subscriptionOverride } from '../../../services/metering/consts.js';
/**
@@ -1169,7 +1170,7 @@ export function concurrencyGate(opts) {
const { release } = guardSlot(slot, '[concurrent]');
const onDone = () => void release();
res.once('finish', onDone);
res.once('close', onDone);
onResponseClose(res, onDone);
if (res.destroyed || res.writableFinished) onDone();
next();
};
@@ -0,0 +1,105 @@
/*
* 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 { EventEmitter } from 'node:events';
import { createServer, get, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { onResponseClose } from './onResponseClose.js';
let server: Server | undefined;
afterEach(async () => {
server?.closeAllConnections();
await new Promise((resolve) => server?.close(resolve) ?? resolve(null));
server = undefined;
});
describe('onResponseClose', () => {
it('shares one close listener and runs every callback once, in order', () => {
const res = new EventEmitter();
const calls: string[] = [];
onResponseClose(res, () => calls.push('a'));
onResponseClose(res, () => calls.push('b'));
onResponseClose(res, () => calls.push('c'));
expect(res.listenerCount('close')).toBe(1);
res.emit('close');
res.emit('close');
expect(calls).toEqual(['a', 'b', 'c']);
expect(res.listenerCount('close')).toBe(0);
});
it('keeps responses apart', () => {
const first = new EventEmitter();
const second = new EventEmitter();
const onFirst = vi.fn();
const onSecond = vi.fn();
onResponseClose(first, onFirst);
onResponseClose(second, onSecond);
first.emit('close');
expect(onFirst).toHaveBeenCalledOnce();
expect(onSecond).not.toHaveBeenCalled();
});
it('runs the rest when one callback throws, and still surfaces the error', async () => {
const res = new EventEmitter();
const after = vi.fn();
const failure = new Error('callback broke');
const uncaught = vi.fn();
const listeners = process.listeners('uncaughtException');
process.removeAllListeners('uncaughtException');
process.on('uncaughtException', uncaught);
try {
onResponseClose(res, () => {
throw failure;
});
onResponseClose(res, after);
res.emit('close');
expect(after).toHaveBeenCalledOnce();
await vi.waitFor(() =>
expect(uncaught).toHaveBeenCalledWith(
failure,
expect.anything(),
),
);
} finally {
process.off('uncaughtException', uncaught);
for (const listener of listeners)
process.on('uncaughtException', listener);
}
});
it('fires when a client hangs up on a real response', async () => {
const closed = new Promise<boolean>((resolve) => {
server = createServer((_req, res) => {
onResponseClose(res, () => resolve(res.writableFinished));
res.write('partial');
});
});
await new Promise<void>((resolve) => server!.listen(0, resolve));
const { port } = server!.address() as AddressInfo;
const req = get(`http://127.0.0.1:${port}/`, (res) => {
res.once('data', () => req.destroy());
});
req.on('error', () => {});
expect(await closed).toBe(false);
});
});
+57
View File
@@ -0,0 +1,57 @@
/*
* 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/>.
*/
/** Only what's used here, so test doubles and express's Response both fit. */
interface ClosingResponse {
once(event: 'close', listener: () => void): unknown;
}
const callbacksByResponse = new WeakMap<ClosingResponse, Array<() => void>>();
/**
* Runs `callback` once when the response closes. Every callback on a response
* shares one `close` listener, since Node warns past ten and request tracing
* adds its own. Callbacks run in the order added; one that throws doesn't stop
* the rest.
*/
export const onResponseClose = (
res: ClosingResponse,
callback: () => void,
): void => {
const callbacks = callbacksByResponse.get(res);
if (callbacks) {
callbacks.push(callback);
return;
}
const pending = [callback];
callbacksByResponse.set(res, pending);
res.once('close', () => {
for (const run of pending.splice(0)) {
try {
run();
} catch (e) {
// Surfaces as an uncaught error, as it would from a listener
// of its own.
queueMicrotask(() => {
throw e;
});
}
}
});
};
@@ -0,0 +1,147 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
RESPONSE_CLOSE_LISTENER_BUDGET,
setupPuterTestEnv,
trackResponseCloseListeners,
type PuterTestEnv,
} from '../../testUtil.js';
/**
* The routes that hold a response open the longest, where middleware, gates and
* the handler each want to hear when it closes.
*/
describe('close listeners per response over HTTP', () => {
let env: PuterTestEnv;
let filePath = '';
beforeAll(async () => {
env = await setupPuterTestEnv({
meteringEnforcement: { subscriptions: false },
} as never);
const { username } = env.users.user;
const user = await env.server.stores.user.getByUsername(username);
filePath = `/${username}/Documents/close-listeners.txt`;
await env.server.services.fs.write(user!.id, {
fileMetadata: {
path: filePath,
size: 4096,
contentType: 'text/plain',
},
fileContent: Buffer.alloc(4096, 97),
});
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const streamedChat = {
model: 'fake',
stream: true,
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 16,
};
const post = (path: string, token: string, body: unknown) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Origin: env.apiOrigin,
},
body: JSON.stringify(body),
});
const routes: Array<[string, string, () => Promise<Response>]> = [
[
'a streamed driver call',
'/drivers/call',
() =>
post('/drivers/call', env.users.user.token, {
interface: 'puter-chat-completion',
service: 'ai-chat',
method: 'complete',
args: streamedChat,
}),
],
[
'an Anthropic-style streamed completion',
'/puterai/anthropic/v1/messages',
() =>
post(
'/puterai/anthropic/v1/messages',
env.users.user.apiToken,
streamedChat,
),
],
[
'an OpenAI-style streamed completion',
'/puterai/openai/v1/chat/completions',
() =>
post(
'/puterai/openai/v1/chat/completions',
env.users.user.apiToken,
streamedChat,
),
],
[
'a file read',
'/read',
() => {
const url = new URL('/read', env.apiOrigin);
url.searchParams.set('file', filePath);
return fetch(url, {
headers: {
Authorization: `Bearer ${env.users.user.token}`,
},
});
},
],
[
'a v2 file read',
'/fs/read',
() => {
const url = new URL('/fs/read', env.apiOrigin);
url.searchParams.set('path', filePath);
return fetch(url, {
headers: {
Authorization: `Bearer ${env.users.user.token}`,
},
});
},
],
];
it.each(routes)('%s stays within the budget', async (_name, path, call) => {
const tracker = trackResponseCloseListeners();
try {
const response = await call();
expect(response.status).toBe(200);
await response.arrayBuffer();
} finally {
tracker.restore();
}
const peak = tracker.peak((req) => req.url?.split('?')[0] === path);
expect(peak).toBeGreaterThan(0);
expect(peak).toBeLessThanOrEqual(RESPONSE_CLOSE_LISTENER_BUDGET);
});
});
+2 -1
View File
@@ -21,6 +21,7 @@ import type { Request, RequestHandler } from 'express';
import { actorUid } from '../actor';
import type { EventClient } from '../../clients/event/EventClient';
import type { LifecycleRejectStatus } from '../../clients/event/types';
import { onResponseClose } from './onResponseClose';
import type { RouteMethod } from './types';
type RoutePathValue = string | RegExp | Array<string | RegExp> | undefined;
@@ -179,7 +180,7 @@ export const createRouteLifecycleMiddleware = (
);
};
res.once('finish', () => settle(false));
res.once('close', () => settle(!res.writableFinished));
onResponseClose(res, () => settle(!res.writableFinished));
next();
};
};
@@ -2128,6 +2128,29 @@ describe('KVStoreDriver', () => {
await expect(inCtx(() => target.flush({}))).resolves.toBe(true);
});
it('removes paths without handing the value back', async () => {
await inCtx(() =>
target.set({ key: 'obj', value: { a: 1, b: 2 } }),
);
expect(
await inCtx(() => target.remove({ key: 'obj', paths: ['a'] })),
).toEqual({ b: 2 });
await exhaust(actor);
// A path the value doesn't have would otherwise read it all back.
expect(
await inCtx(() => target.remove({ key: 'obj', paths: ['x'] })),
).toBeNull();
expect(
await inCtx(() => target.remove({ key: 'obj', paths: ['b'] })),
).toBeNull();
const stored = await server.stores.kv.get(
{ key: 'obj' },
{ actor },
);
expect(stored.res).toEqual({});
});
it('exempts a worker session', async () => {
const worker = makeActor({
session: { uid: 'worker-session', kind: 'worker' },
+15 -2
View File
@@ -42,7 +42,10 @@ import {
type KVUsage,
} from '../../stores/systemKv/SystemKVStore.js';
import { KV_SHARE_HANDLE_WIDTHS } from '../../stores/events/columnWidths.js';
import { assertActorHasCredits } from '../../services/metering/enforcement.js';
import {
actorHasCredits,
assertActorHasCredits,
} from '../../services/metering/enforcement.js';
import { isKvHandleId } from '../../services/events/subjects.js';
import {
KV_HANDLE_KEY_MAX_BYTES,
@@ -263,7 +266,7 @@ const relativizeError = (
* `list` is here for the step before that: deleting a key means knowing it
* exists, and `flush` — the only alternative — takes everything. It is gated
* again inside the method for the forms that return values, which are a read
* like any other.
* like any other. `remove` withholds the value it would return instead.
*/
const CREDIT_UNGATED_KV_METHODS = new Set(['del', 'remove', 'flush', 'list']);
@@ -1231,6 +1234,16 @@ export class KVStoreDriver extends PuterDriver {
) {
return null;
}
// Nor does an account out of credits, whose other reads are refused.
if (
!(await actorHasCredits(
this.services.metering,
opts.actor,
this.config,
))
) {
return null;
}
return res;
}
}
@@ -504,3 +504,95 @@ describe.runIf(process.env.PUTER_TEST_DB_ENGINE === 'postgres')(
});
},
);
describe('an immediate send answers with the row it wrote', () => {
/** What the driver returns for a row. */
const clientShape = (row: Record<string, unknown> | null) => ({
uid: row?.uid,
value: row?.value,
app_uid: row?.app_uid ?? null,
shown: row?.shown ?? null,
acknowledged: row?.acknowledged ?? null,
created_at: row?.created_at ?? null,
});
/** `fn`'s result, with the notification writes and uid lookups it made. */
const traced = async <T>(fn: () => Promise<T>) => {
const reads = [vi.spyOn(db(), 'read'), vi.spyOn(db(), 'pread')];
const write = vi.spyOn(db(), 'write');
try {
const result = await fn();
const sql = (calls: unknown[][]) =>
calls
.map(([q]) => String(q))
.filter((q) => /`notification`/u.test(q));
return {
result: result as Record<string, unknown>,
writes: sql(write.mock.calls),
uidReads: reads
.flatMap((spy) => sql(spy.mock.calls))
.filter((q) =>
/FROM `notification` WHERE `uid` = \?/u.test(q),
),
};
} finally {
for (const spy of [...reads, write]) spy.mockRestore();
}
};
const stored = (uid: unknown) =>
server.stores.notification.getByUid(String(uid), {
userId: holder.user!.id,
primary: true,
});
it('a new row: one insert and no read of it', async () => {
setSchedules(undefined);
const { result, writes, uidReads } = await traced(() =>
notify({ text: 'new' }),
);
expect(writes).toHaveLength(1);
expect(writes[0]).toMatch(/^INSERT INTO `notification`/u);
expect(uidReads).toEqual([]);
expect(result).toEqual(clientShape(await stored(result.uid)));
});
it('a group replacement: one update and no read of the row after it', async () => {
setSchedules(undefined);
const first = (await notify({ group: 'g', text: 'one' })) as {
uid: string;
};
const { result, writes, uidReads } = await traced(() =>
notify({ group: 'g', text: 'two' }),
);
expect(result.uid).toBe(first.uid);
expect(writes).toHaveLength(1);
expect(writes[0]).toMatch(/^UPDATE `notification`/u);
expect(uidReads).toEqual([]);
expect(result).toEqual(clientShape(await stored(first.uid)));
expect(result.value).toMatchObject({ text: 'two' });
});
it('a replacement of the row a delayed send wrote', async () => {
setSchedules(undefined);
const token = uuidv4();
const key = uuidv4();
const id = await claimed(token);
await write(key, txFor(id, token), {
payload: JSON.stringify({
message: { title: 'Later', group: 'g' },
}),
});
const { result, uidReads } = await traced(() =>
notify({ group: 'g', text: 'now' }),
);
expect(result.uid).toBe(key);
expect(uidReads).toEqual([]);
const row = await stored(key);
expect(row?.send_key).toBeNull();
expect(result).toEqual(clientShape(row));
});
});
@@ -161,6 +161,44 @@ describe('NotificationDriver.create', () => {
expect(result?.value).toEqual({ type: NOTIF_TYPE, title: 'lagged' });
});
it('writes once and returns the row without reading it back', async () => {
const { actor, userId } = await makeUser();
const db = server.clients.db;
const read = vi.spyOn(db, 'read');
const pread = vi.spyOn(db, 'pread');
const write = vi.spyOn(db, 'write');
let result: Record<string, unknown> | null;
let writes: unknown[][];
let reads: unknown[][];
try {
result = (await withActor(actor, () =>
driver.create({
object: { value: { type: NOTIF_TYPE, title: 'once' } },
}),
)) as Record<string, unknown> | null;
} finally {
writes = write.mock.calls;
reads = [...read.mock.calls, ...pread.mock.calls];
for (const spy of [read, pread, write]) spy.mockRestore();
}
const touches = (q: unknown) => /`notification`/u.test(String(q));
expect(writes.filter(([q]) => touches(q))).toHaveLength(1);
expect(reads.filter(([q]) => touches(q))).toEqual([]);
const row = await server.stores.notification.getByUid(
result!.uid as string,
{ userId, primary: true },
);
expect(result).toEqual({
uid: row?.uid,
value: row?.value,
app_uid: row?.app_uid ?? null,
shown: row?.shown ?? null,
acknowledged: row?.acknowledged ?? null,
created_at: row?.created_at ?? null,
});
});
it('rejects a `value` with no `type` with 400', async () => {
const { actor } = await makeUser();
await expect(
@@ -64,6 +64,7 @@ import { PuterDriver } from '../types.js';
import type { Actor } from '../../core/actor.js';
import type { DriverRateLimitConfig } from '../meta.js';
import { CRUD_CONCURRENT } from '../util/crudLimits.js';
import type { NotificationRow } from '../../services/notification/NotificationService.js';
const MAX_SELECT_LIMIT = 200;
const MINUTE_MS = 60_000;
@@ -289,12 +290,12 @@ export class NotificationDriver extends PuterDriver {
// Silent: a driver `create` has never pushed to the live socket, only
// ever written the row — moving through the registry does not change
// that.
const uid = await this.#write(actor.user.id, value, {
const row = await this.#write(actor.user.id, value, {
type: registeredType,
appUid,
silent: true,
});
return this.#readBack(uid, actor.user.id);
return this.#toClient(row);
}
async read(args: Record<string, unknown>): Promise<unknown> {
@@ -447,12 +448,9 @@ export class NotificationDriver extends PuterDriver {
return this.#scheduleAppMessage(actor, message, handle, timing);
if (handle === undefined) {
const uid = await this.#sendAppMessage(
actor.user.id,
appUid,
message,
return this.#toClient(
await this.#sendAppMessage(actor.user.id, appUid, message),
);
return this.#readBack(uid, actor.user.id);
}
try {
const recipient =
@@ -484,7 +482,7 @@ export class NotificationDriver extends PuterDriver {
recipientId: number,
appUid: string,
message: AppMessage,
): Promise<string> {
): Promise<NotificationRow> {
return this.#oneSendPerGroup(recipientId, appUid, message, () =>
this.#replaceOrWriteAppMessage(recipientId, appUid, message),
);
@@ -675,7 +673,7 @@ export class NotificationDriver extends PuterDriver {
recipientId: number,
appUid: string,
message: AppMessage,
): Promise<string> {
): Promise<NotificationRow> {
// Primary: a lagging replica would turn a replacement into a new
// notification, which spends a day slot.
const open = message.group
@@ -698,17 +696,13 @@ export class NotificationDriver extends PuterDriver {
);
if (open) {
const uid = String(open.uid);
if (
await this.services.notification.notifyUpdate(
uid,
recipientId,
message,
{ type: APP_MESSAGE_TYPE, replace: true },
)
) {
return uid;
}
const replaced = await this.services.notification.replaceUnseen(
open,
recipientId,
message,
{ type: APP_MESSAGE_TYPE },
);
if (replaced) return replaced;
// Shown or dismissed since the lookup, so this one is new after all.
await this.#spendAppMessageLimits(recipientId, appUid, ['day']);
}
@@ -760,7 +754,7 @@ export class NotificationDriver extends PuterDriver {
}
}
/** Writes for `userId`, who need not be the actor, and names the row. */
/** Writes for `userId`, who need not be the actor, and returns the row. */
async #write(
userId: number,
value: Record<string, unknown>,
@@ -770,31 +764,18 @@ export class NotificationDriver extends PuterDriver {
silent: boolean;
group?: string | null;
},
): Promise<string> {
const uid = await this.services.notification.notify(
): Promise<NotificationRow> {
const [row] = await this.services.notification.notifyRows(
[userId],
value,
opts,
);
if (!uid) {
if (!row) {
throw new HttpError(500, 'Notification could not be created', {
legacyCode: 'internal_error',
});
}
return uid;
}
/** The service hands back only the uid; the primary has the rest. */
async #readBack(
uid: string,
userId: number,
): Promise<Record<string, unknown> | null> {
return this.#toClient(
await this.stores.notification.getByUid(uid, {
userId,
primary: true,
}),
);
return row;
}
// -- Permissions -------------------------------------------------
@@ -129,9 +129,9 @@ describe('pipeStreamResult', () => {
expect(warn).not.toHaveBeenCalled();
});
it('adds at most two close listeners to the response', async () => {
// The response already carries close listeners from metering,
// compression and tracing; Node warns past ten.
it('adds no close listener beyond the one pipe adds', async () => {
// The response already carries close listeners from middleware and
// request tracing; Node warns past ten.
let added = -1;
server = createServer((_req, res) => {
const before = res.listenerCount('close');
@@ -146,8 +146,7 @@ describe('pipeStreamResult', () => {
res.on('end', () => resolve());
}),
);
expect(added).toBeGreaterThan(0);
expect(added).toBeLessThanOrEqual(2);
expect(added).toBe(1);
});
it('drops the source, without throwing, when the client left before the result was ready', async () => {
Loaded 100 of 439 files, more files were not shown because too many files have changed in this diff. Show more