Compare commits

..
Author SHA1 Message Date
Daniel Salazar a8a78736bb feat: kv list ordering toggle + addressable path improvements (#3843)
* fix: put-1787

* feat: kv list reverse
2026-09-09 17:24:03 -07:00
Juan Fernando Castro 9b465aba9f Merge pull request #3837 from HeyPuter/juancastro/team-count-active-seats
feat: add TeamStore.countActiveSeats for the billable seat count
2026-09-09 19:33:55 -04:00
Daniel Salazar 0edee23dd9 Update CODEOWNERS (#3842) 2026-09-09 15:55:19 -07:00
Daniel Salazar 99f3037110 Update CODEOWNERS (#3841) 2026-09-09 15:53:36 -07:00
Juan Castro 7ab48c590e feat: add TeamStore.countActiveSeats for the billable seat count
Per-account billing needs "how many accounts is this team getting the
benefit of", and neither existing count answers it. `countSeats` includes
suspended seats, because it bounds the seat cap and a suspended seat still
holds its username. `countPayers` counts the owner. Billing off `countSeats`
overcharges for suspended accounts, contradicting what the admin console
tells the owner: "0 suspended accounts cost nothing per account."

So this is a third, separate count rather than a change to either — the cap
must keep counting suspended seats.

An unactivated seat is deliberately still counted: it holds a temporary
password, not a suspension, and it is paid for. That is why the filter is
narrower than `listDirectory`'s, which also excludes
`requires_password_change`.

Falsified: dropping the suspended clause fails exactly one test
("drops a suspended seat from the active count but not from the cap",
expected 3 to be 2) and leaves the other 34 green.
2026-09-09 18:13:04 -04:00
404oopsandClaude Fable 5.1 6146ab9dd3 feat(ai): refresh txt2vid catalogs, retire Sora, default to Veo 3.1 Lite (#3828)
OpenAI shuts the Sora Videos API down on 2026-09-24 and sora-2 was the
default txt2vid model, so the default moves to Veo 3.1 Lite on Gemini
and the OpenAI video provider goes. While there, the video catalogs are
brought in line with what each vendor serves today, the request options
are unified across providers, and the txt2vid docs are rewritten.

- driver: default provider gemini-video-generation with
  veo-3.1-lite-generate-preview; a request under the generic `ai-video`
  driver name lands on the default instead of the first-registered
  provider; `WIDTHxHEIGHT` sizes map onto tier catalogs by the shorter
  side and fill width/height
- openai video provider, the `openai-video-generation` alias, its
  config template and migration entries, and Together's openai/sora-2*
  rows removed
- gemini: Veo 3.1 Fast rates 10/12/30 cents per second for
  720p/1080p/4K, Veo 3.1 Lite accepts reference images, URL image
  inputs are fetched server-side through the SSRF-guarded fetch
- together: drop nine models retired upstream, add eighteen from the
  live listing; per-second models are estimated from the catalog rate,
  clamped to remaining credit and billed at the cost Together reports
  on the job; tier-sized models take resolution/ratio;
  input_reference/last_frame map onto keyframes; generate_audio is
  forwarded
- byteplus: Seedance 2.5 (dreamina-seedance-2-5-260628) with per-model
  reference-image caps
- util/imageInput: string-level image helpers shared by the image and
  video drivers; ai-image/inputImage re-exports them unchanged
- puter.js types: provider and generate_audio options; docs: txt2vid
  page rewritten with per-provider model tables, unified options and
  four new playground examples

Known follow-up: Veo returns a key-protected Google file URL, so the
default clip cannot be played directly by a browser until the provider
fetches it server-side.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-09 14:56:17 -07:00
404oopsandClaude Fable 5.1 c8f4906af4 fix(ai): keep per-provider failures when every chat route fails (#3836)
When the chat fallback chain is exhausted, the driver already records
each attempt (model, provider, status, code, message, timeout) in the
error's `fields.attempts`, but the alarm keyed on the classified message
alone and the alarm client printed the error at inspect depth 2, so the
log and Slack line read `internal_error:All providers failed` with
nothing about which providers failed or why. Deduped repeats printed
only a count.

- The HTTP alarm gate now attaches an HttpError's `fields` to the alarm
  under a single `details` key. One key can't shadow the gate's own
  request fields, and a repeat from another thrower on a shared id
  replaces it instead of merging into it.
- The chat driver logs one warn line per exhausted chain with the
  completion id, the resolved route, the classified code and the
  attempts as JSON, so every occurrence is greppable by trace even when
  the alarm dedupes it. Chains marked `noAlarm` don't log.
- Docs: `fields` reaches both the client and the alarm, so it has to be
  safe to show the caller.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-09 14:56:11 -07:00
Reynaldi Chernando 435629afc5 Add gpt 2.5 image (#3839) 2026-09-09 14:29:41 -07:00
Juan Fernando Castro e132f765d6 Merge pull request #3745 from HeyPuter/juancastro/put-1736-team-directory
🏗️ PUT-1736: a team directory apps can read, once the team opens it
2026-09-09 16:09:03 -04:00
Juan Castro 4e45fdc172 feat: a team directory apps can read, once the team opens it
Members can already enumerate each other: `/teams/:uid/members` needs a user
actor and nothing more. The only thing this adds is admitting an app actor to
the same names, so an app can offer colleagues without the member driving it.

That is the whole risk, so it is off until the team owner turns it on.
`group.directory_enabled` defaults to 0, and a team that has not opted in
answers 404 rather than 403 -- whether a team has this on is not something an
app should be able to probe for either.

Three things bound what an app sees. The membership tested is always the
person's, never the app's, so an app installed by a member of one team can
never read another's. The page carries username and uuid and nothing else --
no email, activation state, usage or role. And suspended accounts and ones
that never took up their credential are left out, since offering someone who
cannot sign in is noise and their existence is not this list's to disclose.

Activation is the forced-change flag clearing, not the password existing: a
provisioned seat holds its temporary password from birth, so testing
`password IS NOT NULL` would have leaked exactly the accounts meant to be
excluded. A test covers that distinction.

Turning the directory on or off writes an audit row, because it changes who
can read the member list and that is not something a team should be able to
alter silently. Setting it to the value it already has records nothing.

The toggle lives in TabTeams, and turning it on asks for confirmation while
turning it off does not -- one grants access, the other only takes it away.

Closes PUT-1736.
2026-09-09 14:51:05 -04:00
Juan Fernando Castro f6b519874e Merge pull request #3742 from HeyPuter/juancastro/put-1740-1762-workspace-gui
🏗️ PUT-1740 + PUT-1762: the workspace surfaces in the GUI
2026-09-09 14:50:56 -04:00
Reynaldi ChernandoandCopilot Autofix powered by AI 59be4ad237 more recipes (#3819)
* more recipes

* addition

* final

* Potential fix for pull request finding

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

* styling fix

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-10 01:07:26 +07:00
Juan Castro 9121e66c3f feat: the team surfaces in the GUI
Two places a team becomes visible to a person rather than an API: the
Dashboard tab that administers it, and the share dialog that shares with it.

Squashed because they are one change to a reviewer. Both are the first GUI
consumers of `puter.teams`, both add strings to the same `en.js`, and both had
to answer the same question -- what a team looks like to someone who has
never seen one. Splitting them would mean reviewing that answer twice and
resolving the same translation conflict twice.

TabTeams (PUT-1740)

  Registered in `builtinTabs` after `TabFiles`, as a plain object with
  `html()` and `init($el_window)` like every other tab. The member table
  carries the operations the API allows and nothing it does not: provision,
  resend an activation, disable, restore, delete.

  Deleting asks for confirmation naming the account, because the API deletes
  on a single call -- PUT-1732 places the reversible step at disable, not in
  front of the request, so the client is where a confirmation belongs.

  The wording says plainly that disabling stops the per-account charge and
  keeps the storage one, and that deleting is the only thing that ends it.
  This is the one place a person decides between the two, so leaving them to
  infer the difference costs them money.

  Rendering and the billing arithmetic are pure functions in
  `teamsConsole.js`, with tests beside them; the tab file is the DOM.

Share dialog (PUT-1762)

  `UIWindowShare` gains the teams the caller owns as recipients. A
  team is one entry in the list, not its members expanded -- sharing
  reaches every member, and the per-person wording would understate what the
  grant does.

  `shareWorkspaces.js` holds the resolution and the deduplication, so the
  dialog does not gain a second source of truth about who a recipient is.

Closes PUT-1740 and PUT-1762.
2026-09-09 11:51:44 -04:00
Juan Castro afb5b0d82a feat: add the puter.teams SDK module
Team administration reaches the backend through the `/teams` routes
rather than a driver interface, because the gates it needs are route-level:
a user actor, a verified account, and a dual-window rate limit.

The module follows `apps/` and `perms/` in layout -- one file per method,
a thin `index.js`, a JSDoc-only `types.js`, and the `METHODS` rebinding so a
destructured method keeps its `this`. It does not follow `perms/lib/req.js`:
those endpoints resolve `{ error: true }` for backward compatibility, and
nothing here has callers to keep compatible, so this throws `PuterJSError`
with the backend's own code.

Every method takes a team `uid`. A handle is a mutable label that
deleting the team releases, so a stored handle can later resolve to a
different team.

The list methods offer the three forms `puter.apps.list()` does -- an array
by default, the page envelope under `cursor`/`includeTotal`, an async
iterator under `stream`. They refuse `offset`: these routes are keyset-only
and would otherwise return page one however far you asked to skip.

The surface covers the routes that exist today. Usage totals and member-email
correction have no backend route yet and are deliberately absent rather than
shipped as methods that 404. `deleteMember()` is here because the route it
needs lands in the commit below this one.
2026-09-09 11:51:44 -04:00
Juan Castro a60a4a98b4 feat: delete a team seat for good, once it is disabled
A disabled account persists indefinitely. Removing it is an explicit request,
never a timer, and it is refused on a live account with
`account_must_be_disabled_first` — which puts a reversible step in front of the
only irreversible operation in the feature.

The audit row is written before `cascadeDelete` runs. The FKs are ON DELETE SET
NULL and the `_keep` columns carry the identifiers, so the record of what was
done survives the account it names.

No second billing emit here: `cascadeDelete` already captures the seat and
fires `team.account.deleted` through UserAccountService, and emitting again
would close the storage charge twice. Disabling closed the per-account charge;
this closes the storage one, and it is the only thing that does.

There is no restore window, and none was wanted: the reversible step already
exists earlier at disable, a disabled account costs only the bytes it holds so
nothing pressures a hasty delete, and a restore promise means retaining data
the team explicitly asked to be rid of.

Published in rate-limits-and-quotas.md alongside the team-deletion note,
since the two are easy to confuse and only one of them frees a seat.

Closes PUT-1732.
2026-09-09 11:51:44 -04:00
Juan Castro 81d15f412b feat: tell a team member what was done to their account
Two halves of the same question -- what did the team do to me, and how do
I find out. One is pull, the other push.

The activity view (PUT-1746) was already user-scoped and already 404'd a
non-member; what was missing is the load-bearing half. A member is told a reset
happened, but a reset only matters alongside who signed in afterwards, so
`SessionStore.listSignIns` merges their own sign-ins into the same stream,
newest first, with a per-stream cursor. Without that row the audit says a
credential was issued and never says whether it was used.

The notices (PUT-1733) cover the two things a member cannot discover for
themselves: their account being disabled, and their team being closed.
Deliberately one each -- closing a team disables every account in it, so
sending both would tell one person twice about one event. Both say plainly that
nothing was deleted; the accounts persist, suspended, holding their files.

Squashed because they are one change to a reviewer: same audience, same
purpose, seven files, overlapping only in TeamService. Neither touches shared
platform code.
2026-09-09 11:51:44 -04:00
Juan Castro f5b2365e83 feat: enforce the forced password change on team seats
`user.requires_password_change` shipped with the team columns but nothing
enforced it and nothing ever cleared it, so a provisioned seat kept its
administrator-issued password indefinitely and `reissueCredential`'s
"already activated" 409 was unreachable.

Adds the fourth clause to `assertVerifiedAccount`, the only place a
verification gate may live -- WebDAV builds its own actor and calls that
function directly, so a second implementation would bypass it the way the
phone and card gates once were bypassed.

A gate that refuses everything also refuses the endpoint that clears it,
so `/user-protected/change-password` opts out with `allowUnconfirmed`.
That widens the route: an account pending email, phone or card
verification can now change its password, which it could not before. The
caller is authenticated and proves the current password, so this is
benign, but it is a behaviour change to a shared route.

Also here, because the gate is worthless without them:

- change-password and the recovery-token path clear the flag, and record
  an `activate` entry when the account is a seat.
- Reset takes a live account back with a fresh credential, capped at 20
  per day and audited as `reset_member_password` with no credential in
  the row. Re-issue is audited the same way; it stays closed once a seat
  has chosen its own password.
- An issued credential expires after 24h (new `temp_password_expires_at`
  column, three dialects) and login refuses it after that, so an unused
  reset dies instead of becoming a standing credential.
- 2FA is untouched by a reset, so a reset alone is not takeover.
2026-09-09 11:51:44 -04:00
Juan Fernando Castro f3943f6a55 Merge pull request #3729 from HeyPuter/juancastro/put-1726-put-1727-put-1729-team-share-recipient
🏗️ PUT-1726 + PUT-1727 + PUT-1729: share with a workspace
2026-09-09 10:41:21 -04:00
Juan Castro 7a02fa7741 feat: tell every team member when something is shared with them
`notifyShared` keyed every map off `ResolvedShare.holderId`. A team share
has no individual holder -- the grant is one row against the group and
`holder_user_id` is NULL -- so it was skipped everywhere and the call returned
having told nobody. Nothing errored: the share was created, resolved and listed
correctly. The failure mode was silence.

`#recipientsOf` now expands a team share to its live members at
announcement time, so the grant stays one row and only the telling fans out.
`#announce` and `#emailHolder` are both inside the per-recipient loop, so
members get the in-app notification and the email digest.

Bounded by `NOTIFY_FANOUT_CAP`, which logs when it bites rather than truncating
silently -- a shortened fan-out otherwise reads as "everyone knows". The
existing per-recipient budgets absorb the volume from there.

Wording is unchanged: "shared N items with you", not the team name.

A member who joins later resolves the grant through the scan but is not
retroactively told; announcements describe a moment, not a state.
2026-09-09 10:22:56 -04:00
Juan Castro da68074401 feat: share with a team
Covers PUT-1726, PUT-1727 and PUT-1729. Together because a recipient without
the listing work produces a share that is created correctly, resolves
correctly, and never appears in "Shared with me" — a state nobody would ship.

The recipient. `ShareRecipient` gains `team` (uid) and `teamHandle`, resolved
before the email and username branches and never falling through to them.
Passing both is an error rather than a precedence rule, so a call site always
shows which was chosen: handles are released on soft delete and can be
reclaimed by an unrelated team, and a scripted share to a handle would
silently retarget. There is no bare-string spelling, since that would change
how existing strings are interpreted.

ACLService.setUserGroup mirrors setUserUser: same read-modify-write, same
one-mode-per-node rule, under a node lock keyed on the group.

One grant against the team, not one per member, so membership changes
apply without touching the grant and a share spends one unit of the daily
quota however many members there are. A member who joins afterwards gets
access, which is asserted.

The index row carries `holder_group_id` and leaves `holder_user_id` NULL, so
`0077`'s group index constrains it rather than the user-holder one.

Listing. `listByHolder` and `countByHolder` union the caller's teams into
the same keyset page — `ORDER BY id` still holds — and `#grantEvidence` gains
group grants as a third source. Without that third source the share is
filtered out of every listing as dead: nothing errors, the share simply is not
there, which is the one place a user would look for it.

Unsharing revokes the grant as well as deleting the index row. Deleting the
row alone would hide the share while leaving every member holding real access.

Closes PUT-1726, PUT-1727 and PUT-1729.
2026-09-09 10:22:56 -04:00
Juan Castro 9f615cc632 feat: add the two-team fixture and group permission grants
Two tickets, together because the fixture has no behaviour of its own — the
grant tests are its first real use, and "a grant to team A must not
resolve for B's members" is exactly a two-team assertion.

PUT-1719. Every query resolving team-scoped data has to take the
team as a parameter rather than infer it. One that forgets returns the
right rows against a database holding a single team, so a one-team
fixture does not give weaker coverage — it gives false confidence.

The fixture provides two teams, each with its own master and two
activated seats, plus a user in neither. Each team having its own master
is also what the shipped `max_teams_per_user: 1` requires, so it runs
against the real cap rather than lifting it the way the existing team suites
have to. Seats are activated before use: provisioning leaves
`requires_email_confirmation` set and `requireVerified` rejects them outright,
so an unactivated seat cannot call anything and every authorization assertion
built on one would be vacuous.

PUT-1725. The write half of group grants; the read half already worked, since
`#scanUserGroup` joins `jct_user_group` itself and resolves for whoever is a
member at scan time.

  PermissionStore   resolveGroupId, listGroupMemberUuids,
                    upsertUserGroupPerm, deleteUserGroupPerm,
                    auditUserGroupPerm
  PermissionService grantUserGroupPermission, revokeUserGroupPermission

Both rewrite the permission first, so `fs:/path:mode` collapses to
`fs:<uuid>:mode` and a revoke names the same string the grant wrote; both gate
on `canManagePermission`; both audit into the existing
`audit_user_to_group_permissions`.

The delete is scoped to the issuer, so one issuer's revoke cannot drop
another's identical grant — the PK is (user_id, group_id, permission) and
user_id is the issuer.

Cache invalidation goes through `bumpCacheGenerations` with every member uuid
at once. That already announces to peer regions in a single event, so the
fan-out costs one broadcast rather than one per member.

There is no flat-KV equivalent for group grants, so unlike the user-to-user
path there is no second write to keep consistent and no second invalidation
surface.

Closes PUT-1719 and PUT-1725.
2026-09-09 10:22:56 -04:00
Daniel Salazar 8a70fa2e5c fix: CODEOWNERS + perm dialogs outside puter dashboard/desktop (#3833) 2026-09-08 22:56:03 -07:00
Felix-Ayush 9bb5bffd01 fix(puter-js): account for base64 padding in estimateDataUrlSize (#3805)
Math.ceil(len * 3 / 4) over-counts by 1-2 bytes on padded payloads, so thumbnails at the size limit were wrongly rejected. Subtract padding like ai dataUriByteLength.
2026-09-08 22:33:33 -07:00
Felix-Ayush d7877359d4 fix(puter-js): return key_undefined for missing kv keys (#3803)
get/del/incr/decr/add/expire/expireAt only checked size, so undefined keys threw TypeError. Add assertKeyPresent first, matching set/update/remove.
2026-09-08 22:32:53 -07:00
Felix-Ayush cdc524f504 fix(puter-js): preserve zero amount in kv incr/decr (#3802)
parseCounterArgs used a falsy check, so incr(key, 0) sent amount 1. Only default when amount is undefined/null.
2026-09-08 22:30:02 -07:00
dependabot[bot] 8b872545dc chore(deps): bump docker/setup-buildx-action from 3 to 4 (#3823)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 22:27:06 -07:00
dependabot[bot] 52f9fa57a6 chore(deps): bump docker/metadata-action from 5 to 6 (#3822)
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 22:26:49 -07:00
dependabot[bot] eb28012897 chore(deps): bump peter-evans/dockerhub-description from 4 to 5 (#3826)
Bumps [peter-evans/dockerhub-description](https://github.com/peter-evans/dockerhub-description) from 4 to 5.
- [Release notes](https://github.com/peter-evans/dockerhub-description/releases)
- [Commits](https://github.com/peter-evans/dockerhub-description/compare/v4...v5)

---
updated-dependencies:
- dependency-name: peter-evans/dockerhub-description
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 22:26:27 -07:00
Daniel Salazar e5f322f68c fix: misc event hardening + perm fixes (#3831) 2026-09-08 19:16:21 -07:00
Neal Shah ab3bba5525 add file query param (#3829) 2026-09-08 21:39:19 -04:00
Juan Fernando Castro 9f3c658975 Merge pull request #3830 from HeyPuter/juancastro/phase3-land-on-main
Teams phase 3: land the remaining three PRs on main (#3733, #3724, #3725)
2026-09-08 18:27:11 -04:00
Juan Castro bd19fd18ec feat: cap teams per user and seats per team
A seat is a real Puter account: it takes a name from the global username pool
and gets a home directory. Nothing charges for one — that is prod's job — so
until it does, the only bound on creation is the request rate limit, which
bounds the rate and not the total.

  max_teams_per_user   default 1
  max_seats_per_team   default 50

Ordering is the substance of both checks. The team cap is tested before
the handle, so a capped user is told they are capped rather than that the name
they picked was unusable. The seat cap is tested before any account state
exists, so a refused provision does not burn a global username.

Soft-deleted teams do not count toward the owner's cap, so deleting frees
the slot — which does mean create, provision, delete, repeat still consumes
usernames over time, bounded by the daily rate limit. The caps raise the cost
and make the cycle audited; they do not close it.

Lowering the seat limit blocks new provisioning and disables nobody.

Both limits are published in rate-limits-and-quotas.md, and both keys are
documented in config.template.jsonc and config.default.json.

Closes PUT-1758.
2026-09-08 16:59:29 -04:00
Juan Castro 41a05b68fd feat: announce credit-state transitions so billing can act on them
A seat that exhausts its allowance is refused and cannot fix it itself — only
the team owner can add capacity. Something has to tell the owner, and that
something lives in prod, so OSS's job is to say when it happened.

`MeteringService` emits `metering.credit-state` from `rememberRemainingCredits`,
the single point where the verdict is computed, at 90% of the allowance and
again on exhaustion.

Emitted on a transition, never per request. A blocked account keeps trying and
every retry recomputes the verdict, so without that a retry loop would announce
hundreds of times. The state is tracked per uuid in process, under the same FIFO
bound as the credit cache, and dropped in `#dropCachedCredits` — so added
capacity re-arms the alert for the rest of the month.

The dedup that turns this into exactly one notice per member per month belongs
with whoever sends the mail, and needs a cross-region marker rather than a
per-cluster one.

Part of PUT-1750; the notification half is prod's.
2026-09-08 16:59:29 -04:00
Juan Castro 0c6045ed90 feat: emit team billing events for the payment integration
The charge lives outside this repo, the way the marketplace extension cancels
Stripe subscriptions off `user.delete`. This is the trigger, and it is the
whole of what OSS owes billing.

  team.account.created    a seat exists and can be used
  team.account.disabled   it stopped, and still holds its bytes
  team.account.enabled    it resumed
  team.account.deleted    it is gone
  team.deleted            the team is gone; its accounts are not

Each carries the team uid, the affected account, and the owner's
`stripe_customer_id`. That column ships in the mysql and postgres schemas but
not sqlite, so the read is guarded and degrades to null, as `cascadeDelete`
already does.

Deleting a team emits one `team.account.disabled` per seat plus the
team event, rather than one bulk event: the accounts persist, suspended,
holding their files and their usernames. Deleting a team is not a way to
stop paying for the accounts in it.

`team.account.deleted` is captured before the row is deleted.
`jct_user_group.user_id` is ON DELETE CASCADE, so by the time a listener on
`user.delete` runs, nothing can say which team paid for the account.

`getOrgSeat` deliberately admits soft-deleted teams: their accounts still
exist, so the charge is still running.

Closes PUT-1712.
2026-09-08 16:59:28 -04:00
Juan Fernando Castro 00afea1285 feat: a seat is created, never adopted (#3720)
`addMember` refuses to turn an account that already has a password into a
team seat. No service path did this — `provisionAccount` always creates —
but the store permitted it, and the design rules out existing accounts joining
a team.

Provisioning passes the guard because it admits the account before setting its
temporary password.

There is no bypass parameter. Both HTTP suites now provision a real seat and
authenticate as it, using the same token-minting the harness uses for
`POST /login`. That surfaced something worth knowing: an unactivated seat
cannot call the API at all. Provisioning leaves `requires_email_confirmation`
set and `requireVerified` rejects it, so the suites activate the seat first —
which is the state a member is actually in when making requests.

`listMembers` and `getMembership` also return `u.uuid`, which the billing
events need in order to name the account without a second lookup.
2026-09-08 16:45:16 -04:00
dependabot[bot] 84368f8d02 chore(deps): bump otpauth from 9.5.1 to 9.5.2 (#3820)
Bumps [otpauth](https://github.com/hectorm/otpauth) from 9.5.1 to 9.5.2.
- [Release notes](https://github.com/hectorm/otpauth/releases)
- [Commits](https://github.com/hectorm/otpauth/compare/v9.5.1...v9.5.2)

---
updated-dependencies:
- dependency-name: otpauth
  dependency-version: 9.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 13:26:41 -04:00
Daniel Salazar 86050f131b fix: hardening events for shared kv (#3827) 2026-09-08 09:04:50 -07:00
dependabot[bot] e50f2911b6 chore(deps): bump actions/upload-artifact from 4 to 7 (#3824)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 08:58:37 -07:00
dependabot[bot] 414c121ae4 chore(deps): bump actions/setup-node from 4 to 7 (#3825)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-08 08:58:24 -07:00
Toshit ChawdaandClaude Opus 5 1d6d0da950 feat(puter.js): transferables in AppConnection.postMessage (#3776)
`postMessage(message, transfer)` (or `{ transfer }`) moves ArrayBuffers,
MessagePorts, streams and the rest to the target app instead of copying
them. The second argument is optional, so existing callers are unchanged.

The transfer list rides in the message body as well as the real transfer
list: structured clone's memory map keeps those objects identical to the
ones inside `contents`, which is how the desktop picks them out mid-relay
and keeps transferring them onward rather than leaving copies behind. Both
`messageToApp` paths carry it — the direct iframe relay and the connection
path that `launchApp()` between apps actually uses.

The desktop forwards the list as-is and lets postMessage judge it. A list
that arrived through the SDK was already validated by the browser on the
first hop, and validating again here would mean an allowlist of
transferable types that silently downgrades an unrecognised one to a copy.
A bad list only reaches us from an app that hand-wrote the envelope; that
throws without detaching anything, and is caught so it cannot escape
`ipc_listener` as an unhandled rejection.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-09-08 08:57:52 -07:00
dependabot[bot] 72889d72c8 chore(deps): bump undici from 7.29.0 to 7.29.1 (#3821)
Bumps [undici](https://github.com/nodejs/undici) from 7.29.0 to 7.29.1.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.29.0...v7.29.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.29.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-09-08 08:52:50 -07:00
Nariman Jelveh 8a6daee9ab feat: let extensions customize recommended apps 2026-09-07 16:44:41 -07:00
404oops e980b131a0 Merge pull request #3815 from HeyPuter/filipkujundzic/ai-vision-canonical-media-parts 2026-09-08 00:54:41 +02:00
404oopsandClaude Fable 5.1 1cb5472c4c fix(ai): canonicalize image parts once and translate them per provider
The SDK shorthand sends `{ image_url: { url } }` with no type. Claude
forwarded it untouched (Anthropic: "content.1.type: Field required"),
the OpenAI/Azure Responses providers sent a Chat Completions part the
Responses API rejects, and Mistral emitted snake_case `image_url` where
its SDK validates `imageUrl`. All three only appeared to work because
the fallback loop re-served them through OpenRouter.

- utils/mediaParts: canonicalise every inbound media part (untyped
  shorthand, bare string URLs, Responses input_image, Anthropic image
  blocks, Gemini inline_data) to the Chat Completions form in the
  driver, so providers translate from one shape
- Claude → Anthropic image blocks (url / base64 sources), copy-on-write
- Responses processor → input_image with a string URL and detail, and
  copies messages instead of mutating the caller's objects
- Mistral → camelCase imageUrl; the test that asserted the old shape
  is corrected
- driver: 400 up front when the catalog says the model has no image or
  video input; resolve puter_path parts per attempt for providers
  without their own upload path (Claude keeps the Files API)
- move the Moonshot http→data-URL inliner to utils/inlineImages and
  apply it to Grok-via-Azure and Gemini, whose upstreams cannot fetch
  http image URLs

Fixes #3409

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-07 15:23:40 +02:00
Daniel Salazar 927317bc4e fix: events hardening (#3814) 2026-09-06 22:44:07 -07:00
Daniel Salazar a441e7f748 fix: harden events (#3813)
* fix(events): rename carries from, one retry on deploy-timeout, forward-path counters

- FSService.rename passes the pre-rename path so a folder subscription
  sees op move with from, like a real move does
- deploy-timeout from the dispatcher gets one retry with the deployed
  header and no second upload (ALREADY_DEPLOYED_MISS_REASONS)
- OTel counters events.forward.sent/received and events.single.attempt
- cross-app KV subscribe error hints at the three-segment parse
- docs: lease is 60 s, kv prefix example is fully qualified, move covers
  rename

* feat(events): forward session subscriptions across regions, fast bumps, worker session cleanup

- session (onLocal) subscriptions now receive writes committed in other
  regions: a transition-maintained remote-watch index (ev:sc / ev:rw),
  watch/event forward items, replay through dispatchForwarded against
  session rows only; events.forwardSession=false is the kill switch
- subscription and presence generation bumps also ride the addressed
  forward channel (kind bump) so a peer sees a new durable row within
  a queue window; the webhook fan stays as backstop
- workers.destroy revokes every holder's events:handlers session; app
  deletion reaps the app's rows, backlog and handlers and revokes the
  sessions; an hourly sweep revokes sessions whose app is gone
- docs: cross-region latency, per-region caps footnote, session
  lifecycle
2026-09-06 16:44:07 -07:00
dependabot[bot] 2784feb47d chore(deps): bump fast-uri from 3.1.5 to 3.1.7 (#3810)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.5 to 3.1.7.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-06 16:43:02 -07:00
Daniel Salazar 2f70cd1a14 feat(email): sendTransactional alias and streaming file inputs (#3812)
- puter.email.sendTransactional is the new name; send stays as a
  deprecated alias with the same arguments and result
- fileInput: openFileInputStream / resolveFileInputEntry expose the
  ACL-checked FS read as a stream; loadFileInput wraps them
- EmailAttachment accepts a `path` the transport streams on its own
2026-09-06 16:41:02 -07:00
Felix-Ayush b1d3a7c191 fix(puter-js): require prompt in ai.txt2img (#3804)
txt2vid rejects missing prompts client-side with prompt_required, but txt2img sent the request to the backend. Add the same guard before the driver call.
2026-09-06 16:39:35 -07:00
Nariman Jelveh 092a9245ff Fix PDF thumbnail worker loading from CDN bundles 2026-09-06 14:31:42 -07:00
Nariman Jelveh e6cedde732 Add resilient PDF thumbnails to GUI uploads (#3809)
* Add resilient PDF thumbnails to GUI uploads

* Keep GUI image thumbnails working with SDKs lacking the callback context

The desktop loads puter.js from js.puter.com by default, and the SDK
there predates the thumbnail callback context, so passing the PDF
generator made every image upload lose its thumbnail until the SDK
deploys. Fall back to the SDK's bundled image generator whenever the
running SDK passes no usable context, and cover both paths in the unit
and browser tests.

* Ignore preparation failures that land after an upload is cancelled

Cancelling during preparation already rejects the upload and fires the
abort callback. If the step that was in flight then fails, such as a
dropped directory that cannot be read, the error callback also fired and
the GUI showed an upload error for an upload the user had just
cancelled. Skip error reporting once preparation has been aborted.

* Give each PDF thumbnail worker four seconds

The per-PDF budget covers downloading PDF.js as well as rendering, and
the first PDF of a session on a slower connection ran out of time
before its assets had even loaded. Four seconds fits that first load on
ordinary connections while staying under the five-second batch cap, so
one stuck PDF still leaves the rest of the batch a chance.
2026-09-06 13:51:02 -07:00
404oops 9b4d8f11d4 Merge pull request #3801 from HeyPuter/filipkujundzic/remove-retired-imagen-moonshot-models
fix(ai): remove retired Imagen 4 and legacy Moonshot models
2026-09-06 13:26:14 +02:00
dependabot[bot] bf4e810f9c chore(deps-dev): bump browserstack-local (#3808)
Bumps [browserstack-local](https://github.com/browserstack/browserstack-local-nodejs) from 1.5.5 to 1.5.9.
- [Release notes](https://github.com/browserstack/browserstack-local-nodejs/releases)
- [Commits](https://github.com/browserstack/browserstack-local-nodejs/compare/v1.5.5...v1.5.9)

---
updated-dependencies:
- dependency-name: browserstack-local
  dependency-version: 1.5.9
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 15:43:42 -07:00
dependabot[bot] 89a4947f68 chore(deps): bump webpack from 5.101.3 to 5.110.3 in /src/puter-js (#3800)
Bumps [webpack](https://github.com/webpack/webpack) from 5.101.3 to 5.110.3.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Changelog](https://github.com/webpack/webpack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack/compare/v5.101.3...v5.110.3)

---
updated-dependencies:
- dependency-name: webpack
  dependency-version: 5.110.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 15:40:47 -07:00
dependabot[bot] 9ebe1092f1 chore(deps): bump dependabot/fetch-metadata from 2 to 3 (#3763)
Bumps [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) from 2 to 3.
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/v2...v3)

---
updated-dependencies:
- dependency-name: dependabot/fetch-metadata
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 15:40:22 -07:00
dependabot[bot] 1caf3e121f chore(deps): bump actions/checkout from 3 to 7 (#3764)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 15:40:13 -07:00
dependabot[bot] 3620f7bd85 chore(deps): bump docker/build-push-action from 5 to 7 (#3765)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 15:39:57 -07:00
dependabot[bot] e48802252e chore(deps): bump actions/download-artifact from 4 to 8 (#3767)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 15:39:21 -07:00
Daniel Salazar da65b7f569 feat: the invoking backend deploys an events worker the dispatcher cannot find (#3807)
The dispatcher's rehydrate callback reaches whichever backend answers the
API's public hostname. A backend with the runtime flag on that is not behind
that hostname, or the only one in the fleet with it on, could never get its
scripts deployed that way — the callback answered "disabled". The invoking
backend already knows the app and script, so on a dispatcher miss it deploys
the set itself and retries once, telling the dispatcher to skip its callback
and negative cache. The callback stays the path for evicted scripts.
2026-09-05 14:40:44 -07:00
404oopsandClaude Fable 5.1 ded3d3695b fix(ai): remove retired Imagen 4 and legacy Moonshot models
Google shut down imagen-4.0-fast/standard/ultra in the Gemini API on
2026-08-17; live calls now return 404 "not found ... or is not supported
for predict". Drop the three entries, the generateImages code path that
only they used, and their tests; the integration test moves to
gemini-2.5-flash-image.

Moonshot's live /models listing and the Kimi pricing docs now carry only
kimi-k3, kimi-k2.7-code(-highspeed) and kimi-k2.6. Drop kimi-k2.5 and the
whole moonshot-v1-* family and repoint the unit and integration tests at
current models. kimi-k2.5 still resolves through OpenRouter.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-05 22:22:16 +02:00
dependabot[bot] e451d507ad chore(deps): bump lodash from 4.17.21 to 4.18.1 in /src/puter-js (#3797)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 12:21:36 -07:00
dependabot[bot] 7099cadc6f chore(deps): bump shell-quote from 1.8.1 to 1.10.0 in /src/puter-js (#3774)
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.1 to 1.10.0.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.1...v1.10.0)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.10.0
  dependency-type: indirect
...

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

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 23:58:57 -07:00
Daniel Salazar e478087260 fix: keep matrix legs of test/coverage jobs always instantiated (#3799)
Skipping the whole matrix job via a job-level `if` collapses it to a
single check with the unevaluated name template ("test (\${{
matrix.artifact }})") instead of expanding to "test (base)"/"test (pr)",
since there's no matrix context to fill in when the job never runs. That
check never matches a required-status-check pinned to "test (pr)", so
PRs that skip backend/puterjs testing block forever waiting on a check
that will never be reported. Gate the steps instead so the matrix always
expands with stable names, doing no real work when irrelevant.
2026-09-04 23:58:12 -07:00
dependabot[bot] b6fb4f3cae chore(deps-dev): bump @eslint/js from 9.39.4 to 9.39.5 (#3791)
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.39.4 to 9.39.5.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v9.39.5/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.39.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 23:56:03 -07:00
dependabot[bot] 61566431cc chore(deps): bump fs-extra from 11.3.5 to 11.3.6 (#3785)
Bumps [fs-extra](https://github.com/jprichardson/node-fs-extra) from 11.3.5 to 11.3.6.
- [Changelog](https://github.com/jprichardson/node-fs-extra/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jprichardson/node-fs-extra/compare/11.3.5...11.3.6)

---
updated-dependencies:
- dependency-name: fs-extra
  dependency-version: 11.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 23:55:31 -07:00
dependabot[bot] d3baf0ef7e chore(deps): bump js-yaml from 4.3.1 to 4.3.2 (#3792)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.1 to 4.3.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.1...4.3.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 23:54:54 -07:00
dependabot[bot] 4c3fe8ef7b chore(deps-dev): bump @vitest/coverage-v8 from 4.1.10 to 4.1.11 (#3793)
Bumps [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) from 4.1.10 to 4.1.11.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/coverage-v8)

---
updated-dependencies:
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 23:53:49 -07:00
dependabot[bot] c164b5724f chore(deps): bump nodemailer from 9.0.3 to 9.0.6 (#3795)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 9.0.3 to 9.0.6.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.0.3...v9.0.6)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 23:52:48 -07:00
Daniel Salazar bc9cb2d7e7 fix: withdrawing background consent revokes the app's events session (#3777)
* fix: withdrawing background consent revokes the app's events session

A background handler runs as a worker session for the subscriber and app.
Revoking `events:background` or uninstalling the app suspended the
subscriptions but left that session valid, so a token a handler had copied
out kept working until the user found the row in the sessions list. The
revocation settle now revokes the session too; the next consented delivery
mints a fresh one.

* fix: cleanup docs
2026-09-04 21:10:27 -07:00
dependabot[bot] 5669a2d960 chore(deps): bump fastify from 5.10.0 to 5.12.3 (#3749)
Bumps [fastify](https://github.com/fastify/fastify) from 5.10.0 to 5.12.3.
- [Release notes](https://github.com/fastify/fastify/releases)
- [Commits](https://github.com/fastify/fastify/commits)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 21:01:11 -07:00
dependabot[bot] 50aea0cc64 chore(deps-dev): bump vite from 8.1.3 to 8.1.5 (#3790)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.1.3 to 8.1.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 03:53:36 +00:00
dependabot[bot] c67f1c3653 chore(deps): bump uuid from 14.0.1 to 14.0.2 (#3789)
Bumps [uuid](https://github.com/uuidjs/uuid) from 14.0.1 to 14.0.2.
- [Release notes](https://github.com/uuidjs/uuid/releases)
- [Changelog](https://github.com/uuidjs/uuid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/uuidjs/uuid/compare/v14.0.1...v14.0.2)

---
updated-dependencies:
- dependency-name: uuid
  dependency-version: 14.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 03:53:13 +00:00
dependabot[bot] 78f44c8f8c chore(deps-dev): bump vitest from 4.1.10 to 4.1.11 (#3788)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.10 to 4.1.11.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 03:53:03 +00:00
dependabot[bot] 45ee0ef102 chore(deps-dev): bump @types/node from 24.13.2 to 24.13.3 (#3787)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.13.2 to 24.13.3.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 03:52:45 +00:00
dependabot[bot] c4da663533 chore(deps): bump sharp from 0.35.3 to 0.35.4 (#3786)
Bumps [sharp](https://github.com/lovell/sharp) from 0.35.3 to 0.35.4.
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.3...v0.35.4)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 03:52:31 +00:00
dependabot[bot] 69b5901278 chore(deps): bump mysql2 from 3.22.5 to 3.22.6 (#3784)
Bumps [mysql2](https://github.com/sidorares/node-mysql2) from 3.22.5 to 3.22.6.
- [Release notes](https://github.com/sidorares/node-mysql2/releases)
- [Changelog](https://github.com/sidorares/node-mysql2/blob/master/Changelog.md)
- [Commits](https://github.com/sidorares/node-mysql2/compare/v3.22.5...v3.22.6)

---
updated-dependencies:
- dependency-name: mysql2
  dependency-version: 3.22.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 03:51:54 +00:00
dependabot[bot] 99fb4c2dad chore(deps-dev): bump prettier from 3.9.4 to 3.9.6 (#3783)
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.4 to 3.9.6.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.4...3.9.6)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 03:51:41 +00:00
dependabot[bot] 25b6a80cb5 chore(deps): bump libphonenumber-js from 1.13.6 to 1.13.12 (#3782)
Bumps [libphonenumber-js](https://gitlab.com/catamphetamine/libphonenumber-js) from 1.13.6 to 1.13.12.
- [Changelog](https://gitlab.com/catamphetamine/libphonenumber-js/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/catamphetamine/libphonenumber-js/commits/v1.13.12)

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

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 03:51:34 +00:00
dependabot[bot] b334961f75 chore(deps): bump @esbuild/linux-x64 from 0.25.11 to 0.25.12 (#3781)
Bumps [@esbuild/linux-x64](https://github.com/evanw/esbuild) from 0.25.11 to 0.25.12.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2025.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.25.11...v0.25.12)

---
updated-dependencies:
- dependency-name: "@esbuild/linux-x64"
  dependency-version: 0.25.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 03:51:10 +00:00
dependabot[bot] aa95a76253 chore(deps): bump @prelude.so/js-sdk from 0.12.0 to 0.12.1 (#3780)
Bumps [@prelude.so/js-sdk](https://github.com/prelude-so/web/tree/HEAD/packages/js-sdk) from 0.12.0 to 0.12.1.
- [Changelog](https://github.com/prelude-so/web/blob/main/packages/js-sdk/CHANGELOG.md)
- [Commits](https://github.com/prelude-so/web/commits/HEAD/packages/js-sdk)

---
updated-dependencies:
- dependency-name: "@prelude.so/js-sdk"
  dependency-version: 0.12.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-09-05 03:50:51 +00:00
dependabot[bot] 85bf595d2d chore(deps): bump highlight.js from 11.11.1 to 11.11.2 (#3779)
Bumps [highlight.js](https://github.com/highlightjs/highlight.js) from 11.11.1 to 11.11.2.
- [Release notes](https://github.com/highlightjs/highlight.js/releases)
- [Changelog](https://github.com/highlightjs/highlight.js/blob/main/CHANGES.md)
- [Commits](https://github.com/highlightjs/highlight.js/compare/11.11.1...11.11.2)

---
updated-dependencies:
- dependency-name: highlight.js
  dependency-version: 11.11.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-05 03:50:38 +00:00
Daniel Salazar 2f15e40a77 ci: add CODEOWNERS, restrict Dependabot to patch bumps, make test workflows always-run (#3778)
- CODEOWNERS: default owners @Salazareo @ProgrammerIn-wonderland @jelveh
  @jfcastro92, plus @reynaldichernando for src/docs.
- dependabot.yml: ignore semver-minor/major npm updates, patch only.
- backend-tests.yaml / puterjs-tests.yaml: trigger unconditionally and gate
  the real work behind a dorny/paths-filter job instead of a workflow-level
  path filter, so the checks always report (skipped when irrelevant) and
  can be marked required. Also pins matrix job names to the artifact label
  instead of the branch ref, which previously made the check name change
  per PR and unusable as a required check.
2026-09-04 20:49:14 -07:00
Daniel Salazar b784b51cf3 fix: harden the events stack for flag-on (#3752)
* fix: harden events dispatch, single delivery and KV share handles

Dispatch: a filtered subscription used the anchor path stored at subscribe
time, so renaming or moving the anchor folder silently ended its deliveries;
dispatch now resolves the anchor's live path from the event's own ancestor
chain. A move out of a watched folder now reaches that folder's subscribers,
with `from` only for rows that watched the source side. Gap markers are
authorized like deliveries and coalesced per subscription and subject instead
of fanning per lost event. Session subscriptions: the per-socket cap decides
on the write, not before it; an orphaned watched-set token heals on refresh;
durable rows keep their watch window when a session subscribe touches the
same keys. `self` is false when the acting user is unknown.

Single delivery: a subscription in backoff or suspended with a backlog pinned
the sweeper's head and starved everyone behind it — the sweep now defers it.
Only a settled handler run bills a delivery. A socket-only account row no
longer wedges after two attempts nobody received. The lease is twice the
handler timeout; remote candidates have their own attempt counter; the region
depth reconcile runs once a minute region-wide with a bounded scan.

KV share handles: a grantee no longer sees the owner's namespace and absolute
prefix on the subscribe answer or listing, nor in the delivery token; revoking
a wider handle retires the handles it covers; minting the same handle twice
returns the existing one, after the delegation check; a row whose event
cannot be re-based onto its handle is dropped rather than delivered raw.

* fix: presence survives replication, long sessions and region churn

One presence item per (user, app) with per-region map fields lost a region
whenever two regions joined inside the replication window, and nothing ever
put it back. Presence is now one item per (user, app, region): each region
writes only its own, a leave or repair retires it conditionally on its own
write stamp, and a read is a prefix query. Items carry a 48 h ttl refreshed by
a claim-gated write off the existing socket renew path, at most once per
12 h, so a tab that stays connected keeps its region in the row. A region
that answered "no socket" or completed a leave releases a shared pin, so a
reconnect on another node rejoins and a flapping client cannot force a
replicated write per cycle. Cached rows expire after a minute; unaddressable
region names are filtered and pruned; relayed acks settle under a bounded
concurrency; the forward queue is bounded in bytes as well as items.

* feat: indexes for the event_subscriptions hot queries

Handler publish, remove and listing, and the hourly expiry and suspension
sweeps, all scanned `event_subscriptions`. Adds (app_uid, handler_name),
(expires_at) and (suspended_at, id), guarded on every engine. Existing
migrations: the postgres widens are now guarded so a boot does not take an
exclusive lock for a no-op, the kv_share_handles grantee FK gets an index,
the sqlite notification rebuild is transactional and idempotent.

* fix: notification writes go through the registry

The driver's `create` bypassed the type registry, producing uncatalogued
rows with no size bound; it now requires a registered type, caps the payload,
and answers 400 rather than 500 for a bad one. `mark_acknowledged` emits the
ack other tabs listen for, and only when a row was actually changed.

* fix: the handler scanner, unsubscribe, and the in-tab handler environment

The free-variable scanner skipped arrows inside a declaration's initializer,
so `const ids = event.items.map(x => x.id)` was refused, and treated a name
after a comma in a nested initializer as bound, so a real free variable slipped
through to fail on first delivery. `unsubscribe()` now drops the durable
routing entry so the events socket can close. A broadcast handler running in
the tab gets `user` and `fetch` like the worker gives it. `single` without
a handler name is refused before the round trip.

* docs: events limits, error codes and the background-workers section

Retention is deployment-configured rather than a fixed 14 days, and the
template no longer ships it armed. Documents `events_terminal`, the two
per-event gap reasons, the subject length and listing caps, the `from` field
on moves, and the handle-relative anchor. The sessions manager hides the
background-workers section when the server has none to show.

* feat: a background handler acts as the app does for its user

A handler's `user` was a five-minute access token scoped to the subscription's
`list` grant, which could stat the changed file but not read it, and could
not reach the app's KV or AppData — so an app told that a file was written
could do nothing with it. It now runs with the same authority the app has for
that user in a tab: an app-under-user worker session, one row per (user, app)
named `events:handlers`, visible and revocable in the sessions list. The
`events:background` consent is what authorizes running it unattended, and is
re-checked before every mint.

The wider token exposed two things: puter.js opens a filesystem socket the
moment it has a token, which would have parked the isolate in the app's own
delivery room and steered deliveries at it; the events client now opts out of
sockets (and the per-open bookkeeping) before construction, and is memoized
per token in the isolate. And four filesystem operations assumed a socket
exists; they no longer do.
2026-09-04 17:32:57 -07:00
Daniel Salazar f30baa2a1c feat: the per-app events worker runtime (#3697)
* feat: bake published handlers into a generated events worker

* feat: deploy and address the per-app events worker behind a flag

* test: single delivery end to end through a real local worker

* feat: events workers run their own runtime, in their own namespace

An events worker was being deployed as an ordinary worker: default dispatch
namespace, a `subdomains` row, the router preamble, and an app-scoped worker
token baked in. The public dispatcher resolves any script in that namespace
straight off the hostname, so the worker answered at `<name>.puter.work`, and
the only thing in front of it was an unguessable name plus a check that a
`puter-auth` header was present — which the router never validates. Anyone who
learned the hostname could run an app's handlers with a body of their choosing,
in an isolate holding the owner's token as `me`.

Instead:

- Handlers run on their own runtime (`src/worker/src/events-runtime.js`), which
  provides no `router` and no `me`, owns the single invoke route, and hands a
  handler only `{ event, ctx, user, fetch, ack }`. `user` is built from the
  invocation's delivery token, so a handler acts as the subscriber whose
  delivery it is and nothing wider. The preamble build emits one bundle per
  runtime; the shared half of the template is now included by both.
- The deploy target carries the runtime to prepend, the source to deploy, and
  whether to mint a worker token at all, so an events worker deploys into the
  `events` dispatch namespace from generated source with no token binding, no
  `subdomains` row, and no claim on the owner's worker quota or worker list.
- An invocation carries a key derived from the deployment secret and the script
  name, bound as a secret and checked in constant time inside the isolate,
  which reads it once and drops it before handler code runs.
- Scripts are named after the handler set they contain, so publishing writes
  rows and deploys nothing: a set is deployed the first time a delivery needs
  it, and a changed set is a new script rather than an overwrite of a running
  one. Publish responses keep the shape they had before the runtime existed.
- Invocations reach a worker only through the events dispatcher, which has no
  zone route and requires the internal secret; the backend's own deploy path is
  the rehydrate route the dispatcher calls on a namespace miss. Locally there is
  no dispatcher, so the controller hands the service an in-process transport
  that deploys on miss itself.

The SDK stops allowlisting `puter` as a handler global — a handler that reaches
for an ambient SDK is now refused at publish time, naming `user` instead, rather
than passing the scan and failing on its first delivery.

Requires `events.workerNamespace`, `events.dispatcherUrl` and
`events.internalSecret`; without them nothing is addressable and background
deliveries stay retriable, as they did with the runtime off.

* fix: a handler's delivery token gets through the read routes

An events handler acts as the subscriber through the access token its
invocation carried, but every FS read route refused scoped access tokens
outright, so `user.fs.stat(event.path)` — the design's own example — answered
403 inside the worker. The read-side routes now admit them; the ACL each
handler already runs intersects the token's grant with its issuer's, which is
the check that keeps a token to what it was minted for. The end-to-end suite
asserts the stat from inside the isolate.

* fix: shorthand-method handlers publish as functions

`{ ingest({ event }) { … } }` stringifies without the `function` keyword, so
its source is not an expression and the events worker baked it as a broken
stub — every delivery a retriable 500 until the subscription suspended, with
nothing at publish time to say why. The SDK now gives a shorthand method the
keyword before hashing and sending; getters, setters and computed names are
left for the server-side check to refuse.

* feat: an app's events worker is listable and destroyable

An app with published handlers has an events worker, and hosted deployments
bill it monthly per app, so its owner needs to see it and be able to take it
down. The core announces the lifecycle on the bus — `events.worker.create`
when an app's first handler is published, `events.worker.destroy` when its last
one goes — with the owner as the actor, so pricing can plug in from outside.
`GET /events/workers` lists the caller's workers (paginated, with the script
each set deploys as) and `POST /events/workers/destroy` removes every handler
of an app under the same owner scoping as the handler routes, suspending the
subscriptions bound to them. `puter.events.workers.list/destroy` in the SDK,
a docs page, and a 5 MB cap on an app's combined handler source
(`events_worker_too_large`) so a set that publishes can always deploy.

* fix: harden the events worker runtime for production

- A 4xx is terminal only when it carries the handled marker the runtime (and
  the dispatcher) stamp on every answer that came from a script; an unmarked
  4xx — an edge 404 for a wrong dispatcher hostname, a WAF page — stays
  retriable and is logged, once per script per minute, with the runtime's
  reason header.
- Script names are scoped to this backend's exposed API origin, so two
  backends sharing a namespace never resolve one script with the wrong
  endpoint binding or key. Shape unchanged.
- Each handler is validated in the exact context it is emitted into and the
  whole generated file is compiled once; a source that would break the script
  marks every handler broken instead of deploying a SyntaxError.
- Locally, events scripts live under their own registry key: the public local
  worker host cannot reach them and an ordinary worker cannot take their name.
- A suspended or deleted app owner stops invocations; deploys are throttled
  per app per hour; in-flight deploys are keyed by app and script; the
  upstream deploy call times out; the generated source is size-capped with a
  margin over the publish cap; boot fails when the runtime is on but its
  preamble is not built. Byte-length secret compare, appUid shape check,
  dispatcher URL prefix preserved, wider connection pool.

* feat: background workers are listed in the sessions manager

A user paying for an app's events worker needs somewhere to see it and take it
down. The sessions manager gets a section listing the apps that run event
handlers in the background, with a Destroy action that removes their published
handlers.
2026-09-04 17:32:57 -07:00
dependabot[bot] dfab3d50cd chore(deps-dev): bump browserslist from 4.28.5 to 4.28.9 (#3759)
Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.5 to 4.28.9.
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.5...4.28.9)

---
updated-dependencies:
- dependency-name: browserslist
  dependency-version: 4.28.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 23:20:29 +00:00
dependabot[bot] 9daaec4ec4 chore(deps): bump qs from 6.15.3 to 6.16.0 (#3757)
Bumps [qs](https://github.com/ljharb/qs) from 6.15.3 to 6.16.0.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.15.3...v6.16.0)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.16.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 16:17:36 -07:00
dependabot[bot] b7effe1e01 chore(deps): bump fast-uri from 3.1.0 to 3.1.7 in /src/puter-js (#3756)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.7.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.7)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-04 16:17:08 -07:00
Daniel Salazar 2a68e7aafb ci: add Dependabot config and patch auto-merge workflow (#3760)
Weekly npm/docker/github-actions updates plus vulnerability PRs;
patch-level Dependabot PRs auto-merge, minor/major stay manual.
2026-09-04 16:16:04 -07:00
Daniel Salazar 3355190a17 feat: add astra to models (#3755) 2026-09-04 15:56:53 -07:00
Nariman Jelveh 6ebbc6ae0c feat(gui): let users reposition and zoom a new profile picture (#3754)
* feat(gui): let users reposition and zoom a new profile picture

Picking a photo in the dashboard's Account tab used to stretch the whole
image into a 150x150 square, so anything that was not already square came
out distorted and off-center. The pick now opens an adjust step: a
dashboard-style modal (centered card on desktop, bottom sheet on phones)
where the user drags to reposition and zooms with the slider, pinch, or
wheel before saving. The saved result is the same 150x150 PNG as before.

Geometry lives in profilePictureCrop.js with unit tests; the modal owns the
DOM and pointer handling.

* fix(gui): stop double-encoding the crop modal's hint and frame label

i18n() already HTML-encodes its output, so wrapping it in html_encode()
again turned any apostrophe or ampersand in a translation into a literal
"&apos;" / "&amp;" on screen. Also puts the new profile_picture_* keys
in alphabetical order.

* fix(gui): let the crop frame take focus on click so arrow keys work after a drag

pointerdown's preventDefault() also cancels the click-to-focus that a
mousedown would have done, so after dragging the photo the arrow keys and
+/- went to the dialog container and did nothing. The frame now focuses
itself on pointerdown. Focus that arrives by pointer draws no ring; the
first key press lifts that so keyboard users still see where they are.

Also ignores secondary mouse buttons and treats a lost pointer capture as
a release so a pointer can't stay stuck in the gesture map.

* fix(gui): return focus to the avatar when the crop modal closes

The modal remembered document.activeElement to restore focus later, but
at that moment focus sits inside the file picker, which closes right
after -- so on Save, Cancel or Escape focus fell to <body>. The Account
tab now names its avatar as the place focus returns to, and the avatar
becomes a real button (role, tabindex, label, Enter/Space) so it can
hold that focus and be reached from the keyboard at all.

* fix(gui): announce the crop zoom as a magnification, not a 0-100 slider value

Screen readers read the range input's raw value, which maps to nothing a
user can picture. aria-valuetext now carries the zoom factor (1.0x-4.0x)
and follows every zoom source: slider, buttons, keys, wheel, pinch.
2026-09-04 14:38:55 -07:00
404oopsandClaude Opus 5 7248951410 fix(ai): make chat fallback reach streamed Claude calls and rank Azure explicitly (#3743)
* fix(ai): make chat fallback reach streamed Claude calls and rank Azure explicitly

- ClaudeProvider opens the upstream stream and awaits its connection
  before returning the populator, so an overloaded or rate-limited route
  throws from complete() and reaches the driver's fallback loop instead
  of surfacing as an error frame on a 200
- the OpenAI-compatible chat and completions routes only pin a provider
  when the caller sent one, so they get the same preferred healthy route
  puter.js callers do and unhealthy-route skipping applies to their
  first attempt
- Azure is ranked ahead of the vendors it fronts by an explicit tier in
  modelRouting rather than a price tie plus registration order
- drop Together's synthetic always-failing model-fallback-test-1 entry
- test that a 4xx leaves a route in rotation while a 503 marks it

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

* fix(ai): surface swallowed Claude stream errors, keep compat-route defaults

Review follow-ups on the fallback work.

- The pre-created event iterator only receives an error if a reader is
  already waiting on it, so a failure landing between the connect and the
  populator's first pull ended the stream cleanly — truncated content
  billed and reported as a success. Rethrow when the stream is errored.
- A refused stream deleted its Anthropic uploads but left the caller's
  message parts pointing at those file ids, so the fallback route was
  handed handles it cannot resolve. processPuterPathUploads now returns a
  restore() that both failure paths call.
- The OpenAI-compat routes keep pinning OpenAI when the caller sends no
  model at all, so the default model stays put instead of moving to
  Azure's.
- Say why /openai/v1/responses and /anthropic/v1/messages stay pinned:
  each translates one provider's native shape by hand.
- PREFERRED_PROVIDERS is unexported and its doc now states the rank is
  unconditional; the duplicated hidden-model list is one constant.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(ai): undo the puter_path rewrite by field instead of snapshotting the part

Copying the content part kept whatever the caller sent on it — a large
inline `source` or `text` alongside `puter_path` — reachable until the
request ended, where overwriting the field used to make it garbage right
away. The only fields this function writes are `type`/`source` on success
and `type`/`text` on failure, and the fallback uploader keys off
`puter_path` alone, so restore undoes those three by name.

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-04 11:10:05 -07:00
Reynaldi Chernando 2966cee6b0 explicit mention of kv and fs being user scope and workers for backend (#3751) 2026-09-05 01:02:22 +07:00
Reynaldi Chernando d8735d950e fix kv set value type (#3750) 2026-09-05 00:30:17 +07:00
Reynaldi Chernando 48abf73d1a kv add array example (#3748) 2026-09-05 00:22:46 +07:00
Reynaldi ChernandoandCopilot Autofix powered by AI 11c5a7ce52 dynamic workers docs (#3747)
* dynamic workers docs

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-04 22:57:11 +07:00
Reynaldi ChernandoandCopilot Autofix powered by AI b6876c50bf PUT-1477 Add fs commands to cli (#3746)
* Add fs commands to cli

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-04 21:31:33 +07:00
Daniel Salazar 6d7be8bd6a docs: kv key layout is the ACL boundary (PUT-1689) (#3693) 2026-09-03 19:43:28 -07:00
Daniel Salazar b451d05d10 feat: app-minted kv share handles (PUT-1688) (#3692) 2026-09-03 19:43:27 -07:00
Daniel Salazar f139588f89 feat: revocation settles kv handles (PUT-1687) (#3691) 2026-09-03 19:43:27 -07:00
Daniel Salazar ac5446f877 feat: cross-user KV share grants and handles (PUT-1686) (#3690) 2026-09-03 19:43:27 -07:00
Daniel Salazar adde6c3a29 feat: notification GUI rides the events surface (PUT-1685) (#3689)
Widen the notif: match filter and fetch scope for a session's own
generic developer/app-user subscribe: today it pins ref to the
session's own uuid, so a row naming an app (handler-suspension
notices, app-bound worker deploys) never matches live and never
replays on reconnect, even though the audience predicate already
grants the holder every such row it owns. The predicate is the
authority and already reruns per row/page after the match, so
widening the filter to it (account is unaffected — it never names an
app) adds no exposure.
2026-09-03 15:39:22 -07:00
Daniel Salazar fe5f81e52e feat: scoped app access to notifications (PUT-1684) (#3688)
An actor holding an app reads the `app-user` rows naming that app, plus its
`developer` rows when the holder owns it. `account` rows reach no app, and a
slice an actor may not see comes back empty rather than refused. The audience
predicate becomes the enforced read path in the same change that lifts the
blanket app-actor 403, layered behind an audience/app_uid SQL scope; two-segment
`notif:` subjects expand server-side from the actor's own app, so an app can
never name another app's uid.

No feature flag: `audience` defaults to 'account', so every pre-registry row is
default-denied to app actors and the backfill can only narrow.
2026-09-03 15:39:22 -07:00
Daniel Salazar 796683a133 feat: missed-event fetch and notification fold-in (PUT-1681) (#3687) 2026-09-03 15:39:22 -07:00
Daniel Salazar eb8f497e9f feat: presence and cross-region event forwarding (PUT-1679) (#3686)
* feat: presence and cross-region event forwarding (PUT-1679)

* fix: fan cache bumps to sibling nodes and stop the forward shed cascading (PUT-1679)

`outer.events.generationBumped` and `outer.events.presenceBumped` rode
`outer.*`, which the broadcast service only webhooks to peer regions;
only `outer.pubsub.*` also fans over Redis to a region's other nodes.
Both caches are per-process maps with no expiry, so a bump landing on
one node left its siblings stale until that user's next transition.
Renamed onto `outer.pubsub.events.*`; the listeners already accept the
`from_outside` copy the Redis re-emit carries.

`PeerForwardQueue.push` called `onOverflow` synchronously and the
handler pushed markers straight back, each of which re-tripped the
bound and shed the next item: one item over a 5000 bound recursed ~2200
deep, threw a RangeError, and turned ~2200 queued deliveries into gap
markers. It also re-summed `bytes` over the whole queue per drop. The
handler now returns its markers and the queue appends them past the
bound check, sheds deliveries before markers, keeps one pending marker
per (peer, subscription), and subtracts bytes per dropped item.
2026-09-03 15:39:21 -07:00
Daniel Salazar 32a838d2e2 fix: one ** per match pattern and one * per segment (#3736)
`compileMatch` turned every `*` into an unbounded `[^/]*`, so a subject
like `fs:~/x/*a*a*a*a*a*a*a*a*a*a*z` tested against a 24-char filename
with no `z` — both named by the same subscriber — cost the regex engine
C(34,10) splits before failing: 1.0 s per event at ten stars, 3.7 s at
eleven, on the fs-write dispatch path, inside the 256-character cap.

With one `*` per segment the delimiters pin each star and a wrong split
dies in one step; the single `**` is the only choice point left, so the
worst allowed shape is O(depth × length): 0.4 ms at depth 60 of 200-char
segments. Every documented pattern (`*.png`, `**/build.log`, `**/*.png`,
`dir/**`, `report-?.csv`) stays valid; `*a*`, `a**b` and `**/x/**` are
refused with `invalid_subject_pattern`. The rule is stated where the
syntax is introduced, on the limits page, and in onLocal's error table.
2026-09-03 15:08:49 -07:00
404oops 1b777cd760 Merge pull request #3730 from HeyPuter/filipkujundzic/put-1620-video-generation-timeouts-return-500-and-page-on-call
fix: stop video generation timeouts and failures from paging (PUT-1620)
2026-09-03 22:54:58 +02:00
Daniel Salazar 799fc4ac3c feat: send app icons as a subdomain URL plus an API fallback (#3734)
App payloads carried only the /app-icon endpoint URL, which 302s to the
icons hosting subdomain. Networks that mangle that redirect render no icon
at all, and every icon load pays a round trip for the hop.

Ship the direct subdomain URL as `iconCdnUrl` alongside it (taskbar items,
installedApps, recent/recommended launch apps, suggested apps), and have the
GUI load that first with the endpoint URL as a one-shot retry - desktop
taskbar, start menu, dashboard app grid and recents. Only rows whose `icon`
column is already an http(s) URL get one: a data: column means the resize
pipeline has not written anything to the subdomain yet.

Also folds the four copies of the generated-size list into one exported
APP_ICON_SIZES.
2026-09-03 13:32:55 -07:00
Juan Fernando Castro d855315db8 Merge pull request #3714 from HeyPuter/juancastro/put-1708-26-teamcontroller-and-audit-writes
🏗️ PUT-1708 + PUT-1709 + PUT-1743: TeamController, teams_enabled, and the isolation suite
2026-09-03 16:21:02 -04:00
Juan Castro 9bc2cd741c feat: add TeamController, the teams_enabled flag, and the isolation suite
Covers PUT-1708, PUT-1709 and PUT-1743.

Twelve routes, every one setting requireUserActor -- that option is what
installs requireAuthGate, requireVerifiedAccount and requireNonAccessTokenGate,
because server.ts derives `needsAuth` from the route options. Reads need it as
much as writes: without an auth option a route gets no suspension check and
admits access tokens, so a just-disabled member could still read the roster and
a scoped third-party token could read the audit log.

Authority is checked before anything observable. Validating the body first made
POST /members answer 400 before 403, and resolving :username first turned the
member routes into a global username-existence oracle.

Provisioning applies the same username and email rules as signup rather than
its own -- USERNAME_REGEX, USERNAME_MAX_LENGTH, RESERVED_USERNAMES and
validator.isEmail, now exported from AuthController. Without them a workspace
could mint accounts signup would refuse, claim unregistered reserved names, and
mail arbitrary unvalidated addresses.

Handle problems are 400 or 409 rather than a bare Error, which the server turns
into a 500 and a deduped critical alarm -- an uppercase handle should not page
on-call.

Disable drops sessions through SessionStore.removeByUuid rather than a raw
DELETE. The store invalidates every composite cache key; without that a
disabled member kept authenticating from cache for the session TTL, which is
exactly the "takes effect on the next request, not after a cache TTL" property
disable is supposed to have. Revoking also preserves last_ip/last_user_agent,
which the member-facing audit view reads.

Audit writes live in TeamService at the point of each action rather than in the
route, so a caller reaching the service directly cannot skip them, and the SQL
lives in TeamStore. Audit reads map internal user ids to usernames, and remain
readable by the owner after the workspace is soft-deleted -- otherwise the
delete_team entry was written and immediately unreachable.

teams_enabled gates route registration through an optional isEnabled() the
server honours, so with it off the paths do not exist rather than existing and
refusing. It does not gate DDL.

TeamIsolation.http.test.ts asserts the negative the feature rests on: the
workspace manages accounts and cannot read them, including through a
full-access token and after the member is disabled. It asserts outcomes rather
than the absence of an implicator.
2026-09-03 15:57:23 -04:00
Juan Castro 673f7fe75f feat: provision org accounts from a workspace
Covers PUT-1705. The master account supplies { username, email }; the account
is created with no password, gets the default filesystem tree, joins with
org_owned = 1, and receives a one-shot activation link.

Activation reuses password recovery rather than new token machinery: the same
pass_recovery_token, the same one-hour purpose-scoped JWT, the same
/action/set-new-password link. No team_activation table, no new token type,
and no unauthenticated endpoint on the team surface. Activation state needs no
column either -- an unactivated account is one with no password.

Applies the same username and email rules as signup rather than its own:
USERNAME_REGEX, USERNAME_MAX_LENGTH, RESERVED_USERNAMES and validator.isEmail,
now exported from AuthController. Without them a workspace could mint accounts
signup would refuse -- the username becomes the /username home-directory
segment -- claim unregistered reserved names, and send activation mail to
arbitrary unvalidated addresses at the route's daily limit.

Usernames come from Puter's global pool, so a taken one is refused with free
alternatives rather than silently modified: a suffixed name would appear in
every share dialog that person ever sees, and they never agreed to it. The
check runs before any write, so a rejected provision leaves no orphaned user
row -- asserted by a test on the workspace's member count.

The new account carries requires_email_confirmation, since the address came
from the administrator rather than its holder.

Adds a team_account_activation email template stating what the workspace can
and cannot do -- including that it can reset the password, which the design
requires be said rather than only claiming files are private.

free_storage stamping and the billing event are phase 3.
2026-09-03 15:57:23 -04:00
Juan Castro da2b42d7fe feat: add TeamService workspace lifecycle and disable/re-enable
Covers PUT-1704 and PUT-1707: creating a workspace, admitting the master
account, and the whole of offboarding.

`createWorkspace` admits the creator with org_owned = 0, which is what makes
the master pay for itself and stay an invalid target of every member route.
`checkOwnerInvariant` asserts the rule no dialect can express -- the owner is
a member with org_owned = 0 and the only such member -- and a test breaks it
deliberately, since the schema cannot refuse a second one.

Three authority checks: 404 to a stranger so the endpoint is not an existence
oracle, 403 to a member who is not the master, and the master refused as a
target of member routes.

Handle problems surface as 400 (unusable) or 409 (taken), including the
unique-index race. `TeamStore` throws a bare Error, which the server would turn
into a 500 and a deduped critical alarm -- an uppercase handle should not page
on-call.

Disable writes `user.suspended` as well as suspended_at and suspended_reason.
PUT-1707 named only the latter two, but those are siblings added by 0061 and
0063 -- `userProtected` rejects on `if (user.suspended)` and reads neither.
Setting only the timestamp and reason would have recorded a disable that never
took effect, and disable is the whole of offboarding here.

Sessions are dropped through SessionStore.removeByUuid rather than a raw
DELETE. The store invalidates every composite cache key with its double-delete
pattern; without that a disabled member keeps authenticating from cache for the
session TTL, which is exactly the "next request, not after a cache TTL"
property disable is supposed to have. Revoking also preserves last_ip and
last_user_agent, which the member-facing audit view reads.

Files are untouched and re-enable restores the account.

Adds team_not_found, not_the_master_account and not_an_org_account to the
HttpError legacy codes, which the controller also needs.

Billing events, invalidateActorSubscription, audit rows and the GUI push are
deliberately not here -- they belong to phase 3 and PUT-1708.
2026-09-03 15:57:23 -04:00
Juan Castro 8b4e259421 feat: add team membership reads and writes to TeamStore
Membership management for workspaces: addMember, removeMember,
getMembership, isMember, listMembers and listTeamsForUser. The permission
scan is untouched -- readUserGroupPerms already joins jct_user_group and
resolves group grants; this is the management side.

Resolves the ticket's "do not leave two writers" by splitting domains and
enforcing the split in SQL rather than by convention. Every existing caller
of GroupStore targets a seeded system group -- ADMIN_GROUP_UID,
default_user_group, default_temp_group -- never a team, so the two stores
were already disjoint in practice. GroupStore.addUsers/removeUsers now carry
`AND kind IS NULL`, making a team uid a no-op there, which costs no extra
query because it folds into the existing subquery and matches how addUsers
already treats an unknown username. TeamStore's writes select group_id from
a kind-filtered subquery, so neither store can reach the other's rows.

org_owned is written here but never accepted from a request; TeamService
sets it at provisioning and workspace creation only.

listMembers is keyset-paginated on id per doc/pagination.md, using the
shared cursor and limit helpers and fetching one row past the limit to
decide whether a cursor is warranted.

Passes 1/0 for org_owned rather than db.booleanValue, which yields a real
boolean on postgres and is rejected by the smallint column there -- sqlite
accepted it silently.
2026-09-03 15:57:16 -04:00
Juan Castro 37cb11df8c feat: add TeamStore for workspace CRUD
`GroupStore` has only addUsers/removeUsers; nothing creates, reads back or
lists a group at runtime. `TeamStore` is that missing half, scoped to rows
with `kind = 'team'`.

A workspace is addressed by `uid`, which `group` has carried as NOT NULL
UNIQUE since 0015. `handle` is a mutable display label with no addressing
role, so a rename invalidates nothing and a stale reference can never
resolve to a different workspace.

Soft delete releases the handle and keeps `name`. Nothing points at a
handle, so the name returns to the pool instead of being reserved forever
by a global unique index that cannot exclude dead rows -- mysql has no
partial indexes, so that exclusion was never available.

Handles validate to ^[a-z0-9]+(-[a-z0-9]+)*$, 3-64 chars, against a
reserved list. The charset is deliberately narrower than the column so the
engines' collations cannot disagree: mysql's utf8mb4_unicode_ci also folds
accents and eszett, which sqlite's NOCASE and postgres's lower() do not.

Every read filters `kind = 'team' AND deleted_at IS NULL`, which is what
makes the seeded admin/system groups unreachable rather than merely absent.
Handle lookups compare lower(handle) on postgres, where the index is on
that expression rather than the column.
2026-09-03 15:20:07 -04:00
Juan Fernando Castro ad70e8763b Merge pull request #3708 from HeyPuter/juancastro/put-1701-13-add-audit_team_membership-and-shareholder_group_id
🏗️ PUT-1701: Add audit_team_membership and share.holder_group_id
2026-09-03 15:17:34 -04:00
Juan Castro 64db3fa050 feat: add audit_team_membership and share.holder_group_id
Insert-only record of what a workspace administrator did to an account,
shaped like \`audit_user_to_group_permissions\` after 0019: nullable FK
beside a NOT NULL \`_keep\` column. The FKs are ON DELETE SET NULL, never
CASCADE, so hard-deleting an account cannot erase the record of the
resets performed on it.

Two indexes rather than one. The member's own view is the only place a
reset becomes visible to the account it was performed on, so
(user_id_keep, id) is a read path, not an optimisation.

\`share.holder_group_id\` mirrors \`holder_user_id\` from 0067. The existing
unique index does not constrain team shares at all -- it leads with
\`holder_user_id\`, which is NULL on every team share, and NULLs are
distinct -- so the group-scoped unique index is what prevents duplicates.

Drops the \`role\` column from the specified DDL: it contradicted the
settled single-administrator model.
2026-09-03 15:07:53 -04:00
Juan Fernando Castro 43f62cf7d0 Merge pull request #3705 from HeyPuter/juancastro/put-1700-12-deduplicate-jct_user_group-before-adding-the-unique-pair
🏗️  PUT-1700: Deduplicate jct_user_group and make membership writes unique
2026-09-03 15:07:49 -04:00
Juan Castro dc7b456afa fix: run the membership dedup once instead of on every boot
Review follow-ups on the dedup migration.

mysql and postgres track no per-file applied state and re-execute every
migration on each boot, so the unguarded DELETE self-joined the whole table at
every process start, forever. Both now sit behind the same index-existence
check that guards the ALTER, which also stops a rolling deploy deleting on one
instance while another adds the index.

The dedup test was a false green. `targetVersion: 67` never applies 0071 -- the
loop breaks on `threshold + 1 >= targetVersion` but stamps the target anyway --
so the fixture asserted the current schema version on a database missing a
migration. It now replays the real 0072 file against a fully migrated database,
and a second test pins the off-by-one so nobody builds a fixture on it again.
2026-09-03 14:33:46 -04:00
Juan Castro c1c1588939 fix: deduplicate jct_user_group and make membership writes unique
\`jct_user_group\` had no unique constraint on (user_id, group_id) and
\`GroupStore.addUsers\` had no conflict clause, so re-adding a member
inserted a second row. \`readUserGroupPerms\` joins the junction table on
group_id alone, so each duplicate returned another copy of every group
permission the user holds.

Deduplicate keeping the lowest id, add the unique pair index, and make
\`addUsers\` ignore conflicts via the existing \`insertIgnoreInto\` helpers --
without that last part the index turns a re-add into a raised error, which
five call sites would log as a failed signup step.

mysql cannot delete from a table it reads in a subquery (error 1093), so
it uses a self-join with the same lowest-id-wins semantics.
2026-09-03 14:32:30 -04:00
Juan Fernando Castro abb426a717 Merge pull request #3704 from HeyPuter/juancastro/put-1699-11-add-team-columns-to-group-and-jct_user_group-three
🏗️ PUT-1699: Add team columns to group and jct_user_group
2026-09-03 14:26:58 -04:00
Juan Castro eae25459f4 fix: make handle lookups case-insensitive and pin the reset flag
Review follow-ups on the team columns.

NOCASE moves onto the `handle` column itself, not just the index. Index-only
NOCASE makes uniqueness case-insensitive while leaving `WHERE handle = ?`
case-sensitive, so the same lookup would match on mysql (utf8mb4_unicode_ci)
and miss on sqlite. Postgres still needs lower(handle) at the call site.

`requires_password_change` becomes NOT NULL DEFAULT 0 on all three dialects,
matching the three sibling `requires_*` flags. Left nullable, any query written
as `= 0` would silently exclude every pre-existing user.
2026-09-03 12:10:48 -04:00
Juan Castro 2a5c089dbe feat: add team columns to group and jct_user_group
Schema foundations for teams. Ships dark — nothing reads these columns yet.

group:          kind, name, handle, plan_id, deleted_at
jct_user_group: org_owned
user:           requires_password_change

`kind = 'team'` marks a workspace; the seeded system groups keep it NULL so a
team query can never surface them. `org_owned` distinguishes accounts the
workspace created from the master account, deciding who pays rather than who
may read. `requires_password_change` is a fourth `requires_*` flag for
assertVerifiedAccount, needed by the phase 5 reset flow but shipped here to
avoid a second three-dialect migration.

`handle` uniqueness is case-insensitive, and the dialects disagree by default:
mysql gets it from utf8mb4_unicode_ci, sqlite needs COLLATE NOCASE (as
0055 does for usernames), and postgres indexes lower(handle). Without this the
same migration would accept `Design-Team` next to `design-team` on sqlite and
postgres while mysql rejected it. Postgres handle lookups must therefore
compare lower(handle) to use the index.

idx_group_owner is sqlite-only: mysql and postgres already index that column.
idx_jct_user_group_group is composite, unlike the existing single-column keys.
2026-09-03 12:10:40 -04:00
404oops 55fa5cadcb Merge pull request #3732 from HeyPuter/gemini-38-flash
gemini 3.8 flash direct integration
2026-09-03 17:35:22 +02:00
Reynaldi Chernando 644f59f9dd gemini 3.8 flash direct integration 2026-09-03 22:18:02 +07:00
404oopsandClaude Fable 5.1 8b01077d44 docs: describe the real txt2vid error body and per-provider wait windows
The Errors section claimed a `{ message, code }` body and a single
ten-minute wait. The body also carries `error`, `errorCode`, `provider`,
`upstreamCode` and, for pre-job rejections, `upstreamStatus`, and the
Sora provider still waits five minutes and folds refusals into
`upstream_failed`. Document the fields and the exceptions.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-03 16:57:36 +02:00
404oopsandClaude Fable 5.1 627a5b2d5e fix: stop video generation timeouts and failures from paging (PUT-1620)
A video job that outlived its poll window, an SDK request that timed out,
or a Veo operation that finished with an error all reached the HTTP error
handler as plain Errors. Each became an unhandled 500 with critical
severity and paged on-call for what is the provider's pace or the
provider's fault.

Video providers now share one poll loop that gives up with a 504
`upstream_timeout`, treats a transient poll failure (timeout, dropped
connection, 408/429/5xx) as a missed poll rather than a failed job, and
stops polling with a 400 `client_aborted` when the caller disconnects, so
nothing is metered for a clip nobody will receive. The driver controller
exposes the disconnect as an `abortSignal` on the request context. The
window is ten minutes for every provider; Together and BytePlus move up
from five.

Failed jobs are classified: content-filter refusals become a 400
`bad_request` with `errorCode: moderation_flagged`, rejected parameters a
400 `upstream_bad_request`, and anything else a 502 `upstream_failed`,
each carrying the provider's own code. Veo's filtered output keeps
`disallowed_value` and gains the same `errorCode`. The sanitizer and
content-filter pattern move from the Replicate provider into a shared
util so image and video agree.

Status-less SDK connection timeouts are translated to a 504
`upstream_timeout` at the driver boundary, and the chat driver records
them per attempt so an all-timeout chain is a 504 and a mixed chain is
`upstream_failed` instead of an `internal_error` 500. The Together chat
client gets the same ten-minute request timeout as the other providers.

The OpenAI video provider is left alone beyond an import path: its API is
scheduled to shut down on 2026-09-24.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-03 16:47:16 +02:00
Daniel Salazar 72011fa6ed feat: events metering and quotas (PUT-1683) (#3685)
* feat: events metering and quotas (PUT-1683)

* fix: drop the standing subscription charge and price single deliveries at 100 µ¢

An idle durable row costs nothing worth billing; the plan quotas bound how
many an account holds. Removing the daily line also removes the global
day-claim, the whole-table scan and the timer it rode on.

* test: stop asserting on documentation pages

The limits test read rate-limits-and-quotas.md and grepped it for numbers,
so every rewording of the page failed the backend suite. Docs are kept in
step by the PR and checked in review; AGENTS.md now says so.
2026-09-03 01:39:07 -07:00
Daniel Salazar 41b837b398 feat: background delivery seam, retries, and consent (PUT-1682) (#3684) 2026-09-03 01:39:06 -07:00
Daniel Salazar 04c00385b9 feat: event handlers, context, and suspension machine (PUT-1680) (#3683) 2026-09-03 01:39:06 -07:00
Daniel Salazar 9626ab9c71 feat: KV change events and KV subjects (PUT-1678) (#3682) 2026-09-03 01:39:06 -07:00
Reynaldi Chernando 3de6eeb474 init docs recipes (#3728) 2026-09-03 10:04:01 +07:00
Neal Shah af27115f68 Attempt 1: Fix PUT-1625 (#3727) 2026-09-02 21:36:04 -04:00
Daniel Salazar d8ebfc8023 feat: delivery re-check cache, revocation and anchor settle (PUT-1677) (#3681)
* feat: delivery re-check cache, revocation and anchor settle (PUT-1677)

* fix: authorize re-anchors, settle each row once, purge revoked backlog (PUT-1677)

- A path-form row whose anchor is deleted only climbs to an ancestor its
  holder may still watch under the mode it subscribed with; otherwise it ends
  with `anchor_deleted`. It used to land on any surviving ancestor (a guest's
  row on the owner's home), where the re-check denied every delivery but the
  row still held an anchor slot and a filter evaluation there.
- After a climb the new anchor is re-verified and the climb repeated if a
  recursive delete took that level too, instead of leaving the row on a dead uid.
- suspend() is one conditional write per row and reports which rows it was the
  one to suspend; concurrent settles of the same grant (an unshare revokes
  several strings) no longer each purge, forget and notify the same rows.
- One "subscriptions ended" notification per holder and app, carrying the count
  and subjects, instead of one per row.
- A revoke that removed nothing no longer announces; the sweeper purges (not
  defers) the backlog of a permission_revoked row; the reap purges pending
  entries with the row.
- The delivery auth cache indexes entries by subscription so forget() is not a
  scan of the whole cache.
2026-09-02 16:00:14 -07:00
Daniel Salazar 41d4850ab4 feat: pending event deliveries and delivery-class invariants (PUT-1676) (#3680)
* feat: pending event deliveries and delivery-class invariants (PUT-1676)

* fix: make pending delivery claims and drains atomic, keep the region under its ceiling (PUT-1676)

- claim() and the drain-time reindex run as Lua over the subscription's own
  {subId}-tagged keys. Two claimers can no longer both lease the head, and a
  drain that finds the queue empty deletes it in the same step it checks, so a
  concurrent enqueue is never wiped between the two.
- An append writes the entry and its queue position in one MULTI (same slot),
  with the index seeded before it and corrected after, so an entry is never
  visible without its position and never left out of the sweeper's index.
- Pipelines no longer mix slots (index/counter vs. per-subscription keys), so
  the store works on a multi-shard cluster, not only a single-shard one.
- Region shedding counts the marker it leaves behind; it used to stop one over
  the ceiling and convert a real event into a marker on every enqueue after.
- A claimed or suspended subscription moves to the back of the sweeper's index,
  so a delivery nobody settles cannot hold the head against every other backlog.
- `single` rows must carry a `worker` target: with sockets exhausted and no
  handler, an unacknowledged delivery would sit at the head forever.
- A gap marker for a row with no socket target is dropped rather than counted
  as a delivery of nothing.
- Backlog keys carry a 7-day TTL, refreshed by every claim, as a backstop for
  keys a purge/enqueue race left unindexed.
2026-09-02 16:00:14 -07:00
Daniel Salazar 0dfbceb047 feat: durable event subscriptions store, cache, and routes (PUT-1673) (#3679)
* feat: durable event subscriptions store, cache, and routes (PUT-1673)

* fix: durable subscription hardening (PUT-1673)

- Expired rows stop delivering at dispatch time and no longer count toward
  the per-account cap, instead of waiting for the sweep.
- The expiry sweep runs hourly with a jittered first pass shortly after boot;
  a 24 h interval never fired on a fleet that redeploys more often than that.
- Only a durable generation bump marks peer regions cold. A session
  subscribe/unsubscribe in one region used to force a primary read in every
  other region on its next dispatch.
- `subject`/`anchor_path` widen to varchar(4096) to match `fsentries.path`,
  and subjects longer than that are refused with `invalid_subject` rather than
  failing the insert on MySQL/Postgres.
- The dispatch and durable integration suites wait for the specific delivery
  they expect and assert only within their own folder; the old any-delivery
  `settle()` let a late event from a previous test satisfy or pollute the
  next one under CI load.
2026-09-02 16:00:13 -07:00
Daniel Salazar cab5253923 feat: puter.events SDK module (PUT-1675) (#3678)
* feat: puter.events SDK module (PUT-1675)

* fix: end event subscriptions on a server-side disconnect and retry budget refusals (PUT-1675)

- A disconnect socket.io will not retry (`io server disconnect`) now fails
  every subscription with `events_connection_failed` and drops the socket, so
  the next onLocal() starts a fresh connection instead of reusing a dead one.
- The connection closes when the last subscription lapses on re-subscribe.
- A re-subscribe refused with `too_many_requests` is retried after 10 s
  rather than ended for good.
- The API suite no longer passes when it cannot connect; every runtime it
  runs on (node, browser, workerd) carries the socket.
2026-09-02 16:00:12 -07:00
404oopsandClaude Fable 5.1 d89dc347a2 fix: keep the credit-bounded max_tokens on the context-length retry (PUT-1628) (#3718)
OpenRouter and Together reject a request whose prompt plus max_tokens
overflows the model's context window. Both providers retried by deleting
max_tokens, which threw away the output cap the credit gate had sized to
the caller's remaining balance and let the retry run to the model's full
output limit with no second gate and no new hold.

The retry now goes through a shared helper that sizes a new cap from the
window and input count the rejection reports, falling back to the model's
declared context and a doubled prompt estimate, and never exceeds the cap
the gate set. When no window can be determined or no output fits, the
original rejection is rethrown instead of retrying uncapped. The rejected
params are copied rather than mutated, so the first attempt's record is
not rewritten after the fact.

Also corrects the estimator's own comment, which described the mean of
two approximations as a deliberate halving.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-02 13:28:36 -07:00
Daniel Salazar 66dc0a6396 feat: subscribe ACL and app scoping for event subscriptions (PUT-1672) (#3677) 2026-09-02 13:08:23 -07:00
Daniel Salazar fb8e2976dc fix: don't redirect puter.com (#3723) 2026-09-02 11:57:30 -07:00
404oopsandClaude Fable 5.1 fd7b517448 fix: classify Replicate prediction failures instead of 500ing (PUT-1608) (#3719)
A Replicate prediction that ran and ended `failed` reaches the provider as a
plain Error with no HTTP status, so the driver-boundary translator could not
classify it and it surfaced as an unhandled 500, a critical alarm, and an
on-call page. Most of these are the model's content filter refusing the
user's prompt.

Wrap the run call and classify the failure: content-filter refusals become
a 400 with `errorCode: moderation_flagged` (the code chat refusals already
use); anything else becomes a 502 `upstream_failed`, which the alarm gate
skips. Status-bearing SDK errors pass through untouched so the boundary
translator keeps handling them. Upstream messages are stripped of markup and
bounded so an HTML error page can no longer ride into a response body or an
alarm signature.

Documents the codes callers can now act on in the txt2img reference.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-02 11:51:08 -07:00
Neal Shah 262f1dc5c5 allow concept of "home regions" (#3699)
* allow concept of "home regions"

* remove extraneous config value not applicable to repo
2026-09-02 14:11:55 -04:00
Daniel Salazar 7ec674d33b feat: app sockets join their own events room (PUT-1671) (#3676) 2026-09-02 09:01:22 -07:00
Daniel Salazar b07d2e109f feat: session event subscriptions and dispatch hot path (PUT-1666) (#3675) 2026-09-02 09:01:22 -07:00
Daniel Salazar 92e1b40361 feat: events subject registry, parser, anchors, filters (PUT-1660) (#3674) 2026-09-02 09:01:21 -07:00
Daniel Salazar d0ee19a2c2 chore: remove dead notification surfaces (PUT-1669) (#3673) 2026-09-01 22:04:33 -07:00
Daniel Salazar 82076ea767 feat: notification retention sweep (PUT-1668) (#3672) 2026-09-01 22:04:32 -07:00
Daniel Salazar 5f37817a5c feat: notification type registry and audience rules (PUT-1665) (#3671) 2026-09-01 22:04:32 -07:00
Daniel Salazar 148b930182 feat: notification scope columns and backfill (PUT-1659) (#3670) 2026-09-01 22:04:31 -07:00
Daniel Salazar 0385072e06 feat: better verification step handling (#3717) 2026-09-01 21:17:19 -07:00
Daniel Salazar 1736d23ee7 Ds/put 1674 (#3715)
* feat: app-scoped share listing and revoke surface (PUT-1670)

* fix: address review on app-scoped share surface

- Scope the uid-addressed revoke to the named row: only that row's
  issuer's grant is withdrawn, and only that one invite cancelled —
  an app or owner addressing one row no longer takes another issuer's
  grant on the same (item, recipient) pair with it.
- Delete a pending row directly on uid-addressed revoke, so an invite
  whose address registered but never claimed can still be withdrawn.
- Read the legacy `issuerAppUid` data key in the SQL app filter and
  grouping, alongside the unified `issuedByApp`.
- Refuse malformed `appUid` input (duplicated param, empty string)
  instead of silently listing everything, and refuse app-listing
  cursors that decode but name no appUid.
- Derive the acting app from `effectiveApp` alone, per the actor
  contract; drop the second derivation site.
- Pin the attribution semantics with tests: one row records one
  issuance, so re-sharing the same pair re-attributes it to whoever
  issued last, in both directions.
- Soften the uniform-404 docblocks to what the gates actually answer.

* feat: readable grant audit trail (PUT-1674)

* fix: cover the apps summary's no-app-group first page (PUT-1670)

listOutboundApps sorts the no-app group first via an empty-string
sentinel. Add a regression test pinning that a first page (no cursor)
actually returns it, and that the cursor it hands back resumes past it
into the app-keyed groups rather than skipping or repeating.
2026-09-01 14:07:15 -07:00
Daniel Salazar 66a975f659 feat: global outbound share listing (PUT-1664) (#3694)
* feat: global outbound share listing (PUT-1664)

* fix: address review on outbound share listing

- Check share-row liveness per (holder, entry, issuer) so a grant
  withdrawn outside unshare doesn't stay listed while another issuer
  still reaches the same holder; batch the permission reads across the
  whole page instead of per holder.
- Retire a revoked issuer's unclaimed invites in the revoke cascade,
  and hide invites whose issuer lost their authority at read time.
- Unify the pending/active app-attribution key on `issuedByApp` and
  dual-read the legacy `issuerAppUid` spelling.
- Add the missing share issuer index (sqlite, postgres) and correct
  the listOutbound plan comment.
- Refuse cursors that decode but name no id instead of silently
  restarting from page one.
- Consolidate the five hand-built ResolvedShare literals and the two
  listing endpoints' parse/shape code.
- Ship the SDK surface: puter.fs.listSharedByMe() with docs, types,
  suite coverage, and the rate-limit page entry.
2026-09-01 13:52:37 -07:00
404oopsandClaude Fable 5.1 6478a8f47d Add Claude Fable 5.1 (#3711)
Register claude-fable-5-1 in the Claude catalog and gate it in the
provider the same way as Fable 5: no sampling params, effort via
output_config, adaptive thinking with summarized display. The bare
claude-fable / claude-fable-latest aliases move to 5.1, matching how
the Opus aliases moved when Opus 5 landed.

Fable 5.1 keeps Fable 5's $10/$50 per MTok, 1M context and 128K output,
but bills cache reads at 0.025x input instead of the 0.1x every other
Claude model uses, so the catalog row carries its own rate and a test
pins it.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-01 15:34:49 -04:00
Nariman Jelveh fdd86ceb03 Stop server-rendered landings flashing for signed-in users (#3707)
The shell renders its anonymous markup — the marketing homepage, an
`/app/<name>` landing — off the session cookie alone, and that cookie is
set with no maxAge, so a browser drops it on quit while the GUI's
localStorage token lives on. A returning user is served the anonymous
page and the GUI only tears it down once `whoami` answers, a network
round-trip after first paint. That teardown is the flash.

Gate it before the paint instead. The shell now emits, as the first thing
in <head>, a rule hiding `.hide-if-logged-in` under an <html> class that
an inline script adds iff `auth_token_v2` is in localStorage. The rule is
already in the cascade when the markup is parsed, so a browser holding a
token never paints it at all.

`initgui` settles the guess the token represents: `whoami` confirming the
session removes the nodes outright (replacing the old `#appLanding`
removal), and no session — none stored, or one `whoami` rejected — drops
the class so the markup comes back. The gate carries its own 12s failsafe
so a bundle that never boots can't strand a blank page.

SEO is unaffected: the HTML is byte-identical for every client, nothing
branches on user-agent, and a crawler has no stored token so it never
adds the class. Unreadable storage fails open the same way.

Anonymous markup opts in with `class="hide-if-logged-in"`, which
`home.html` already carried.
2026-09-01 09:19:00 -07:00
Daniel Salazar 7584dffc4b fix: metering for ai (#3703) 2026-09-01 09:11:04 -07:00
jelveh b400e06473 Remove login landing fade-out overlay
Drop the delayed fade-out when a logged-in user reaches the GUI and remove the landing overlay immediately instead. This avoids the overlay sitting above the email/phone/card verification gates because of its z-index.
2026-08-31 20:13:58 -07:00
jelveh 22c3316109 Hide app landing after session restore
Remove the /app landing overlay once `whoami` confirms the user is authenticated, even if the server rendered the page as anonymous because the session cookie was missing. This prevents the overlay from covering the email, phone, and card verification gates during localStorage-based session restores.
2026-08-31 20:07:47 -07:00
404oops a27852de0a Merge pull request #3655 from HeyPuter/FK/normalize-openai-format
Comprehensive Normalization.
2026-08-31 22:24:34 +02:00
jelveh f80a0d72b3 fix: dashboard 'Add Existing Account' dialog buried on phones/tablets
On device-phone/device-tablet, style.css pins every .window to
z-index 9999999 !important — including the fullpage dashboard window.
The Add Existing Account login dialog opens with backdrop: true, and
the .window-backdrop wrapper is not a .window, so it kept its small
inline z-index and rendered entirely behind the dashboard.

Pass stay_on_top: true like every other backdropped dashboard dialog,
and make the Forgot-password child dialog inherit the flag so it does
not open buried under the now stay-on-top login window's backdrop.

Verified with Playwright on iPhone 13 emulation and a desktop
viewport: login and recover-password dialogs stack above the
dashboard in both.
2026-08-30 21:45:43 -07:00
Nariman JelvehandClaude Fable 5 3e8b2735bb Cap /app/<name> meta descriptions at 150 characters (#3698)
The app shell fed the row's full description into <meta name=
"description">, og:description and twitter:description. Long
descriptions now collapse whitespace and cut at a word boundary with an
ellipsis, never exceeding 150 characters.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-30 20:17:04 -07:00
Daniel Salazar 5065bcbf0a fix: document allowedAppIdsGate's real non-app-gate behavior (PUT-1667) (#3669)
The JSDoc claimed non-app actors are rejected; they are passed through, and
the check reads `actor.app` rather than `effectiveApp`, so an app in the
token chain is invisible to it. Callers depend on the pass-through — routes
combining `adminOnly` + `allowedAppIds` are reached with an admin's browser
session, and the dev-account surface is called from the desktop's own
session — so the docs are corrected instead of the behavior, on the gate and
on the `allowedAppIds` route option. Tests cover the gate per actor shape
(user session, worker, full-access token, app-issued access token,
app-under-user allowed/disallowed) and the admin composition.
2026-08-30 12:55:39 -07:00
404oopsandClaude Fable 5 7e1cdefd98 feat(ai): backfill provider catalogs from live /models listings
A live audit of every keyed provider's /models endpoint against the hardcoded
catalogs found no stale entries but large gaps. This backfills them under four
rules: nothing vendor-deprecated, nothing without a price confirmed on the
vendor's official pricing page (each entry's source was recorded during
review), nothing absent from the live /models listing, and nothing that fails
a live routing probe.

Added: 46 Alibaba entries (qwen3/3.5/3.7/3.8 families, VL/omni/MT lines, and
Model Studio's hosted GLM/DeepSeek/Kimi third-party models) plus 9 dated
aliases; OpenAI chat-latest and gpt-4o-2024-11-20 plus 16 snapshot aliases;
Gemini gemma-4-31b-it and gemma-4-26b-a4b-it (vendor-documented free tier)
plus rolling -latest aliases; Mistral-hosted zai-glm-5-2 and a
mistral-medium-3.5 alias; deepseek-v4-flash-vision-exp; glm-5.3-flash.

Culled by the rules: 15 vendor-deprecated OpenAI entries (the 3.5/4/4-turbo
legacy line, gpt-4o-2024-05-13, o1-pro, four chat-latest predecessors, the
5.x codex line — deprecations page, most shut down 2026-10-23) and dated
aliases onto the deprecated o1/o3-mini/o4-mini; qwen3-vl-flash-2025-10-15
(live routing probe returned upstream 400 twice). Tiered Alibaba prices are
encoded at the base tier and busy-hour rates where time-of-day priced, noted
in comments.

Every surviving addition was verified end-to-end through a local deployment:
58/58 answered a live prompt, including all 46 Alibaba entries and every
spot-checked alias.

Not changed, flagged for maintainers: pre-existing o1, o3-mini and o4-mini
entries are now vendor-deprecated (shutdown 2026-10-23); the pre-existing
deepseek-v4-flash/-pro prices no longer match DeepSeek's current pricing
page; gemma-4 emits its own <thought> markup inline in content.

Co-Authored-By: Claude Fable 5 (1M context) <[email protected]>
2026-08-30 20:50:46 +02:00
404oopsandClaude Fable 5 4199a2506d feat(ai): forward Mistral prompt_mode so magistral can emit separable thinking
Live-probing magistral-small-latest showed the model inlines its reasoning as
answer prose in a flat string — no ThinkChunk content, no markers, nothing a
client can separate. Mistral's chunked thinking shape is requested via
`prompt_mode: 'reasoning'`, which the provider previously dropped on the
floor: there was no way to even ask for it.

`custom.prompt_mode` now forwards to the SDK's `promptMode`, following the
BytePlus custom-params precedent. Opt-in rather than a default because the
API rejects the mode where the account/model lacks it ('Reasoning prompt
mode is not enabled for this model', code 3051) — verified end-to-end: the
3051 travels back through the stack, which also proves the parameter is
delivered. The moment Mistral enables the mode, the ThinkChunk content flows
into the existing splitter and comes out as `message.reasoning` and
`reasoning` stream chunks with no further changes.

Co-Authored-By: Claude Fable 5 (1M context) <[email protected]>
2026-08-30 20:50:46 +02:00
404oopsandClaude Fable 5 4aa66c7c9b feat(ai): make puter.ai.normalize tri-state — unset means the release-date policy
The SDK-wide flag now mirrors the per-call option's tri-state. Left unset
(the default), the release-date policy applies: models released on or after
2026-09-01 are normalized to the OpenAI shape, older models keep their
vendor-native shape, and nothing rides the wire — the server's policy
resolution decides. Setting `true` force-normalizes every chat() call
regardless of release date; `false` disables normalization for every call;
either explicit value is sent on each call that doesn't set its own.
A per-call `normalize` overrides the flag in both directions, and assigning
`undefined` restores the policy.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-30 20:50:15 +02:00
Daniel Salazar afe46a17a0 fix: alaem cascade (#3668) 2026-08-29 03:41:23 -07:00
Juan Fernando Castro 81d700d146 fix: publishing a directory takes ownership, not write (PUT-1619) (#3654)
Creating a hosted subdomain gated `root_dir` on `write`, and hosting serves
everything under that directory with the ACL deliberately bypassed. So a
recipient of a `write` share could point a `*.puter.site` subdomain at the
owner's folder and make the subtree world-readable — continuously, covering
files the owner added later, with the row under the recipient's account where
nothing the owner can list would show it. `update` had the same gate for a
changed `root_dir`.

`#checkPublishAccess` now decides both: the actor's own tree still takes
`write`, anyone else's takes `manage` — "Can edit & share", the level that
delegates the decision.

Keyed on who owns the entry rather than asking for `manage` outright, which is
what the ticket proposed. `manage`'s is-owner implicator declines to answer for
app actors, so a flat `manage` would refuse every app publishing a directory
its user handed it, with no way for the app to obtain the grant. The write
check still runs first — it is what masks a directory the caller cannot see as
a 404 — and `manage` satisfies every lower mode, so the order costs a
manage-holder nothing.

The GUI's Publish As Website item reuses the own-it-or-`manage` answer it
already computes for sharing, so it is not offered where this would refuse.
Docs state the rule on `hosting.create()` and in `share()`'s level list.

Regression tests fail without the driver change: a write-share recipient is
refused on create and on repointing an existing subdomain, while `manage` and
the actor's own directory are accepted.
2026-08-29 01:46:14 -07:00
LEANDER ROSHAN L d5e9c14b0d fix: update sharing indicator #3649 (#3665) 2026-08-29 01:43:40 -07:00
Juan Fernando Castro c17613669c fix: open read-only shared files instead of forwarding write_url=undefined (#3667)
Opening a file you hold read-but-not-write access on (a read-only share)
failed silently. For such a file the backend correctly omits write_url from
the /open_item signature, but launchApp appended it to the app iframe URL
unconditionally. URLSearchParams coerces undefined to the string \"undefined\",
so the app received puter.item.write_url=\"undefined\" — truthy, so the editor
believed the file was writable, and an invalid URL, so it broke on open. Only
read-only shares hit this; files you can write carry a real write_url.

Extract the puter.item.* param building into append_signed_item_params and
guard the write_url append so it is only added when present. launchApp.js is
too coupled to UIWindow/jQuery/window globals to unit-test directly, so the
pure helper carries the logic and its own test, matching the helpers/ pattern.

Regression test pins both directions: a read-only signature omits write_url
entirely (no \"undefined\"), and a writable signature still forwards it.
2026-08-29 01:42:46 -07:00
Juan Fernando Castro cdfd38bd4b fix: stop an embedded fs: in a path from escalating to full FS access (#3666)
The fs-path-to-uid permission rewriter split permission strings on the raw
\`fs:\` substring instead of parsing on component boundaries. A path can
itself contain \`fs:\` — a home dir named \`…fs\`, or \`fs\` in the mode
position — and the raw split mistook that for the mode delimiter.

Crafting \`fs:/<victim>fs:junk:read\` made the split do two things at once:
it dropped the \`junk:read\` mode, and it consumed the trailing \`fs\`, turning
the harmless-looking nonexistent path shown in the consent dialog into the
victim's real home path. The rewriter then stored a bare \`fs:<home_uuid>\`,
which subsumes every mode via parent-permission matching — full
read+write+delete from a request the user saw as \`junk:read\`.

Parse with PermissionUtil.split() in both matches() and rewrite(), matching
every other permission parser, and locate the \`fs\` component by position
(after an optional \`manage\` prefix). The path component is now exactly what
sits between \`fs\` and the next unescaped colon, and all trailing components
are preserved as the mode — so an embedded \`fs:\` is data, never a delimiter.
The crafted input now addresses the nonexistent \`/<victim>fs\` and 404s.

Regression tests pin both halves: the exact PoC must 404, and an \`fs:\` in
the mode position must survive the rewrite instead of collapsing to a bare
permission. Both fail on the old rewriter and pass on the fix.
2026-08-29 01:41:40 -07:00
Juan Fernando Castro 693e86d763 Merge pull request #3657 from HeyPuter/juancastro/put-1616-shareblocks-is-missing-requireuseractor-any-authorized-app
🔧 PUT-1616: Close /share/blocks to app tokens
2026-08-28 16:46:04 -04:00
Daniel Salazar 6d10ef2821 fix: stop a URL parameter putting puter.js into app mode (#3660)
`env = 'app'` was decided by the presence of a `puter.app_instance_id` query
parameter and nothing else, so a crafted link put any page that loads the SDK
into app mode — and app mode is what makes the URL's `puter.api_origin`
authoritative for every credentialed call.

App mode now also requires the document to be framed. The GUI only ever
launches an app into an iframe, so this costs a real app nothing while a
top-level document carrying the parameters is treated as the third-party site
it is. It is not an attestation that the framing document is the GUI — a
cross-origin ancestor's identity is not readable — so the token paths carry the
rest:

- The `web` boot branch adopted a stored token without consulting the origin it
  was bound to, which is what completed the fixation: one link plants a token
  bound to an attacker's origin, and every later visit adopted it. It now
  applies the same binding rule the app branch does, and drops a token that
  fails it rather than leaving it to be re-read.
- `signIn()` had no env guard, and in app mode delivered a real token to
  whatever `puter.api_origin` the launching URL named. Apps get their token
  from the session that launched them, so it now rejects there with
  `not_available_in_app`. Nothing internal reaches it in app mode:
  `authenticateWithPuter` and both implicit-auth call sites already gate on
  `env === 'web'`.
- The cross-origin-isolated branch polled `${this.APIOrigin}/login/wait` and
  adopted whatever came back. Pinned to `defaultAPIOrigin`, the same way the
  popup and its message handler already pin `defaultGUIOrigin`.

Backward compatibility: no signature, response field or existing error code
changes. The only behaviour a caller can observe is the new `signIn()`
rejection, which replaces a call that could not have worked correctly.

Covers the SDK side of the parameter PUT-1395 and PUT-1427 closed on the GUI.
2026-08-28 11:16:10 -07:00
Daniel Salazar 9fab4742c9 fix: complete the malformed-input guards, including the two paths still live (#3663)
Review of the previous change found three of its claims unmet.

The image-generation crash it reported fixed is still reachable. The assert was
scattered across three helpers, and Gemini and OpenAI call `isHttpUrl` directly
on `input_images` without going through any of them — so two of seven providers
still 500 on a non-string. `isHttpUrl` now refuses a non-string itself, and the
shape is settled once in `ImageGenerationDriver.generate`, where the driver call
arrives, rather than per helper. That also covers `input_images` that isn't an
array, which produced a different crash per provider.

The sixth case in the ticket, previously unlocated, is
`Messages.js` reading `tool_call.function.name` with no guard — reachable with
`{"messages":[{"role":"assistant","tool_calls":[{"id":"x"}]}]}`. Guarded, along
with the same shape in `make_claude_tools`: a TypeError there carries no status,
so the retry loop reads it as a provider failure and marks the route unhealthy
for every caller.

`#hardExpiryFromExpiresIn` returning null for a bad type moved the failure past
the session INSERT, leaving an orphaned non-expiring row and still answering
500. Reverted; the controller guard is the fix, now covering fractions,
negatives and unparseable durations rather than only wrong types.

Also: the batch write handlers check that the body is an array but not what is
in it, so a null element 500s the same way; `#requireObjectBody` accepted an
array despite its name; `handleCreateAccessToken` destructured a body that may
be absent; and two AGPL notices had been rewrapped with a Markdown link.
2026-08-28 11:14:30 -07:00
Daniel Salazar f3a46a9be4 fix: stop sockets outliving the session that authenticated them (#3658)
A socket was checked once at handshake and never again. Nothing in the backend
disconnected one, so logout-everywhere, password reset, session revoke and
suspension all left every connection streaming legacy FS entries, upload paths
and notification bodies — up to 400 per account — on a credential that had
already been revoked.

Three gaps, three fixes:

- The handshake skipped the suspension and pending-verification checks every
  authenticated HTTP route gets. `decideSocketAuth` now applies both.
- `revokeCascade` reports which rows it revoked, AuthService announces that as
  `auth.sessions.revoked`, and SocketService drops the account's room. The
  whole room goes, not just the revoked session: narrowing it would need
  `fetchSockets`, which the adapter builds on `serverCount()` — and that calls
  node-redis's `send_command`, which ioredis does not implement. A connection
  whose session survived reconnects on its own and re-authenticates.
- A bulk suspension writes `user.suspended` without touching `sessions`, so no
  revoke fires. A five-minute sweep re-verifies each live socket's token and
  drops the ones that no longer authenticate. De-duplicated by token, since a
  browser's tabs share one.
2026-08-28 11:12:27 -07:00
Daniel Salazar e460e9034c fix: answer 400 instead of 500 on four malformed-input paths (#3662)
Each of these read a field off caller input that wasn't the shape the code
assumed, threw a TypeError, and was served as a 500 with a critical page.

- POST /fs/write and /startWrite: a request whose body never parsed left
  `req.body` undefined, and the first read of `fileMetadata` threw.
- POST /drivers/call, image generation: `input_image` / `input_images` entries
  are documented as strings but nothing checked, so a number or an object
  reached `.startsWith`. Type confusion on caller input, so reachable on
  demand rather than by accident.
- POST /auth/create-access-token: `expiresIn` went to the expiry parser
  unvalidated, where anything but a string or a number has no `.trim`.
- POST /login/wait: destructuring `session` out of an absent body threw.
  Same class as the POST /login body ticket; that one covers /login itself.
2026-08-28 10:22:51 -07:00
Daniel Salazar 5c8defb940 fix: bound failed WebDAV Basic-auth attempts per account and per address (#3659)
The only thing in front of the bcrypt compare on the DAV host was the 600/min
request ceiling, keyed on a fingerprint that rotates with client-controlled
headers. /login guards the same credential with a captcha and two much tighter
buckets; DAV had neither, which left password and TOTP guessing viable from a
host that answers any origin.

The request ceiling can't double as a credential ceiling — a working DAV client
resends its credentials on every request — so the new buckets count only
verifications that failed: 10 per account and 50 per address per 15 minutes,
sized like /login's. They're read before the compare, so an exhausted bucket
costs no bcrypt round, and successful requests never draw them down. `-token`
attempts are held by the address bucket alone; bucketing them per account would
let bad tokens lock out good ones.

Reading a bucket without spending from it is new, hence `peekRateLimit` and the
matching `peek` on all three backends.

Also documents the DAV limits, which were undisclosed.
2026-08-28 10:21:34 -07:00
Daniel Salazar 88b851a423 fix: derive the signup client IP from req.ip instead of x-forwarded-for (#3661)
Both signup paths read the x-forwarded-for header directly for the
puter.signup.validate event, the puter.signup.success event and the
signup_ip_forwarded column. Behind an appending proxy a client can prefix that
header with anything it likes, which gives every per-IP abuse signal a fresh
bucket per request.

req.ip is the value `trust proxy` resolves, so it is the one the server can
stand behind; server.ts already uses it for the ip.validate gate for exactly
this reason. All three now share one derivation, so a per-IP counter is written
and read under the same key. The raw chain is still recorded, but only as
audit_metadata.ip_fwd, where nothing keys on it.

Rows written before this hold whatever the proxy appended, so per-IP velocity
over the trailing window is undercounted until they age out.
2026-08-28 10:19:24 -07:00
Juan Castro 94d798ccce fix: close /share/blocks to app tokens
All three block routes set only `subdomain`, `requireVerified` and a rate
limit, and the controller's actor check accepts anything carrying a user, so an
app-under-user token passed. Unlike `share` and `revoke`, nothing else bounded
them: an app the user authorized once could read exactly whom they had blocked,
then clear the list — blanket switch and per-username — and reopen the channel.

A block list is a personal safety control, not something an app can otherwise
reach, so it falls outside the "an app can share what it can already reach"
carve-out the other share routes rely on. `requireUserActor` on all three, and
deliberately without `allowFullAccessToken`: this is security management, which
stays closed to every access token, personal ones included.

The only caller is the desktop's Blocked Senders window, which sends the user's
own session token.

Tests fail without the gate: an app token minted through
/auth/get-user-app-token is refused on all three routes while the user's own
session still succeeds, and a metadata check pins the gate on every block route
so a fourth cannot quietly ship without it.
2026-08-28 12:32:17 -04:00
404oopsandClaude Fable 5 07998c32ed test(ai): point the provider-level replay test at the real driver fallback test
The comment claimed the fallback loop cannot be driven from a test — false
since ChatCompletionDriver.test.ts gained a two-attempt fallback test that
drives the actual loop. Say where that test lives instead.

Co-Authored-By: Claude Fable 5 (1M context) <[email protected]>
2026-08-28 01:09:14 +02:00
404oopsandClaude Opus 5 04d12d9524 fix(ai): route Mistral streamed thinking to the reasoning channel, unconditionally
Third triple-check round. Ten findings were put through independent skeptics
first; six did not survive — pre-existing on main, inert, or resting on a false
premise — and are not acted on here.

Mistral streamed thinking now goes to `reasoning` on every path. The previous
commit gated the split, which made this the only place in the repo where
chain-of-thought reached the visible text channel, and made Mistral the only
provider whose streamed chunk *types* depend on a response-format flag. Every
other reasoning path routes thinking to `reasoning` unconditionally —
ClaudeProvider's thinking_delta, the DeepSeek/OpenRouter rename, and this
branch's own Responses summary-delta handler. Removing the gate restores that
uniformity and makes the documented promise that streaming is unaffected by
normalization true again; the two opposing tests collapse into one that runs the
same fixture with and without `normalize` and asserts identical event streams.

Docs stop claiming older models are unchanged. Four reasoning fields were made
consistent across all models, ungated, and one of them removes a field: on
non-streaming responses `message.reasoning_content` is now `message.reasoning`.
chat.md gains a table naming all four so a caller reading `reasoning_content`
learns why it disappeared, instead of reading that nothing changed for them.

Adds the driver-level fallback test. Writing it surfaced that the invariant it
was meant to assert is false and always was: the driver rewrites string
`content` into text blocks in place on the caller's own messages
(`normalize_single_message`, pre-existing) before any provider runs. The test
now asserts what is true and load-bearing — both attempts receive the same array
reference, and the reasoning artifacts survive attempt 1 so attempt 2 can still
replay them.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 01:09:14 +02:00
404oops 875aac5db0 Revert "docs: record AI-normalization review rules in CLAUDE.md"
This reverts commit f220276724fdc1a16ae56f9c95e41773fe4231ed.
2026-08-28 01:09:14 +02:00
404oopsandClaude Opus 5 216e8d68ed fix(ai): scope the normalized flag, gate the Mistral stream split, correct four doc claims
Acts on a second triple-check audit.

`normalized` means one thing. The driver stamped it on both the OpenAI-shape
path and the legacy `response.normalize` path, which converts toward Anthropic
blocks — so a caller could get `normalized: true` alongside array content, and
the flag told them nothing they could branch on. The legacy branch no longer
sets it. That branch is reachable only by a direct driver call with
`response.normalize` and no `normalize`; the four wire routes pin
`normalize: false`, which skips it.

Mistral streaming, split by concern. Flattening a reasoning model's chunked
`delta.content` to a string is a correctness floor and stays ungated — the
shared handler passes the value straight to `addText`, so an array reaches the
caller as stringified objects. Splitting the thinking text out into a
`reasoning` delta is the dialect change and now sits behind the policy gate
like the non-streaming remap. On the native path the thinking text is kept
inline rather than dropped.

Mistral `finishReason` is deleted only once its value carried over. The delete
ran unconditionally, so a non-string `finishReason` with no `finish_reason`
left the choice with no finish reason at all.

Four doc claims corrected against the code paths they cover: `content` is
string-or-null on normalized responses (tool-only turns carry no text, and the
`// always a string` example comment was wrong); `reasoning_details` is not
scoped to normalized responses, since Responses models emit it either way; and
the release-date rule depends on the serving provider's own dates — OpenRouter
derives them from its live API, so models newly listed there from 2026-09-01
normalize by default.

Adds the test the copy-on-write fix was actually for: one messages array sent
through two sequential calls, asserting the caller's array is untouched and
both attempts carried the thinking signature. That is the fallback hazard; the
harness wires one provider per model, so the fallback loop itself cannot be
driven from a provider test.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 01:09:14 +02:00
404oopsandClaude Opus 5 c1b420e480 fix(ai): gate the Mistral remap, unify the reasoning join, stop mutating caller messages
Acts on a triple-check audit of this branch.

Gate the Mistral dialect remap. The camelCase→snake_case rewrite and the
chunked-content flattening were firing for every Mistral call regardless of
`normalize` or the cutoff, deleting `finishReason` and `message.toolCalls` out
from under any caller reading them. Both now sit behind the policy resolution
the driver already used, extracted as `shouldPresentAsOpenAI` so the provider
and the driver cannot drift. The streaming chunk-array split stays ungated:
handing an array to `addText` is a plain bug, and streamed chunks are
provider-uniform by design. The conformance matrix now passes `normalize: true`,
which is the contract it was always testing.

Unify the reasoning join. Three code paths produced two separators while one
doc sentence described them all: the coercer joined thinking segments with '',
the Responses handler and Mistral with '\n\n'. The coercer now matches, and
chatresponse.md's claim is true for every path it covers. Text blocks still
join with '' — Anthropic splits prose mid-sentence across them.

finish_reason is an open set. chat.md's normalize bullet and the SDK
ChatMessage typedef still declared a closed four-value set, contradicting the
documented pass-through of unmapped vendor reasons and the coercer that
implements it.

Stop mutating caller messages. Both reasoning-replay input paths deleted
output-only fields from the caller's own message objects, which the driver
reuses across fallback attempts. Both strip a copy now; tests pass a frozen
message through each.

Drop three dead things the type cleanup left: the no-op ChatProvider
checkModeration stub (no subclasses, no callers — its removal restores a
pre-existing baselined TS2420), the redundant second normalizeReasoningContent
call in BytePlus and ZAI, and the coercer's bare-string branch that no provider
reaches. A bare string now passes through by reference instead of being
coerced.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 01:09:14 +02:00
404oopsandClaude Opus 5 fbb9038651 revert: restore the 31 typecheck-baseline entries this branch removed
Commit 6b4f5b01f dropped 31 entries (49 error instances) from
tools/typecheck-baseline.json as a side effect of fixing the AI-code type
errors they suppressed. The file is a ledger the maintainer owns, and shrinking
it inside a feature PR ships a CI-gate change nobody asked for.

Restoring is free: tools/typecheck.mjs fails only on *regressions*
(count > baseline), so stale entries are non-fatal — the gate prints "49
baselined error(s) fixed. Run npm run typecheck:update to lock that in." and
exits 0. Nothing else in the repo reads the file, vitest included, and
tsconfig.build.json's noCheck:true means emit is unaffected. The errors stay
fixed in the source either way; only the ledger's own bookkeeping is deferred
to whenever the maintainer chooses to regenerate it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 01:08:48 +02:00
404oopsandClaude Opus 5 74640d4e83 fix(ai): flatten Mistral reasoning models' chunked content
`magistral-*` returns `message.content` as a ContentChunk[] rather than a
string, with the thinking text nested one level deeper inside `thinking`
chunks. The camelCase remap did not touch it, so a non-streamed magistral
response reached the caller as an array with no `reasoning` — the one case
left where a provider did not produce the equalized shape this branch
promises. Streaming had the matching bug: the chunk array was handed to
addText, which would have stringified it into the text stream.

Both paths now split chunked content into a string `content` plus a
`reasoning` string, joining multiple thinking chunks with a blank line as the
Responses handler and the Anthropic coercer do. The streaming fix rides the
existing Mistral-only `chunk_but_like_actually` hook, so no new deviation is
introduced.

The conformance matrix had no Mistral reasoning fixture, which is why it
missed this; it now has one carrying chunked content, verified to fail
without the flattening.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 01:08:48 +02:00
404oopsandClaude Opus 5 3b8de6a90b docs: record AI-normalization review rules in CLAUDE.md
Rules from the post-normalization review: don't silently change what existing
models return, verify doc claims against every code path they cover, record
self-disclosed defects in the PR draft, finish with a fresh build + suites, and
check git stash before concluding an edit was lost. Plus the standing
typecheck-baseline no-go.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 01:08:48 +02:00
404oopsandClaude Opus 5 a6830f341a fix(ai): round-trip reasoning artifacts and pass unmapped stop reasons through
Closes the reasoning gaps left open by the normalization work.

Reasoning replay. The coercer dropped Anthropic thinking-block signatures and
the Responses handler dropped reasoning item ids/encrypted_content, so a
normalized reasoning turn could not be replayed — Anthropic rejects an
extended-thinking tool-use continuation whose thinking blocks lost their
signature. Both now ride `message.reasoning_details` verbatim, and both input
paths accept them back: ClaudeProvider splices the blocks ahead of the content
(Anthropic requires them to lead), and the Responses input processor expands
them into standalone `reasoning` items. Output-only fields a replayed message
carries (`reasoning`, `refusal`, `normalized`) are stripped on both paths,
since neither upstream accepts them. The docs caveat recommending
`normalize: false` for agentic Claude loops is gone; it is no longer true.

Unmapped stop reasons. chatresponse.md promised a vendor `finish_reason` with
no OpenAI analog "passes through unchanged" — true for the Mistral remap, false
for the Anthropic coercer, which discarded it. Anthropic's `pause_turn` means
"continue this turn", so flattening it to `stop` destroyed the signal. The
coercer now passes unmapped values through verbatim, matching both the doc and
the Mistral path, and the docs gain the full Anthropic stop-reason table.

Reasoning summaries. Multi-part summaries joined with '' instead of a blank
line, and the streaming Responses path emitted no reasoning at all;
`response.reasoning_summary_text.delta` now feeds the same `reasoning` stream
channel the chat-completions handler uses.

Types. `text?: string & { verbosity?: ... }` was an uninhabitable intersection
(providers read `text?.verbosity` as an object), and the verbosity enum was
`'concise' | 'detailed'` where OpenAI accepts `'low' | 'medium' | 'high'`.
Adds `reasoning`, `reasoning_details`, and `refusal` to the SDK ChatMessage
typedef.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 01:08:48 +02:00
404oopsandClaude Fable 5 548d26f884 test(ai): cover Hoonify in the provider-consistency matrix
Adds the upstream Hoonify provider (feat #3499, merged after this
branch was cut) to the cross-provider output conformance suite. It
speaks the OpenAI chat-completions dialect and conforms out of the box.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-28 01:08:48 +02:00
404oops 9b67a2b1dd Add AI response normalization controls
Introduces a new `normalize` option for chat completions, plus release-date based default normalization (post-2026-09-01) to coerce provider-native outputs into a consistent OpenAI-style shape. Adds shared normalization utilities, extensive driver/provider consistency tests, and controller safeguards that pin provider-native output where route-specific translators are used. Also wires the option through puter.js (`chat` options and `puter.ai.normalize` default), updates AI/chat response docs and examples, and resolves related TypeScript typing issues reflected in the typecheck baseline.
2026-08-28 01:08:47 +02:00
Juan Fernando Castro f952b5006b Merge pull request #3652 from HeyPuter/juancastro/put-1594-permission-requests-should-auto-return-true-and-resolve
🔧 PUT-1594: Settle a permission request from what is already granted
2026-08-27 19:02:15 -04:00
Nariman Jelveh 56b4ef7f2b Leave worker notifications out of the dashboard entirely 2026-08-27 15:59:25 -07:00
Nariman Jelveh 1d3963539f Keep worker notifications out of the dashboard toasts 2026-08-27 15:47:57 -07:00
Juan Castro c4be7fabac fix: settle a permission request from what is already granted
`puter.perms.request()` already pooled a permission read and prompted only
for what was missing. The raw `puter.ui.requestPermission()` did not, so
every caller still on it re-asked the user on each launch — including
`perms.requestAppData()`, whose own docs promise the opposite, and the
driver-denial retry.

- puter.js: `ui.requestPermission()` reads what is held before prompting and
  resolves true when the whole request is covered. Only in env=app and
  env=web, the environments that raise a prompt; elsewhere the method still
  answers false without asking anyone. A check that cannot be made — no
  token, an unreadable request shape, a failed read, or one that outlasts its
  timeout — falls through to the prompt rather than standing in for an
  answer. Public signature unchanged.

- GUI: the request-permission popup asks the same question as the app, using
  the user-app token its own exchange already mints, and skips the dialog
  when the access is held. This is the one case the SDK cannot settle for
  itself: a signed-out site holds no token to check with. An origin the
  browser does not vouch for never reaches the check, since the exchange
  fails first.

Both checks are time-boxed, because each one stands in front of something
that is waiting: the popup's gates the dialog, so a stalled read would leave
the prompt unshown and the opener pending, and the SDK's spends the browser's
transient activation, which a slow read would cost the popup.

Note that driver, service and feature scopes are implicitly granted to every
app (backend/data/hardcoded-permissions.js), so requests for those now
settle silently — the dialog was asking about access the app already had.
Consent scopes (email, fs, apps, subdomains, app-data, app-root-dir) are
unaffected and still prompt until granted.

Fixes a bug this method already had on the way past: `pollDecision` read an
undeclared `permission`, so every attempt threw a ReferenceError into its
network-failure catch and the COOP-severed-opener recovery burned its full
five-minute timeout before answering false. It polls `requested` now, and
requires the whole list.

Tests: the e2e suite drove its dialogs with an implicitly-held driver
permission, so the fixture now asks for a driver nothing implies, fresh per
page load, which also removes the cross-test grant carry-over the old
revokes worked around. The reconciliation tests ask for the held scope plus
an unheld one, since a fully-held request no longer reaches a dialog. Adds a
backend contract test for check-permissions under an app-under-user actor,
which is what the two new client paths rest on.
2026-08-27 16:35:06 -04:00
Nariman Jelveh 167e281cc8 Keep read notifications in the dashboard panel
The panel listed only what the user hadn't dismissed, so acting on a
notification made it vanish. It now shows the 30 most recent regardless
of state, with read/unread as a visual flag: unread rows carry a dot and
a check to mark them read; reading one (check, click-through, toast
close, mark-all, or an ack from another tab) leaves it listed but
quieter. The badge, tab title, and header count follow the unread count.
2026-08-27 13:31:39 -07:00
Nariman Jelveh c6ecc0c786 Soften dashboard notifications empty-state title 2026-08-27 13:14:05 -07:00
Nariman Jelveh f38dec07ea Remove empty-state hint from dashboard notifications 2026-08-27 12:59:01 -07:00
Nariman Jelveh 7332993ff3 Show share notifications in the dashboard (#3653)
* Show share notifications in the dashboard

The dashboard opens its own socket but never listened for notif.* events,
so a file shared with a user on the dashboard was announced to nobody.

Add a notification center to the dashboard: a bell in the sidebar with the
unread count (a dot when collapsed, and on the mobile hamburger), a panel
anchored to it — a bottom sheet on phones — listing what the server still
holds as unacknowledged, and toasts for what arrives live. Clicking a share
lands on Files › Shared with the item selected; dismissing, and "Mark all as
read", acknowledge on the server so other tabs clear too. A toast timing out
is not a dismissal: the entry stays unread in the panel.

Arrivals fold in by uid (a regrouped share rewrites its row and toast in
place), the panel refreshes on open, reconnect and tab focus so shares the
backend folds in silently past the sender's budget still surface, and a
burst on connect is capped at three toasts plus a summary.

Backend: notif.unreads carries created_at so delivered-on-connect items can
be dated, and listings break same-second ties by id so order is stable.

The desktop's mark-ack calls move to a shared helper; UINotification gains an
optional auto-hide timeout that pauses on hover/focus.

* fix: look up notification glyphs and icons by own key only

A notification whose `source` or `icon` was a prototype key such as
`constructor` made the glyph lookup return a function, and the toast
icon builder then threw inside the socket handler — which dropped the
whole burst of toasts it was part of. The list rendered the same entry
as "[object Object]".

* fix: show dashboard toasts above open app windows

App windows opened from the dashboard are stay-on-top and stack in the
99999999+ band, so the toast container at 10000000 sat underneath them:
any notification arriving while an app was open — the usual state of a
dashboard session — was drawn behind the app and never seen.

* fix: reveal the dashboard when a toast is clicked over an open app

Clicking a share toast switches the dashboard to Shared, and the "N new
notifications" toast opens the panel — both inside the dashboard window.
With an app maximized over it, all of that happened out of sight and the
click appeared to do nothing. Minimize the covering app windows first,
the way the minimize controls do, landing the URL on the dashboard's own
route, and act once the history pop has settled so the tab switch is not
traversed over.

* fix: keep keyboard focus in the notifications panel across re-renders

Every change to the list rebuilds its rows, which dropped focus to the
document body: dismissing an entry with Enter threw the keyboard out of
the dialog (the row it had just been moved to was replaced), and so did
any arrival while an entry was focused. Put focus back on the same entry
after a render, or on the one that took a dismissed entry's place, and
let Shift+Tab from the panel itself wrap to the last control instead of
leaving the dialog.

* fix: keep the notifications panel inside short viewports

The anchored panel is bottom-aligned with the bell and grows upward, but
its height was capped only by the viewport (560px or 100vh - 24px), not
by the room above the bell — on a short window the header ran off the
top of the page. Cap it to that room, so the list scrolls instead.

* fix: make notifications with nowhere to go inert in the panel

An entry with no target (a worker deploy result) showed a default cursor
yet, when clicked, was dismissed and took the panel with it — nothing
opened, the entry was gone, and the list had closed. Render such entries
as text with the ✕ as their only action; only share entries remain
buttons. The ✕ now names its notification for screen readers, since on
an inert entry it is the only focusable control.

* fix: stop a listing in flight from resurrecting dismissed notifications

The list is refreshed on open, on reconnect, and when the tab comes back
into view; a dismissal landing while that listing is in flight was undone
when it resolved, since the server's snapshot predates the ack. When the
listing also resolved after the server's own ack event, nothing removed
the entry again and it stayed until the next refresh. Leave out of the
reconciliation whatever was acknowledged — here or in another tab —
since the listing was requested.
2026-08-27 12:49:20 -07:00
Daniel Salazar 909949c68d fix: failed email alarms (#3651) 2026-08-26 23:22:14 -07:00
Juan Fernando Castro 5a6a1020a1 Merge pull request #3644 from HeyPuter/juancastro/put-1585-share-flag-in-readdir-shared-users-in-stat
🔧 PUT-1585: Share flag in readdir shared users in stat
2026-08-26 18:51:30 -04:00
Nariman Jelveh 7472002cf3 feat: add Hoonify AI chat provider (#3499) 2026-08-26 15:39:29 -07:00
Juan Fernando Castro 7bd6a2d89b Merge pull request #3646 from HeyPuter/juancastro/put-1590-sharing-shared-files-are-not-different-than-regular-files
🔧 PUT-1590: Mark shared items in the file listings
2026-08-26 17:55:19 -04:00
Juan Castro 3f7dac63b9 Merge remote-tracking branch 'origin/juancastro/put-1585-share-flag-in-readdir-shared-users-in-stat' into juancastro/put-1590-sharing-shared-files-are-not-different-than-regular-files
# Conflicts:
#	src/gui/src/UI/Dashboard/UIShareModal.js
#	src/gui/src/UI/UIWindowShare.js
2026-08-26 17:06:25 -04:00
Juan Fernando Castro 3d28b2bb99 Merge pull request #3647 from HeyPuter/juancastro/put-1586-1599-share-dialog-correctness
🐛 PUT-1586 + PUT-1599: Share dialog says what it can grant, and what it did
2026-08-26 16:58:36 -04:00
Juan Castro 63688f81b4 Merge branch 'main' into juancastro/put-1585-share-flag-in-readdir-shared-users-in-stat 2026-08-26 16:55:51 -04:00
Juan Fernando Castro 14ac6542e3 Merge pull request #3645 from HeyPuter/juancastro/put-1589-shared-file-fs-events-seem-to-be-lost
🐛 PUT-1589: Shared file fs events seem to be lost
2026-08-26 16:54:09 -04:00
Juan Castro 5739f4e59f fix: stop pushing file metadata to revoked recipients (PUT-1611)
The realtime fan-out resolved its audience straight from the `share`
index, which has no live-grant check. `/auth/revoke-user-user` deletes
the permission and leaves the index row, so a revoked recipient's socket
kept receiving name, size, masked path and mtime for every write and
move under the folder, with no expiry.

The service already solves this elsewhere — `#reachingHolders` returns
exactly the holder/entry pairs whose grant still stands, and
`listSharedWithMe` was moved onto it for the same reason. The realtime
path never got the same treatment; it does now.

Free on the unshared path: with no share rows reaching the entry there
are no holders to check, so the write path every user takes is unchanged.
Pinned by a test that counts permission reads.
2026-08-26 16:35:38 -04:00
Juan Castro cae51ce67a fix: trashing a top-level share is a removal, not a move
A grant on the entry itself is keyed on uuid, so it follows the entry
into the owner's Trash. Both ends of the move then resolved, and the
recipient was told the shared item had moved — to the GUID name Trash
gave it. Their own copy got renamed to a GUID and stayed on screen.

`shared-with-me` has always omitted trashed entries, so the listing and
the event disagreed; only the event was wrong. Trashing now reports
item.removed at the path the recipient knew, which is also what the
desktop's data-path selector needs to find the row. Restoring out of
Trash reports item.added.

A move that leaves the recipient's masked address unchanged now stays
quiet — a share masks its own root, so the owner shuffling it around
their tree is invisible to the recipient and the event carried nothing.
2026-08-26 16:25:02 -04:00
Juan Castro 8e622189ca fix: tell every recipient when a shared file disappears
Both ways a file can vanish were silent for anyone holding the folder
above it, so a third party's window kept showing a file that was gone
and 404'd on click.

A delete built its audience from the permission rows it removed, and a
file inside a shared folder has no grant of its own — only the folder
does — so the audience was empty. A move resolved its audience from the
entry's new path, and the GUI's Delete is a move to the owner's Trash,
where no recipient has a share.

Resolve the audience from where the entry was rather than from the
grants that went with it:

- Deletes also fan out to holders reaching the entry through an
  ancestor, coalesced by parent so a subtree stays a couple of queries.
  A holder covered by both passes is told once.
- Moves resolve both ends. Reaching both is item.moved, only the
  destination item.added, only the origin item.removed.

Recipients are named by the path they knew, masked through their own
share rather than the owner's tree.
2026-08-26 14:54:06 -04:00
Daniel Salazar c1c830fc32 docs: add puter site config docs (#3648) 2026-08-26 11:11:25 -07:00
Juan Castro cf00ebb9d4 Merge branch 'main' into juancastro/put-1589-shared-file-fs-events-seem-to-be-lost 2026-08-26 13:33:24 -04:00
Juan Fernando Castro b05ee63cb2 Merge pull request #3641 from HeyPuter/juancastro/put-1568-rename-srcguisrchelpers-files-to-camelcase
🧹 PUT-1568: Rename GUI helper files to camelCase
2026-08-26 13:06:58 -04:00
jelveh 40667bc811 Open Document Picture-in-Picture windows on behalf of apps
Browsers only allow documentPictureInPicture.requestWindow() from a
top-level document, and an app lives in an iframe, so an app calling it
gets NotAllowedError ("only allowed from a top-level browsing context").
The `document-picture-in-picture` token in the iframe's `allow` list does
nothing — it is not a policy feature the browser knows. Video PiP
(video.requestPictureInPicture) already works inside apps.

The GUI is the top-level document, so a new PictureInPictureService opens
the window for the app and fills it with an iframe of a page the app names,
which must come from the app's own origin (checked against the message's
origin, now carried on the IPC caller context). One window per app
instance; it closes with the app's window, and the app hears about a close
it didn't ask for. The window's opener is the GUI, so the page inside it
can reach its app's frame through parent.opener.frames and share objects
directly — a MediaStream included, which postMessage cannot carry (tracks
are not transferable between windows in Chromium).

puter.js gains puter.ui.requestPictureInPicture({ url, width, height,
onClose }) and puter.ui.exitPictureInPicture(), with docs.
2026-08-26 09:10:02 -07:00
Juan Castro 4b3d1112d1 Say when a share changed nothing
Sharing a file with someone who already had it answered "Shared with
X", the same as a first share, so the dialog claimed to have done
something it had not.

The service already knew — it computes isNew to decide whether to
notify the recipient — but the flag stopped at the controller. It now
travels on share results only; a listing describes standing access and
says nothing about it.

Which of the three things happened is settled client-side, since the
mode each recipient holds is already on screen: created access, raised
or lowered it, or changed nothing. That keeps the previous mode off the
wire, and an older backend that omits isNew still reads as a share,
which is what these dialogs said before.
2026-08-26 11:57:05 -04:00
Juan Castro 70edef561f Stop offering an access level a delegate cannot grant
A recipient given "can edit & share" could not pass that level on: the
dialog offered it, the server refused it, and the refusal was a bare
403 Forbidden that reads as a bug. Handing out manage needs authority
over manage, which only the owner has — the refusal is right, the dead
end and the silence were not.

The dropdown now withholds it from anyone who does not own the item; a
row already set to it keeps it, so opening the dialog cannot downgrade
the owner's own grant, and a mixed selection follows its strictest item.

The server says why, and only to someone who can already share the item
— a stranger still gets the ACL's own safe error, which does not admit
the node exists.

Verified against a running server: a delegate grants read and write as
before, and manage now answers cannot_delegate_manage with a sentence
naming the owner as the one who can.
2026-08-26 11:48:54 -04:00
jelveh e430ade7bd Reach Shared and Trash from the dashboard Files tab on phones
Below 480px the Files tab hides its directories sidebar, which was the only
entry point to Shared (a query, not a directory) and Trash (filtered out of
the Home listing). Home now carries a row for each; CSS shows them only at
the widths where the sidebar is hidden, using the complementary breakpoint so
the two never both show or both disappear.

The rows borrow the item markup for layout but aren't `.item`, so sorted
insert, selection restore, share-link selection and socket updates ignore
them. The footer count and keyboard select-all exclude them explicitly, and
the placeholder-removal sweeps leave them in place. They navigate on tap and
offer the same menu as their sidebar entry via the ⋯ button or long-press;
the Trash icon tracks empty/full alongside the sidebar's.

Also guard the `window.user.directories` lookup in renderDirectory: it is
undefined for some sessions, and since `puter://shared` doesn't look like a
path it always reached that branch, threw outside the try, and left the
spinner up with navigation stuck — from the desktop sidebar as well.
2026-08-26 08:18:13 -07:00
Juan Castro 519b132355 Inset the shared marker onto the icon glyph
It sat on the icon box's corner, which is 5px outside the artwork on
every side, so the dot read as clipped — half of it hanging over empty
padding with the icon's own drop-shadow falling across it. Nudged in on
both axes: 7px on the desktop, 2px on the dashboard rows.
2026-08-26 11:16:49 -04:00
Juan Castro b08dc70654 Move the shared marker onto the icon's corner and shrink it
Review feedback: too big, and in the wrong place. It sat in the badge
cluster, which the dashboard pins to the row's top-left corner rather
than to the icon — and at 12px it dominated a 24px row icon.

Now a dot on the icon itself, lower-right: 9px on the desktop's 45px
icons, 7px on the dashboard's 24px rows. Anchoring to .item-icon rather
than to the badge cluster is what keeps it on the corner at both sizes.

Dropping the people glyph with it — unreadable at either size, and
colour was the signal the ticket asked for.
2026-08-26 11:07:48 -04:00
Juan Castro a1d027e4a1 Mark shared items in the file listings
A shared file looked exactly like a regular one. The data to tell them
apart arrived with the readdir/stat share flag; nothing rendered it.

Adds the badge to both listings — UIItem (desktop, explorer windows,
file dialogs) and the dashboard's Files rows — fed from is_shared, and
keeps it in step with the share dialogs: both funnel every grant, mode
change and revoke through one render, so the badge follows without
waiting for a re-listing.

Inherited access is deliberately not badged. It is a state of the folder
that was shared, so marking every file inside would repeat one fact on
hundreds of items; the backend flag is direct-only for the same reason.

The icon (owner-shared.svg) and the strings (item_shared_by_you, in 40
locales) were already in the tree, unused — only the wiring was missing.
The blue ring is doing the work: list view shrinks badges to 8px, where
a glyph is illegible and the white circle the sibling badges use
disappears into the row.
2026-08-26 10:56:10 -04:00
jelveh defa37c106 Fix dashboard files tab mobile scrolling
Replace the Files tab's viewport-height sizing with flex-based layout sizing through the dashboard content chain. This keeps the internal file list within the visible content area on mobile browsers, preventing the bottom rows from being clipped when browser toolbars are shown.
2026-08-26 07:42:44 -07:00
Juan Castro 1514ab3cb5 Ignore fs events replayed by replication
Raised in review: could an event from another node re-trigger the
fan-out? Not today — broadcast carries outer.* and pubsub.* only, so
fs.* never crosses a node boundary, and the emitted outer.gui.* is
consumed on the peer by SocketService while ShareService listens to
fs.* alone, so nothing re-enters.

That safety is a property of what broadcast happens to replicate, which
is not this service's to rely on. The handlers now skip anything tagged
from_outside: the node that did the write has already told the audience,
and a second fan-out would only duplicate it.
2026-08-26 09:22:49 -04:00
roar_zx ca25915a38 docs: link Dutch and Odia README translations (#3635) 2026-08-26 00:59:16 -07:00
Juan Castro ee2b5adcf9 Tell share recipients about creates and renames
A recipient's client keeps its cache fresh from fs events pushed over
their socket, and ShareService fans those out to holders — but only for
write, move and delete. A new entry emits fs.create.<flavor>, not
fs.write.file, and an in-place rename emits fs.rename; neither had a
listener, so a recipient watching a shared folder never learned that a
file appeared in it or was renamed. Part of why: those keys and
outer.gui.item.renamed were missing from the typed event map, so a
listener for them did not compile.

Delivering the event is only half of it. Paths were masked against the
entry itself, so item.added named a parent no cached listing was keyed
on, and the payload carried no dirpath, which is how the desktop finds
the container to render into — the event would have arrived and changed
nothing. Paths are now masked at the share the holder reached the entry
through, which is the address their own reads returned, and from_path on
a move and old_path on a rename travel the same way (dropped when the
move started outside the share, self-masked when the share is on the
entry itself, where the root already carries the new path).

Creates fire per entry, so an upload would have cost one share lookup
per file; they are coalesced by parent folder the way subtree deletes
already are. Measured on a 25-file burst into one folder: 25 lookups
before, 1 after. A holder with a share on both a folder and something
inside it is told once, by the nearer of the two.
2026-08-25 19:38:38 -04:00
Juan Castro 2ef5d00d9b Type is_shared and say who the flag counts
stat() and readdir() return FSItemRead, so the is_shared the docs lead
with typechecks for TypeScript consumers rather than erroring on FSItem.

The docs said "you have shared", but the query has no issuer predicate:
a manage delegate's re-share sets the owner's flag too, which is the
useful answer and matches getShares().
2026-08-25 18:15:15 -04:00
Juan Castro bac3dde945 Forget cached entries when sharing changes
Nothing invalidated the SDK entry cache on share or unshare — the socket
handlers only fire on item mutations — so is_shared, which now rides in
the cached entry, stayed stale for every consistency: 'eventual' read.
That includes the GUI's own listing refresh, which defaults to it, so a
badge would not have appeared until an unrelated write flushed the cache.
2026-08-25 18:15:05 -04:00
Juan Castro 1600dc38a5 Retire the share index row with the grant it records
revoke-user-user withdraws a grant without touching the share index, so
the row outlived the access — invisible until now, because listSharesOf
filters against live grants, but the new flag reads the index and would
report a file as shared to nobody, permanently.

Drop the row where the grant goes. The alternative, filtering liveness on
the read side, is the per-entry work the flag exists to avoid.
2026-08-25 18:14:52 -04:00
Juan Castro 0c8dd8b6e4 Document the share flag and returnShares 2026-08-25 17:45:15 -04:00
Juan Castro 3217533594 Give the puter.js API suites rate-limit headroom
Around 500 tests run through one seeded free-tier account, so the
per-tier windows were throttling the suite rather than anything it
tests — adding a single readdir was enough to trip fs:readdir-burst.
Resolve the seeded users to the unlimited policy, alongside the
subscription gate the harness already turns off for the same reason.
Test config only; no published limit changes.
2026-08-25 17:44:56 -04:00
Juan Castro 0591f13cc2 Expose is_shared and returnShares in puter.js
Carry the flag into the v1 entry shape and add returnShares to stat(),
whose shares are mapped into the same Share objects getShares() returns.
returnShares joins the dedupe key and bypasses the entry cache in both
directions — a result carrying share data is never written to it, so a
later plain stat cannot serve other people's names from cache.
2026-08-25 17:44:50 -04:00
Juan Castro ee2f14576b Report sharing in stat and readdir
isShared on /fs/stat and /fs/readdir, is_shared on their legacy
counterparts, and return_shares now fills the shares array the legacy
stat has been stubbing with []. Null for entries the caller does not own,
so a share recipient is never told who else can reach the owner's files.
The share-to-wire mapper moves out of ShareController so both controllers
publish one shape.
2026-08-25 17:44:41 -04:00
Juan Castro 5115fdd48c Answer which entries are shared in one indexed query
Directory listings need a shared/not-shared flag per entry. Ask the share
index once per batch, chunked, rather than a query per entry, and answer
only for entries the caller owns so listing a foreign directory never
queries at all. Direct shares only — inherited access stays with
listSharesOf, which already reports it.
2026-08-25 17:44:25 -04:00
jelvehandClaude Fable 5 e006b19773 puter.peer: room names, relayed guest grants, servers that come back
A peer server could only be reached by the invite code the signaller
minted for it, which is unknowable ahead of time and dies with the
server's socket. Any app that wanted a link to share in advance or reuse
had to run a registry of its own that maps a stable code to whatever
invite is live — and keep it live, since the signaller socket can drop
without the library saying a word (`onclose` was an empty function),
leaving a host serving an invite nobody can dial.

`serve({ name })` serves under a room name of the caller's choosing;
`connect(name)` dials it. A name is held by whoever serves it and free
once they stop; serving a held name from another identity rejects with
`name_in_use`, from the same identity it takes over. A connection to a
room nobody serves fails with `no_host`, so a lobby has a definite answer
to poll on instead of guessing between "not yet" and "gone".

`serve({ guestGrant })` and `server.setGuestGrant()` leave a guest grant
with the signaller, which hands it to every anonymous guest in the connect
reply; the connection redeems it before making its offer, so a guest with
no session gets relays without the app publishing the grant anywhere.

A server whose signaller socket drops now re-registers on its own with
backoff — under the same name, or a fresh code announced by the new
`reconnect` event — and pings the socket to keep idle proxies from cutting
it. It fires `close` (`replaced`, `name_in_use`) when it has to stand
down for good. Existing connections are peer-to-peer and never affected.

Signaller refusals reach the connection's `error` event as an Error with
a `code` next to the message. Keepalive replies and stray frames no longer
throw in the message handlers.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-25 13:24:45 -07:00
Juan Castro 07e73efa40 Merge remote-tracking branch 'origin/main' into juancastro/put-1568-rename-srcguisrchelpers-files-to-camelcase 2026-08-25 15:10:29 -04:00
404oops 2a45a10854 Merge pull request #3630 from HeyPuter/FK/dedupe
refactor: dedupe model catalog aliases and stop mutating shared catalogs
2026-08-25 18:07:27 +02:00
Nariman Jelveh e052aec7fb Carry a share link through the OIDC round trip (#3642)
A share email lands on `/?shared=…`, and its recipient is signed out or
in a temporary session more often than not. Password login reloads the
same URL, so the link came back with it — but an OIDC login leaves the
origin and returns to a URL the backend builds from a whitelist of bare
paths. `return_to` was a pathname only, and `/` wasn't on the list, so
the recipient came back signed in on the Dashboard's Apps tab with
nothing to say what had just been shared with them.

`shared` now travels with the path. The GUI sends it along with the
return path; the backend accepts it as the one parameter a `return_to`
may carry, and only values shaped like the masked share path the mail was
built from — everything else rejects the whole value, the path is still
matched as a raw string so a protocol-relative one can't smuggle an
origin through, and the query is rebuilt rather than reflected. The items
ride onto the error page too, so a failed attempt retries on the link
instead of losing it.

The root joins the whitelist for this, but only when it names something:
on its own it is where the flow already lands.
2026-08-25 08:42:47 -07:00
Daniel Salazar cf4d743854 fix: testing for peer (#3643) 2026-08-25 08:31:45 -07:00
Daniel Salazar 3c866c645b feat: guest turn for peer (#3640) 2026-08-25 07:51:02 -07:00
Juan Castro 4a5d689af7 refactor: rename GUI helper files to camelCase
src/gui/src/helpers was mostly snake_case, disagreeing with itself and
with the camelCase file convention in AGENTS.md. Rename the 45 offenders
with git mv and repoint every import specifier at the new paths.

Files only — exported symbols are unchanged. applyItemAddedToContainers.js
still exports apply_item_added_to_containers and no call site is edited
beyond its import line; those identifiers cross module boundaries and some
hang off window, so renaming them is a separate, riskier change.
2026-08-25 10:49:59 -04:00
jelveh 6f1548e26a Move Share above Download in file context menus 2026-08-25 07:28:53 -07:00
Daniel Salazar 764ce2f8e2 feat: rep score middleware gateway (#3639) 2026-08-25 07:11:41 -07:00
Daniel Salazar 717c098503 fix: metering buffer improvements for manual editing (#3633) 2026-08-24 18:36:43 -07:00
Nariman Jelveh 2c9a5c4f7e Land share-email links in the Dashboard's Shared view (#3634)
* Land share-email links in the Dashboard's Shared view

A share email's links opened the item on the desktop, and "Open Puter"
went to the bare origin — the recipient arrived on Home with nothing to
say what had just been shared. Every link now lands in the Dashboard's
Files tab, on Shared, with the shared items selected.

Each named file still links to itself. "Open Puter" carries every item
in the mail (?shared=a&shared=b), so the whole batch lands highlighted;
the digest records now keep each item's masked path for that. The
button's href is rendered raw like the item links, so `=` and `&` read
the same in the html and text parts, and the unsubscribe link is built
from a separate origin value.

On the GUI side `/?shared=` boots the dashboard rather than the desktop;
`/desktop?shared=` keeps opening the item in place. The Files tab
selects the rows by uid, which survives a rename. A share only reaches a
real account, so a share link never mints a temp user and an existing
temp session is asked to sign in first, as the desktop already did. The
address-bar cleanup both shells need moves into a helper.

* Keep the share email's button link within what mail clients tolerate

The "Open Puter" link names every item in the mail, capped at twenty. But
a single item's parameter is ~150 characters once the owner, uuid and an
encoded name are in it, so twenty of them run to several kilobytes — past
the ~2000 characters where older mail clients cut a URL off or stop making
it clickable — and the count alone couldn't do what its comment promised
for the mail's primary button.

Add a length budget beside the count: items go in, in digest order, only
while the whole link stays under it, so the button always works and the
first items are the ones highlighted. A single item always fits.

* Keep a share link's own item however long its name runs

`shareDeepLink` now builds through `sharedViewLink`, whose length budget
applied to the first item too: a parameter that alone overran it was
dropped, and the link came out as a bare `?shared=`. A name of a few
hundred characters — the GUI allows five hundred, and encoding triples
every non-ASCII one — was enough, so that item's own link in the mail
landed on Shared with nothing picked out, where it used to open the item.

The first item now always goes in; the budget only decides how many more
join it. One long link is still the item the mail is about, and it is no
worse than a bare origin for the clients that truncate it.
2026-08-24 17:01:55 -07:00
jelveh 34af8d18d0 Share a whole selection from the Dashboard's Files tab
Sharing was one item at a time: the only way to give someone four files
was to open the dialog four times. A multi-selection now offers Share in
its context menu and in the mobile selection bar, and UIShareModal takes
a list of items.

With several items the access list folds into one row per person rather
than repeating per file, so a grant, a mode change or a revoke is one
decision about a person. A row says what it can't otherwise show: how
much of the selection the person reaches ("On 2 of 4 items", with an
"Add to all" that extends them), a mode select that rests on a
placeholder when their grants disagree rather than presenting one item's
mode as the batch's, and inherited grants left uncontrollable where they
belong. Changing a mode touches only the items the person already holds
— nothing here widens access without saying so.

Requests are chunked to the documented 50-items-per-request cap, and a
selection past it skips the per-item listing instead of firing one
request per file on every refresh.

The eligibility rule the Share entry already used moves into a shared
can_share() helper, since the multi-select menu needs the same answer
for every row.
2026-08-24 14:08:37 -07:00
Juan Fernando Castro 0527bcd98e Merge pull request #3632 from HeyPuter/juancastro/put-1560-file-sharing-deeplink-in-emailnotifications
🔧 PUT-1560: Deep-link the shared item from email and notifications
2026-08-24 12:30:07 -04:00
Juan Castro ea530e604c Merge branch 'main' into juancastro/put-1560-file-sharing-deeplink-in-emailnotifications 2026-08-24 12:21:21 -04:00
Juan Fernando Castro 97d137b94b Merge pull request #3623 from HeyPuter/juancastro/put-1564-perms-docs-drop-the-app-root-dir-raw-permission-string-wire
🛠️ PUT-1564: Perms docs — drop the app-root-dir raw string, wire the playground examples
2026-08-24 10:59:56 -04:00
jelveh 1ceffbe291 Add sudoku to recommended apps
This change adds Sudoku to the curated recommended apps list so it appears alongside the other bundled games in the app recommendations flow.
2026-08-23 21:59:52 -07:00
Daniel Salazar 684d6752f9 fix: provide fallback for signup verification (#3631) 2026-08-23 21:49:35 -07:00
jelveh 76cea02513 Treat .md like .txt; remove markus suggestion
Merge the .md extension case into the plain-text branch so it suggests ['editor', 'code'] instead of ['markus', 'editor', 'code']. Update the related test to use 'viewer'/'png' as the built-in guard example since 'markus'/'md' no longer applies.
2026-08-23 21:17:28 -07:00
404oopsandClaude Fable 5 308b78e96c refactor: dedupe model catalog aliases and stop mutating shared catalogs
- Add modelLookupNames() and route every provider's list() through it so
  repeated or self-referential aliases can't double-list a model.
- Stop the chat/image/video drivers' #buildModelMap from writing through
  to the module-level catalog constants (ids/aliases accumulated across
  instantiations); bucket copies preserve the models() wire shape.
- Remove self-aliases and exact duplicates from the claude, deepseek,
  groq, mistral, and openai catalogs; fold the duplicated
  gemini-3.7-flash entry — this corrects the live cached-token price
  from 8 to 7.5 and the knowledge date to 2026-03 (the previously
  effective first entry carried stale values).
- ClaudeProvider: derive default max_tokens from the resolved entry
  instead of a second name-based lookup that capped dated ids at 4096.
- Add cross-provider catalog invariants (modelCatalogs.test.ts) with a
  disk-scan registration guard, plus regression tests for catalog
  non-mutation, alias-dedup routing, wire shape, and max_tokens ceilings.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-24 03:42:47 +02:00
nevrkla 2d787c3fdf feat(i18n): add Czech GUI localization (#3617)
* feat(i18n): add Czech GUI localization

* feat(i18n): add Czech GUI localization

* fix(i18n): polish Czech GUI translation
2026-08-23 17:22:44 -07:00
Daniel Salazar 5918e3f4e2 fix: kv max number value (#3629) 2026-08-23 06:31:08 -07:00
Nariman Jelveh 2c9e818e5f Docs/remove discord reddit links (#3628)
* docs: remove Discord and Reddit links

Drops both links from the docs README (header row and Support list) and
from the footer generated on every built docs page.

* docs: remove Mastodon link
2026-08-22 22:14:56 -07:00
Nariman Jelveh 8826a9fc51 Remove Discord and Reddit links from README (#3627)
Removed links to Discord and Reddit from the README.
2026-08-22 13:07:33 -07:00
Daniel Salazar 8307e8c9f0 chore: bump up deps (#3626) 2026-08-21 21:03:54 -07:00
Daniel Salazar 732786a029 fix: clean up email validation (#3622) 2026-08-21 20:27:05 -07:00
Juan Castro 202c43f46e feat(share): deep-link the shared item from email and notifications
A share email named the file but had nowhere to go: the only link was "Open
Puter", and finding what someone shared meant hunting for it under Shared. Each
named file in the digest now links to itself, and a notification covering a
single item points at that item.

The link carries one parameter, the masked path a recipient is already given:

    https://puter.com/?shared=%2Falice%2F<uuid>%2Freport.txt

Its second segment is the uuid, so there is no second copy of it to disagree
with the path, and the GUI can still find the entry when a rename has left the
name segment stale - it stats the path, then falls back to the uuid.

Built from the owner, uuid and name rather than from `ResolvedShare.path`.
That path is masked for whoever made the request, and the issuer owns the
entry, so it comes back as the owner's *real* path - mailing it would tell the
recipient which folders the owner keeps things in, which is the one thing
masking exists to prevent. A test asserts the real path never reaches the mail.

`digestLines` now returns `lead`/`items`/`trail` beside `what`, so the template
can put an anchor around each name while Handlebars keeps escaping the names
themselves; the URL is machine-built from the configured origin and one encoded
path, so it stays literal. Concatenating the parts reproduces `what` exactly,
which a test pins - the linked and sentence forms must not describe different
shares.

Notifications carry the masked path rather than a URL: the recipient is already
in the GUI, which opens the item in place instead of reloading. Only a
single-item notification gets a target; folding into a group drops it rather
than picking one of five.

In the GUI, `?shared=` joins `?download=` and `?app=` as a param that keeps the
desktop booting at `/`, and the handler reuses the `/@user` public-folder flow -
extracted to `open_path_target`, which carried a TODO asking for exactly this -
so a file opens in its associated app and a folder in an explorer window. The
param is stripped from the address bar first, so a reload lands on the desktop
rather than opening the item twice.

Invites are deliberately not linked: there is no account to route to yet, and
the invite's own call to action is to create one.

Rolling-deploy safe: a digest entry queued before this has `names` and no
`items`, and still flushes - without links. New entries write both, so a node
on the previous build can flush them too.
2026-08-21 16:08:39 -04:00
Juan Castro e7be457a56 docs: redirect the retired perms pages, add the missing playground examples
Both were left as follow-ups on the previous commit.

Thirteen `puter.perms` method pages went when the surface collapsed onto
`request(resource, details)`, and `requestAppData` moved to `/Perms/appData`.
All fourteen had shipped, so an external link or a bookmark landed on nothing.
The build already generates meta-refresh redirects from a map; they are entries
in it now. Pages that only ever existed on the branch that removed them are
deliberately absent — nobody can hold a link to a URL that was never published.

Six documented APIs annotated a code block with a playground id but had no
example file behind it, so the build warned on every one and the Try-it link
resolved to nothing: `fs.share`, `fs.unshare`, `fs.listShared`, `fs.getShares`,
`ai.txt2speech` with Speechify, and `ui.showFeedbackDialog`. The files are the
documented blocks themselves, extracted rather than rewritten, so the example
and the page it appears on cannot drift. Each is indexed in `examples.js` beside
its siblings.

The build now runs clean: 12 warnings to none.
2026-08-21 11:26:32 -04:00
Juan Castro 9bf49b7574 docs(perms): drop the app-root-dir raw string, wire the perms playground
`app-root-dir:` was listed among the raw permission strings, but it reads as
nothing during a permission check - the rewriter yields the sentinel outside a
grant, so `check()` reports it ungranted even once it is and `request()` prompts
every call. Both contradict what the same page promises. The `'appRootDir'`
resource is the supported path and asks the server instead; the list now says so
rather than implying the string behaves like its neighbours.

The playground listed no perms examples at all: six files existed but nothing
referenced them in examples.js, so a freshly-reshaped API shipped with nothing
runnable. Adds a Perms group after UI, matching the sidebar's order, and two
examples for the features that had none - one prompt covering several resources,
and check() deciding whether to prompt.
2026-08-21 10:20:05 -04:00
f0cd251626 🛠️ PUT-1521: Cleanup puter js permissions api + backend routes (#3607)
* refactor(puter-js): collapse puter.perms request* to resource + access

Fifteen request methods differed only by a folder name or an access level, so
every new resource meant another method. Replace them with requestFolder,
requestApps, requestSubdomains and requestAppRootDir, each taking the access
level as an argument.

The old names stay as @deprecated aliases: puter.js ships unpinned from
js.puter.com/v2, so removing them would break live apps. They remain in the
generated declarations because stripInternal has no effect on declarations
emitted from JavaScript, and hand-omitting them would break TypeScript callers
the runtime still serves.

Also drops the user-to-user and user-to-group grant wrappers (groups.js and the
grantUser/grantGroup half of grants.js) plus the req_ shim, none of which were
documented or called. The app, origin and dev-app grants stay: the dashboard
uses puter.perms.revokeApp() to clear grants on app uninstall.

* docs(perms): document the collapsed puter.perms surface

Replace the twelve one-method-per-task pages with requestFolder, requestApps
and requestSubdomains, and rewrite the Perms overview around the seven public
methods. The deprecated aliases keep working but are no longer documented.

Boy Scout: drops the long-dead commented-out grantUser/revokeOrigin sidebar
block for pages that were never published.

* refactor(perms): drop unused user-to-user and user-to-group permission routes

Filesystem access is shared through /share, which records the grant so the
owner can see and revoke it. The older direct-grant paths were left behind with
no caller anywhere - not the GUI, not a doc, not an app: grant-user-user
(already a 501 stub), revoke-user-user, grant/revoke-user-group, and the five
/group/* CRUD routes.

Removing them orphans PermissionService.grant/revokeUserGroupPermission and its
group-members cache bump, the three PermissionStore group writers, and six
GroupStore methods, so those go too.

What stays, and why:
- grant/revokeUserUserPermission - ACLService and ShareService power fs.share
  through them.
- The group permission read path (#scanUserGroup, readUserGroupPerms) - a
  migration seeds the admin group unrestricted driver access, so it is
  load-bearing.
- GroupStore getByUid/addUsers/removeUsers - signup, save_account, OIDC and the
  self-hosted default user assign group membership.

No schema change: user_to_user_permissions, user_to_group_permissions and their
audit tables are untouched. Group rows now come only from migrations, so tests
that need one seed it with SQL the way a migration does.

* refactor(perms): reduce GroupStore to membership writes

With the /group/* routes gone, `getByUid` had no production caller left — the
routes were the only thing that read a group back. Removing it takes the row
decoder and the GroupRow type with it, since they exist only to shape its
result.

What remains is `addUsers`/`removeUsers`: signup, save_account, OIDC and the
self-hosted admin bootstrap all assign group membership. Permissions attached to
a group are read through PermissionStore, which joins the junction table itself
and never needed the store.

Tests that wanted a group id now select it, which is all `getByUid` was doing
for them.

* chore(perms): drop the three by-hand groups no code reads

freeai, experimental and dangerous exist in prod but in no migration — they
were added by hand when hardcoded permissions were keyed by group name. That
map is now a flat per-user floor (`default_user_permissions`), so a group
nothing looks up grants nothing.

Guarded rather than unconditional, because both tables the delete can reach
cascade: dropping a group that still carries permissions or members would
silently revoke them from every member. Only a group with neither goes. One that
survives has dependents and needs a deliberate decision — query
user_to_group_permissions by group_id to see what it holds.

system, admin, user and temp are untouched: config names two of them and code
names the others.

Matches on `extra.name`, not `metadata.name` — `metadata` carries the display
title and colour, and `critical: true` is set on all of these including freeai,
so it does not discriminate.

* feat(puter-js): collapse puter.perms onto request(resource, details) + check()

One method per task meant a new method, doc page and sidebar entry for every
resource. `request` now takes the resource and a payload whose accepted fields
depend on it, and `check` answers the same question without prompting.

    request('folder', { name: 'Documents', access: 'write' })  -> path
    request('apps', { access: 'read' })                        -> boolean
    request('email')                                           -> address
    check('folder', { name: 'Documents', access: 'write' })     -> boolean

Returns stay per-resource: a folder gives its path, email the address, the rest
a boolean, and anything denied is falsy so one `if` covers both.

An array asks for several at once. Everything already held is settled first, so
the prompt covers only what is missing and does not appear when the whole set is
held - the user answers once for the lot. `check` answers per entry, in order,
so a caller can tell which parts are missing rather than only that some are.

Each resource declares four things in one registry entry: how to ask for it
alone, whether it is held, the strings a batch pools into a prompt, and the
value once held. The strings themselves are defined once in
lib/permissionStrings.js, so a request and its check cannot name them
differently. `check` is built on /auth/check-permissions, already live and
already used by UI.js, and it throws rather than answering false when the check
cannot run: a caller that cannot tell "denied" from "never ran" would prompt
someone who had already granted it.

Backward compatibility: all 22 older methods stay callable and typed, marked
@deprecated with the call that replaces them. A lone string still routes to the
raw-permission path - no resource name contains a `:` and every permission
string does, so the two forms cannot collide. The grant/revoke app methods are
untouched; the consent dialog and the dashboard's uninstall path use them.

Also drops three copies of the access-level assertion onto one shared
validator, and gives `appRootDir` a non-prompting server probe, since
`app-root-dir:` only resolves while a grant is being written and a permission
check on it always answers false.

* docs(perms): document request() and check() as the perms surface

Five per-method pages became one `request()` page carrying the resource table,
the batch form and the raw-string escape hatch, plus a `check()` page. The
overview is rewritten around the two methods.

requestAppData's page is re-homed as /Perms/appData rather than deleted - its
scope table, private-entry guidance and lifetime notes are not signature
documentation and have nowhere else to live. Inbound links from KV/set.md and
Objects/app.md follow it.

Playground examples move to the new call form. They are not wired into
examples.js, but an example demonstrating a deprecated method is worse than one
nobody loads.

* fix(perms): keep /auth/revoke-user-user as a deprecated route

Dropping this route with the rest of the unused user-to-user plumbing went too
far. The grant side is retired and stays retired - puter.fs.share() is the only
way in - but access those grants left behind has to remain withdrawable, and a
caller reaching the endpoint over HTTP directly had no replacement. Revoking can
only ever narrow what someone can reach, so keeping it carries no risk.

revokeUserUserPermission never left the permission service; it is load-bearing
for puter.fs.share(). This only re-wires the handler to it, with the gates it
always had.

Nothing in this repo calls the route, which makes it exactly what a later
cleanup reads as dead, so a test pins the registration and its gate alongside
the restored 400 and grant/revoke round-trip cases.

The 501 stub at grant-user-user and the never-called /group/* routes stay
deleted, as does puter.perms.revokeUser - puter.fs.unshare() replaces it and
falls back to live grants when no share row exists.

* fix(perms): let a write grant satisfy a read check on apps and subdomains

`apps-of-user:<uuid>:write` covers managing the user's apps, which includes
reading them, but nothing said so to the permission system. Prefix implication
only widens the other way — an `apps-of-user:<uuid>` grant covers both modes —
so a scan for `:read` missed a `:write` grant, and `puter.perms.check('apps')`
reported an app holding write as holding nothing. A batched request would then
prompt again for access already granted.

Adds the read-from-write exploder for both namespaces, mirroring
`fs-access-levels`. The widening runs one way only, and does not cross into
another user's namespace; both are covered by tests.

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

* fix(perms): answer the app-root-dir check without provisioning it

`/auth/request-app-root-dir` conflates two questions: may the caller claim its
root directory, and where is it. The second provisions `AppData/<uid>` on first
ask, so a caller that only wanted the first — `puter.perms.check('appRootDir')`
— created a directory by asking about it.

Adds `check: true`, which runs the same actor guard and stops at the answer.
A caller that may not claim it still gets the 403, so the flag can't widen
anything. Existing callers are unaffected: without it the route behaves exactly
as before.

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

* fix(perms): put request() and check() on one path

`request` dispatched to the old per-task methods while `check` asked the
permission tables, so the two answered different questions about the same
access. Concretely, before this: `request('folder', { access: 'write' })`,
`'apps'`, `'subdomains'`, `'appData'` and `'permission'` prompted every time,
whether or not the access was held — which the docs said they wouldn't;
`check('folder')` reported false for a folder the app could read through an ACL
grant that no `fs:` string names, so a batch prompted for it needlessly; a
batch entry for `'appRootDir'` skipped the post-grant retry the single call
does, resolving `undefined` after a grant that had in fact succeeded; and an
N-entry batch made N permission reads plus 2N `whoami` calls.

Both now run the same pipeline — resolve the permission strings, read what is
held once, prompt for the remainder, resolve each entry — with per-resource
hooks for the parts only that resource can answer. So a batch costs one
permission read and one `whoami`, a check reports exactly what a request would
skip the prompt for, and `'folder'` uses the same stat-or-permission reading in
both.

Also:

- A resource is looked up as an own property, so `request('constructor')` is
  the permission string it always was rather than a TypeError.
- A permission read that fails no longer decides anything: `request` falls
  through to the prompt it would have raised anyway, `check` throws. Before,
  `check('appRootDir')` folded a failed check into "not granted", which is what
  the documentation says must not happen.
- Drops `requestFolder`, `requestApps`, `requestSubdomains` and
  `requestAppRootDir`. They were added in this branch and immediately deprecated
  — never shipped, and `request()` no longer needs to route through them. The 22
  methods that did ship keep their exact behaviour, prompting without consulting
  what is held, which the suite now asserts alongside the new behaviour.
- Documents `'appRootDir'`, which was a supported resource in every overload and
  in `PermsResource` but named in none of the docs.

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

* fix(perms): act on review of the three preceding commits

- `src/puter-js/test/perms.test.js` still called `requestApps`,
  `requestFolder`, `requestSubdomains` and `requestAppRootDir`, which the
  previous commit removed. Four cases in the interactive browser harness threw.
  Pointed at `request(...)` instead.

- `request('appRootDir', …)` made two round trips where the shipped method
  makes one: a read-only probe, then the call that names the directory. A
  request is going to claim it either way, so the claim is now the check, and
  the entry it returns carries through to the result. `check` keeps the
  read-only mode, which is the reason that mode exists. Matters because the
  route sits on the FS_SIGN bucket, shared with signed-URL minting.

- `requestPermission` is one of the shipped methods, and the previous commit's
  message was wrong to say all 22 keep their exact behaviour: it forwards to
  `request`, so it now settles a permission the caller already holds instead of
  prompting for it. The value can differ, not just the prompt count — a user who
  would have clicked Deny on a re-prompt used to get `false`. It is the more
  honest answer (the app does hold the access, and denying a re-prompt never
  took it away), but it is a change, and the suite assertion had been switched
  to an unheld permission, which hid it. Asserted both ways instead, in the unit
  tests and the API suite.

- An entry that names no permission no longer rides a grant given for the other
  entries in the same call. Unreachable today — every resource either names one
  or reports itself held — but nothing pinned it.

- Reverted three type-union reformats in `LegacyFSController.ts` that a
  formatter had folded into the app-root-dir commit. That file was not
  prettier-clean to begin with; reformatting it is somebody else's change.

- Docs and types: `Perms.md`'s `appRootDir` row now matches `request.md`'s,
  `check.md` says that a `true` is per entry and a batch still prompts if any
  one entry is missing, and `types.js` no longer names `requestFolder` /
  `requestAppData` in prose that ships in the generated declarations.

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

---------

Co-authored-by: Daniel Salazar <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 23:48:08 -07:00
ZHOU7821 943adae622 docs: use absolute link to router page in workers create docs (#3615) 2026-08-21 13:30:30 +07:00
Daniel Salazar 65572dcf23 fix: nicer sms card fallback (#3621) 2026-08-20 23:12:09 -07:00
ZHOU7821andZHOU7821 b3923f86da docs: fix typo in workers description (#3614)
Co-authored-by: ZHOU7821 <[email protected]>
2026-08-21 12:26:20 +07:00
Nariman Jelveh 729114bd09 Prioritize app-center in recommendations
Reordered the recommended app list so app-center appears earlier and dev-center is kept at the end. This keeps the default app suggestions aligned with the expected homepage/app-center placement without changing behavior beyond ordering.
2026-08-20 19:16:03 -07:00
Nariman Jelveh da1c85d3c5 Add Meetings to recommended app defaults
Include `meetings` in `RECOMMENDED_APP_NAMES` so it appears in the default recommended apps set alongside other core productivity tools.
2026-08-20 17:16:39 -07:00
Nariman Jelveh 66410d7f1f Revert "Let a browser send the device fingerprint header it is offered"
This reverts commit fef1337b2b.
2026-08-20 17:10:55 -07:00
Nariman Jelveh 76bd05f228 Dashboard: let the trial be a detail line, not an alarm
The Usage card gives each of its three columns a meter and two lines. The
plan column had a boxed, tinted, bordered paragraph instead — and at the
~230px a third of that card comes to, thirty words of it wrapped into a
five-line ribbon standing twice as tall as the storage and resources meters
beside it. The box is the shape we use for the dunning banner. A trial is a
date you should know about, not a fault, and it was reading as the loudest
thing on the card.

So it takes the slot its neighbours use for "n% of 100 GB": a quiet hint-
coloured line under the badge. All three columns now measure the same.

The copy stopped repeating itself while it was there. The badge said "ends
Sep 20" and the sentence beneath it said "ends on September 20, 2026" — two
elements, one fact, and neither of them answering the question someone
actually has, which is how much room is left to decide. The badge now
carries "31 days left" and the line under it carries the consequence:
"First charge Sep 20 unless you cancel." Billing keeps the full sentence and
the opt-out, and Manage is directly below.
2026-08-20 16:59:07 -07:00
Nariman Jelveh fef1337b2b Let a browser send the device fingerprint header it is offered
`fingerprint.ts` reads the device fingerprint from a request body or, for
authenticated requests with no body to carry it, from
`x-puter-device-fingerprint`. That header is documented there as "the
header the GUI may send the device fingerprint on for non-signup
requests", and the middleware has always honoured it.

A browser could never actually send it. The CORS allowlist in
`#installCors` does not include it, so the preflight comes back without
it in `Access-Control-Allow-Headers`, the browser abandons the request,
and `fetch` rejects before anything reaches the server. Nothing in tree
sends the header today, which is why this went unnoticed: the first
client to try it sees every call fail with a network error rather than a
readable status.

Adds the header to the allowlist, and a preflight test asserting the
browser is allowed to ask for it.
2026-08-20 12:40:48 -07:00
404oops 58794f4f76 feat: add MetaProvider for Muse Spark models and integrate with ChatCompletionDriver (#3616)
- Implemented MetaProvider to interface with Meta's Muse Spark models.
- Integrated MetaProvider into ChatCompletionDriver, allowing for dynamic model selection.
- Added unit and integration tests for MetaProvider to ensure functionality and reliability.
- Updated documentation to include Meta as a supported vendor for AI models.
2026-08-20 15:09:56 -04:00
Nariman Jelveh 9809e428b5 Update shared label in email template
Adjust the share-notification email copy to reference the current "Shared" files section instead of "Shared with me," in both HTML and plain-text variants. This keeps onboarding messaging aligned with the UI.
2026-08-19 20:05:56 -07:00
Nariman Jelveh eaddd1d44e redesign: share notification emails (#3612)
* redesign: share notification emails

The share digests were an unstyled fragment: a 520px div with a couple
of paragraphs, no preview line, no small-screen or dark-mode handling,
and an html-only body that left every text-only client reading a machine
down-conversion of the markup.

Both digests (`file_shared_with_you` and `file_shared_invite`) now render
through one layout: a 600px table column that goes full width under
600px, every color written inline so a client that drops `<style>` still
gets the design, with the stylesheet carrying only what inline CSS can't
express — the small-screen and dark-mode overrides. The shares are a
tinted panel of hairline-separated rows rather than loose text, the call
to action is a cell-padded button Outlook can draw, and the reason for
receiving plus the unsubscribe sit below the card where they belong. No
images at all, so nothing depends on an asset a client may refuse.

Templates may now carry a `text` part, and both digests do: it goes out
beside the HTML as multipart/alternative, which is what plain-text
clients, screen readers and spam scoring prefer.

Copy: a preview line that adds to the subject instead of repeating it, a
heading before the list, and an honest unsubscribe label — the link
silences notification mail account-wide, not just shares. The invite now
says what to do with the address and that an existing account can claim
the shares by confirming it.

Also drops `share_by_username` and `share_by_email`, unreferenced since
the digests replaced them.

* fix: widen the template map to the interface before compiling

`Object.entries` over the literal map yields a union with a distinct
type per template, and only the share digests carry a `text` part —
reading it off the union is an error the build tsconfig hides
(`noCheck: true`) and `npm run typecheck` catches.
2026-08-19 18:49:11 -07:00
Reynaldi Chernando 53b792c52c connect to worker's kv with cli (#3613) 2026-08-20 08:36:46 +07:00
Daniel Salazar 2dd7073034 fix: vscode webdav (#3610) 2026-08-19 14:04:56 -07:00
Daniel Salazar a552ce0b87 fix: better metrics for cache hit rates (#3609)
* fix: better metrics for cache hit rates

* chore: type + docs
2026-08-19 11:38:14 -07:00
Neal Shah c04827fd3f add dynamic worker create event (#3608) 2026-08-19 14:20:15 -04:00
Daniel Salazar 9debdffc5d fix: email send frequency + ai ordering (#3604) 2026-08-18 18:13:55 -07:00
Nariman JelvehandJuan Castro 57b4cbab66 feat(dashboard): rewrite Files-tab sharing as a from-scratch modal (#3598)
* feat(dashboard): rewrite Files-tab sharing as a from-scratch modal

The Files tab's Share… action opened UIWindowShare, a desktop UIWindow,
which doesn't fit the dashboard. Replace it with UIShareModal, built on
the same overlay pattern as UIItemPropertiesModal: a centered card on
desktop, a bottom sheet on mobile, styled with the dashboard's design
tokens (dark mode included).

Wiring follows the onShowProperties precedent: generate_file_context_menu
gains an optional onShare hook that TabFiles supplies; every other caller
falls back to the desktop share window unchanged.

Feature parity with UIWindowShare — grant by email/username with an
access level, list who has access (owner, direct, and inherited-from-
ancestor grants), change a grant's mode, revoke — plus:

- revoke confirmation inlined into the row instead of a stacked UIAlert
- real form semantics: Enter submits, button disabled until input,
  spinner while in flight, API errors keep the typed name for correction
- dialog a11y: focus moves in on open and back on close, Tab is trapped,
  Escape closes (first Escape cancels an open revoke confirmation),
  status messages announce via aria-live
- backdrop close keyed on where the press started, so a text-selection
  drag out of the input can't dismiss the dialog
- name-derived avatar colors and a loading state for the first fetch

Also fixes a double-encoding bug (shared by UIWindowShare) where mode
labels ran through html_encode twice, rendering "Can edit &amp; share".

* fix(dashboard): keep keyboard focus inside the share modal across re-renders

Changing a grant's mode, revoking, or Escape-canceling the inline
confirmation re-renders the access list (or disables the focused
control), which dropped focus onto <body> - outside the dialog's Tab
trap and invisible to screen readers. Focus is now restored explicitly
after each of those actions: to the same holder's control when it
survives, else to the dialog itself (tabindex=-1), and the Tab trap
steps back into the cycle when focus rests on the dialog container.
A failed grant likewise returns focus to the recipient input.

* fix(dashboard): give share modal controls 44px touch targets

On touch devices the close (32x32) and revoke (30x30) buttons and the
26px-tall per-row mode select were below the 44px minimum. On coarse
pointers the icon buttons now hit-test at 44x44 through a centered
pseudo-element (visuals unchanged, and the row spacing absorbs the
overhang without overlapping neighbors), and the row select gets
taller padding. Fine-pointer rendering is untouched.

* fix(dashboard): clear the iOS home indicator in the share bottom sheet

The app opts into viewport-fit=cover, so on notched phones the
bottom-docked sheet extends to the physical screen edge and its last
row sat under the home indicator. The sheet body's bottom padding now
adds env(safe-area-inset-bottom), matching how the codebase pads other
bottom-docked surfaces.

* fix(share): say 'Updated access' when changing a grant's mode

Changing someone's access level reported 'Shared with X', the same
message as a fresh grant, which reads as if a new share happened.
Both share dialogs now confirm mode changes with a dedicated
'Updated access for X' message. The UIWindowShare call also drops the
html_encode() around the recipient - i18n() already encodes
interpolations, so the wrapper double-encoded.

* fix(dashboard): align the share dialog's accessible name with the properties modal

The dialog's aria-label read 'name - Share' with an em dash while the
sibling properties modal uses plain 'name Properties'; screen readers
announce the dash as noise. Use the same name-then-noun pattern.

* fix(dashboard): ellipsize the share input's placeholder when it overflows

At phone widths the 'Add people by email or username' placeholder was
clipped mid-letter; text-overflow: ellipsis truncates it cleanly.

* fix(dashboard): raise the share selects' chevron contrast in light mode

The hardcoded slate-400 stroke measured ~2.6:1 against light surfaces,
below the 3:1 minimum for non-text indicators. Light mode now uses
slate-500 (~4.7:1); dark mode keeps slate-400, which already clears
5:1 there.

* fix(dashboard): stop Files-tab shortcuts from firing behind the share modal

The document-level keydown.tabfiles handler kept running while the
share (or item-properties) modal was open. With focus on any of the
modal's buttons or selects, Enter and Space were preventDefault-ed
before they could activate the control, arrows could not drive the
mode selects, and letter typeahead was hijacked into row typesearch —
while Enter opened the selected row behind the overlay, Delete moved
it to Trash, and Cmd+A/C/X/V acted on the hidden list. Yield the
keyboard to the modal for as long as one is up; its own handlers
already cover Escape and Tab.

* fix(dashboard): keep the on-screen keyboard down when the share sheet opens

Autofocusing the recipient input popped the keyboard over the bottom
sheet the moment it opened, hiding the access list before the user had
chosen what to do — the same reason the revoke flow already focuses
the dialog instead of the input. On touch-primary devices give the
dialog container initial focus (which also anchors the Tab trap);
desktop keeps the input autofocus.

* fix(dashboard): give the share sheet's confirm and submit buttons 44px touch height

The inline revoke confirmation's Cancel/Remove pair rendered ~29px
tall on touch — small targets 6px apart where one of the two is
destructive — and the submit button was fixed at 38px. Grow both to
44px under pointer: coarse, matching the standard the modal's icon
buttons already meet. The touch block moves below the confirm-button
base rules it now overrides, since a media query adds no specificity
and source order decides.

* fix(dashboard): name the person in the share rows' accessible labels

Every grant row's mode select announced as bare 'Access level' and
every revoke button as 'Remove access', so a screen reader user
tabbing the list could not tell whose grant a control changes. Carry
the holder in the aria-label ('Access level for alice' / 'Remove
access for alice'); the visible UI and the revoke tooltip stay as
they were. i18n() encodes the interpolated string as a whole, quotes
included, so the labels stay attribute-safe for any holder name.

* fix(dashboard): keep the share status region in the accessibility tree

* fix(dashboard): give the share modal's add-row controls 44px touch height

* refactor(gui): extract the share dialogs' pure logic into tested modules

* feat(dashboard): adopt the shared mode helpers and handle pending invitations

---------

Co-authored-by: Juan Castro <[email protected]>
2026-08-18 17:34:40 -07:00
Juan Fernando Castro 5e1645d40e 🔧 PUT-1524: Sanity check file sharing api (#3603)
* fix: sharing answers "who can reach this" from the grants, not the index

Two ways a share listing could name access that was no longer there.

A recipient's listing paired the flat permission read with the list of
permissions it asked for by array index. That read drops misses and
dedupes its keys, so the two are not positional: one entry's live grant
vouched for another entry that had none, and listShared() kept
publishing a withdrawn item's name, size and signed thumbnail URL to
someone who could no longer open it. Read the permission off the value
instead.

getShares() had the same gap from the owner's side, with no liveness
check at all — a grant withdrawn through /auth/revoke-user-user or an
ACL mode change left the index row behind, and the owner was told
someone could reach a file they could not. Checked against the grants
now, one batched read per distinct holder. Pending invites are not
subject to it: they have no grant yet, which is the point of them.

Also covers the access-token actor, which reaches the same reach bound
as an app through a different arm of the ACL check. No behavior change
there — it was correct and untested.

* test: pin that a revoke crosses regions

A revoke reaches a peer region as a replicated SQL delete plus three
invalidation events, one per cache the region owns: the u2u row cache,
the flat view and the scan generation. The delete alone changes nothing
there, and each cache has a different consequence if its event is lost
— 20s for the generation, 5 minutes for the row cache, and forever for
the flat view, whose grant-path entries carry no expiry.

Nothing covered the whole path end to end. The store tests prove each
event is emitted and applied; this proves the result, which is that the
recipient stops being able to read. It asserts the read still succeeds
after the SQL delete alone, so the test also records why replication is
not sufficient on its own.

* test: pin that a leaked uuid buys no access

A masked share path hides which folder an item sits in; it was never the
thing deciding who may open it. Nothing checked that at the route level,
so the guarantee rested on unit tests of the resolver alone.

Reads one shared file through its masked path, then tries the sibling
four ways: the shared uuid with the sibling's name, the sibling's own
uuid, a `..` back out of the root, and the owner's real path.

Worth knowing about this one: it's mutation-checked. Removing the head !== root.name guard in sharePathMask.ts fails it with reachable: /testuser/55dd54c0…/share-http-1df8933e.txt. I verified that specifically because two tests I wrote earlier in this chunk passed with their guards broken — both were vacuous, and I deleted them rather than commit false assurance.

* docs: say what listShared's total actually counts

`total` counts the shares recorded for you; items are filtered after the
page is read, so a withdrawn grant leaves the count higher than anything
paging will yield. The page description already explained the short-page
behaviour, but the field read as an exact count and the example printed
it as one.

The test pins the gap it describes: two shares, one withdrawn outside
the index, one item listed and a total of two.

* fix: keep an undelivered broadcast event instead of dropping it

The outbound queue was cleared before the send, so a peer that timed out
took its events with it. Most were survivable — a lost cache invalidation
heals when the entry's TTL lapses. A revoke's flat-perm invalidation is
not: grant-path entries carry no expiry, so a peer went on serving a
withdrawn grant until something else wrote that key.

Failed sends now go back on the queue, which the existing flush timer
retries. Anything queued since wins over the retry, and the queue is
bounded at 10,000 with the oldest dropped first, so a peer that stays
down cannot grow it without limit. Each retry is signed at send time, so
it is not rejected against the replay window.

* fix: stop plus-addressing from deciding who a share reaches
2026-08-18 15:51:23 -07:00
b795b219a8 ✨ PUT-1497: Share file link sharing and notifications (#3595)
* refactor(share): move share notifications into their own service

* feat(share): invite an address with no account, and email it

* feat(share): surface pending invites in the SDK and share dialog

* fix(share): unreachable revoke confirmation, and double-encoded labels

* feat(share): budget share announcements, group them, and let people block senders

Sharing had one defence against noise: a 15-minute quiet window per (sender,
recipient) pair, which dropped the second share rather than folding it in.
Twenty senders each under their own window could still bury someone, and there
was no way to make one of them stop.

Announcements are now budgeted on two axes through the existing sliding-window
limiter: 1 per 15 minutes and 20 per day from one sender, and 10 per hour /
50 per day to one recipient from anyone. Over budget the share still succeeds
and the recipient's notification is still brought up to date — only the
interruption is dropped. Invite email to an address with no account is budgeted
the same way, keyed on a hash of the canonical address.

Notifications now fold across senders: a new share rewrites the notification
the recipient hasn't dismissed, so "alice and bob shared 5 items with you"
replaces a stack of five. The record is written even when suppressed, so the
count is right whenever they next look.

Blocking is a new `user_block` table with enforcement in ShareService: a blocked
sender's share is refused with `recipient_not_accepting_shares`, spends no
quota, and writes no row, and their unclaimed invite is dropped when the address
is confirmed. Existing access is untouched — that is what revoke is for.
Managed from a Blocked people card in the dashboard's Security tab.

Also publishes the sharing limits, including the ones already on this branch
that were never documented.

* fix(share): name the item in share email, instead of 'an item'

* fix(share): make the invite lifecycle canonical, authorized, and race-safe

* refactor(email): drop EmailClient.isConfigured; callers read config.email

* feat(share): batch share email into a per-recipient digest, durably

* docs(share): document the share error codes; steady the disk migration tests

* fix(share): log why a digest wasn't sent, and recover orphaned ones

* feat(share): email recipients about shares by default, with a way to decline

Share email was off unless a deployment opted in, which meant an account
holder was told about a share in the app only. It is now on unless
`share_email_notifications` is set to false.

The reason it defaulted off was that nobody could decline. So this also
honors `user.unsubscribed` — the account-wide opt-out the /unsubscribe page
already writes and app feedback already respects, which share email ignored —
and the digest carries that link. Sharing and the in-app notification are
unaffected by it; only the mail stops.

The link is composed in the template around an interpolated uuid rather than
passed pre-built: Handlebars escapes interpolated values, so a whole URL came
out as `user_uuid&#x3D;…`, which browsers decode but link scanners and older
mail clients need not.

* fix(share): count every shared file in the digest, not just the first

* feat(share): let a recipient refuse shares from everyone

Blocking answered "not from this person" but had no answer to "not from
anyone", so the only way to stop a stream of unwanted shares was to name
each sender after they had already reached you.

Stored as a key in the user row's existing `metadata` blob rather than a
column: the share path already holds the recipient's row by the time it
asks, so reading it costs nothing, and a one-bit preference doesn't earn
a migration per dialect. `updateMetadata` merges and refreshes the cached
row, so the switch bites on the very next share.

Refusing everyone reports the same code as refusing one person — which of
the two it is is the recipient's business, not the sender's. Enforced at
both moments the per-sender block is: when the share is issued, and when
a pending invite is claimed. The per-sender list is untouched while the
blanket switch is on, so turning it off restores what it hid.

`GET /share/blocks` now carries `all`; `POST`/`DELETE` take `{ all: true }`
beside the existing `{ username }`. Managed from the same Blocked people
card in the dashboard's Security tab.

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

* fix(share): keep the digest sweep off a window that still has a timer

The sweep treated an entry as orphaned the moment its window closed, which
is also the moment the node that armed it fires. Claiming an entry is only
exclusive among flushers that can see each other's deletes, so the two
could each claim a share of the same digest and both send. It now waits
out a grace period first, which costs a genuinely stranded digest that
much delay and nothing else.

Both digest listings were capped at 200 with no word when they hit it — a
truncated flush sends a digest that undercounts and reads as complete.
The cap is named and logged.

Also: `#emailHolder` still described share email as off by default, which
it stopped being; the config doc said the batch window defaults to 60s
when it is 90; and the two tests that need several calls inside one window
were racing a 50ms window across four sequential round trips, so they
failed under full-suite load rather than on the behaviour they cover.

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

* fix(share): stop double-encoding the recipient in two dialog messages

`i18n()` encodes what it returns, replacements included, so encoding the
recipient first showed the entities to anyone whose address or username
contains one. Same pattern already fixed two lines above.

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

* fix(notification): widen the mysql shown/acknowledged columns

Both arrived from the v1 schema as `tinyint(1)`, where they were flags.
The backend rework changed the writes to a unix second; sqlite (`INTEGER`)
and postgres (`bigint`) took it and mysql did not, so on mysql every
`markShown` and `markAcknowledged` has failed with
ER_WARN_DATA_OUT_OF_RANGE and left the column NULL. Dismissing a
notification never stuck — the unacknowledged count never moved and one
already delivered came back on every reconnect.

No backfill: every reader tests `IS NULL` / `IS NOT NULL` only, so a
legacy `1` keeps meaning "yes" once widened. Guarded on the current type,
because changing a column type copies the table and this directory
replays on every boot.

Not reachable from the test suite — it runs against sqlite and postgres,
both of which already have the right type. Verified by hand against mysql:
`/notif/mark-read` and `/notif/mark-ack` now persist, and a dismissed
share notification is no longer the one a later share folds into.

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

---------

Co-authored-by: Daniel Salazar <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-18 09:36:33 -07:00
Reynaldi Chernando 401b6066b3 Use gpt 5.6 luna as default for docs (#3602) 2026-08-18 09:36:23 -07:00
Daniel Salazar ec3c33fea6 fix: misc hardening fixes (#3600) 2026-08-18 00:06:58 -07:00
Anany Singh 95d797b047 feat(workers): expose app_uid in puter.workers.get and puter.workers.list (#3596) 2026-08-18 00:05:17 -07:00
Daniel Salazar bd06e88185 feat: bring back referrals PUT-1463 (#3599) 2026-08-17 23:49:09 -07:00
Nariman Jelveh cabedc30b0 Require login on app landing pages
Prevent first-time visitors from being treated as temporary users when they open an app landing page such as `/app/<name>` or `/desktop/app/<name>`. These routes now always go through the real login/signup flow.
2026-08-17 20:11:29 -07:00
Daniel Salazar f81b76c1d1 fix: webdav connectivity fixes for macos (#3597) 2026-08-17 19:02:55 -07:00
404oops 9604cc3f23 feat(ai-chat): overhaul provider model catalogs to match current APIs (#3586) 2026-08-17 14:06:44 -04:00
2c852bf6b3 ✨ PUT-1412 File sharing backend api (#3553)
* refactor(permissions): drop hardcoded group permission map for a flat default

* test(drivers): assert credential-gate intent instead of a 403 proxy

* fix(permissions): report whether a revoke removed anything and persist the linked grant row before the flat view

* feat(permissions): replicate permission invalidations across regions

* feat(share): extend the share table into an index of active shares

* feat(share): query and maintain active shares in ShareStore

* feat(users): add a batched lookup by email

* fix(cache): apply cache updates broadcast from peer regions

* fix(permissions): scope a revoke to the issuer that granted it

* feat(share): add ShareService with a per-day share limit

A share is two writes that belong together: the permission grant, which
authorizes access, and a share row, which makes it listable and ties it to an
fsentry so it dies with the file. Nothing else grants fs:* to a user.

Authorization reuses canManagePermission — an owner satisfies it through the
is-owner implicator, a delegate through an explicit manage:fs:<uid> grant. An
owner may clear any issuer's share of their node; anyone else only the ones
they issued, or their own access. Self-revoke skips the manage gate but still
requires `see`, so it cannot be used to probe for files.

The per-day limit counts shares created rather than live rows, so revoking and
re-sharing cannot recycle a slot, and changing an existing share's mode is not
new reach and does not spend budget. Tunable via share_daily_limit.

* feat(share): expose sharing over HTTP

POST /share, POST /share/revoke, GET /share/shared-with-me, GET /share/shares.
The controller was registered but entirely commented out.

Recipients × items fan out concurrently — every pair is a distinct
(holder, entry) key, so none of them contend — bounded by
runWithConcurrencyLimitSettled, which returns results index-aligned with the
input for the per-pair outcome list. Responses carry usernames only, never
internal ids, and the 404-not-403 rule is preserved so a failed call cannot
confirm a file the caller could not otherwise see. Notifications are fired off
the response path; a share must not fail over its own notification.

Per-request caps on recipients and items bound one call's fan-out; the daily
limit bounds the total.

* feat(share): keep recipients consistent when a shared item changes

* fix(fs): stop listing issuer homes at the filesystem root

* fix(acl): serialize concurrent mode changes on one node and pin app containment on shared paths

* fix(fs): expire signed URLs over entries the signer doesn't own

signFile defaults to a ~317k-year TTL and verifySignature checks only uid,
expires and signature — never the ACL. A recipient who ever signed a shared
file therefore held a permanent, revocation-proof URL to its bytes: revoking
the share did nothing to it.

signEntry now takes the acting user and drops to NON_OWNER_SIGNATURE_TTL_SECONDS
(1 hour) when the signer is not the entry's owner. Owners keep the permanent
default, so no existing client changes behavior.

The signature-authenticated directory listing bounds its children
unconditionally: that route has no session actor, and a signature proves
possession rather than ownership, so a recipient holding a short-lived
directory signature could otherwise mint permanent URLs for every child.

A bounded window is not revocation — the durable fix is a per-entry signature
epoch folded into the HMAC and bumped on any permission change.

* refactor(permissions): drop the unused permission-issuer lookup

listUserPermissionIssuers and its store method listUserPermissionIssuerIds
existed to synthesize the filesystem root from the home directories of everyone
who had granted the caller a permission. That listing is gone — it advertised
folders readdir then refused to open — and the share index answers "who shared
with me" directly, so nothing wants them back.

One removed test only asserted that the call returned an array; the other
covered readLinkedUserUserPerms round-tripping and is kept, rewritten without
the issuer lookup.

* fix(share): return the created share, not just an acknowledgement

* feat(puter.js): add file sharing to puter.fs

share(), unshare(), listShared() and getShares() on puter.fs, following the
existing FS operation shape: positional and options-object forms through
defineOperation, JSDoc overloads as the published signature, relative paths
resolved against the app's root directory.

A bare recipient string is read as an email when it contains @ and as a
username otherwise. Sharing an item with someone who already has it replaces
their access rather than stacking a second grant, so raising read to write is
one more call.

Adds a sharing suite to the API runner, which passes unchanged on node,
browser and workerd. Documents all four methods with runnable examples, and
corrects the FS overview callout that told readers one user cannot read
another's files — true before this, not after.

* feat(gui): add a Shared folder for items others shared with you

A sidebar entry listing everything other users have shared with you, backed by
puter.fs.listShared().

The path is the sentinel `puter://shared` rather than /<user>/Shared: this is a
query, not a directory, and a path-shaped value could collide with a folder
someone actually creates. refresh_item_container and update_window_path both
branch on it to skip the stat there is no fsentry for, and the listing swaps
readdir for listShared.

Entries render at their real paths under their owners' directories — the item
container already preferred an explicit fsentry.path over joining onto the
container, so nothing else had to change. Each carries who shared it and at
what level, which the context menu reads next.

* feat(gui): share items from the context menu

A sharing dialog shaped like its neighbours — options object, HTML-string
template, jQuery wiring, delegating to UIWindow() — with a recipient field, a
read/edit/share dropdown, and the current access list with revoke buttons.

Reached from a new "Share…" context menu entry, which is hidden on items shared
*with* you: re-sharing needs manage, so the dialog would only surface an error.

Those items get "Remove from Shared" in place of Delete. Delete moves an item
to *your* trash, which for someone else's file means moving their data out of
their tree — FSService refuses it, and the user saw a bare 403. Removing your
own access is what the action was reaching for, so that is what it now does.

* fix(share): withdraw what a removed recipient re-shared

* feat(share): report access inherited from a parent folder

* fix(gui): load the puter.js bundle the server configured

* refactor(gui): extract the action icon set into a helper

* feat(gui): surface Shared in the file browser

* feat(gui): manage access from the share dialog

* test(share): cover access inherited from a parent folder

* fix(share): keep downstream access from surviving a delegate who leaves

* fix(gui): name the real owner in the share dialog

* fix(gui): page through every shared item instead of the first 50

* feat(share): return item metadata with a share

* fix(share): invalidate a holder's cache when the entry is deleted

* fix(gui): treat items inside a shared folder as someone else's

* feat(permissions): let manage inherit down the filesystem tree

Access already reached descendants through the ancestor chain while authority did not, so someone trusted to manage a shared folder could re-share the folder but nothing inside it, and could not see who had access to a file within it.

A manage-inherits-from-ancestor implicator resolves it in the permission layer, beside is-owner, so every caller agrees rather than just ShareService. It consults only the immediate parent — resolving that re-enters one level up, making a chain of depth d cost d checks rather than d².

That makes two cascade gaps reachable, both fixed here. A revoke now walks the subtree, since a grant on a descendant can rest on authority held at the folder. And it stops at a delegate whose authority survives another issuer, because what they granted was never theirs to lose.

Also pins that manage is not transitive: granting it needs manage:manage:fs:<uid>, which only the owner holds, so delegation is one level deep by construction.

* fix(gui): offer sharing inside a folder you manage

The menus encoded "manage does not inherit" and would now hide an action that works. The Shared listing records each root's mode; the menus resolve a child's by longest matching ancestor, loading on demand so a deep link or restored window works too.

* fix(share): make the daily share limit hold under concurrency

* test(share): cover concurrency, measure cost, and name cases for what they verify

* fix(gui): import the ownership helpers the item menu calls

The single-item context menu handler calls is_owned_by_me and
shared_mode_for, but the imports were only ever added to
generate_file_context_menu.js — so every right-click on an item threw a
ReferenceError before the menu could build, and the non-owner Delete
gating never ran.

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

* fix(share): authorize before resolving the recipient

share() looked up the recipient in parallel with the entry, before the
manage check — and the two failures carried different error codes. Any
verified user with a real entry uid could probe arbitrary emails and
usernames for account existence, at no quota cost. Resolve the entry,
authorize, and only then resolve the recipient: an unauthorized caller
now sees the identical safe 404 whether or not the recipient exists.

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

* fix(permissions): broadcast permission row-cache invalidations to peer regions

Every publishCacheKeys call for the u2u, u2a, and access-token row
caches omitted broadcast, so a revoke only cleared the mutating
region's Redis. A peer region applied the replicated generation bump,
re-scanned, read the deleted row from its own still-warm 5-minute row
cache, and re-warmed the flat view from it — revoked access outlived
the revoke by the row-cache TTL instead of the intended 60-second
bound. CacheReplicationService already consumes these events; the
emits were just never sent.

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

* fix(fs): refuse to rename an entry owned by another user

remove and move both refuse to act on an entry the caller does not
own, even when the ACL allows the write — rename had no such guard, so
a write-mode share recipient could rename the owner's file, or the
shared folder itself, rewriting the owner's whole subtree's paths.
rename now takes the acting user and applies the same policy.

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

* fix(permissions): decide a flat delete from the primary, not a lagging replica

revokeUserUserPermission deletes the SQL grant, then only drops the
flat KV entry once no issuer still grants the permission. That
remaining-check read through the row cache the delete had just
invalidated, straight to a replica — under any lag the deleted row
reappeared, the flat delete was skipped, and the stale rows were
re-cached for another five minutes. Grant-path flat entries carry no
TTL, so the holder kept working access with zero SQL rows behind it,
invisible to every listing. The check now reads the primary and
re-warms the cache with what it actually saw.

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

* fix(permissions): keep a failed remote flat-invalidation from crashing the process

The outer.permission.flatInvalidated applier was fire-and-forget with
no catch, and it awaits a KV delete — one transient KV error while
applying a peer region's revoke became an unhandled rejection, which
is process-fatal under default Node. Its sibling appliers were already
guarded; this one now logs and moves on, leaving the entry to the next
invalidation or its TTL, same as a lost event.

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

* fix(share): revoke every requested item and recipient, not just the first

revokeShare destructured only the first recipient and first item while
the parsers accept arrays up to the request caps — unshare({items:
[a, b, c]}) returned success having revoked only a, leaving access the
caller believes is gone. Revoke now fans out over every (recipient,
item) pair exactly like POST /share, reports per-pair outcomes, and
sums the revoked count; the response stays backward compatible.

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

* fix(share): only a confirmed email designates a recipient

Recipient resolution by email accepted unconfirmed accounts, so
pre-registering someone else's address (unconfirmed) was enough to
receive shares meant for them once no confirmed account held it.
An email now only resolves to an account that has confirmed it;
username shares are unaffected.

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

* fix(share): accept tilde-rooted paths like the FS routes do

The SDK resolves relative paths to ~/..., but the share routes never
expanded the tilde — a ~-prefixed string was read as a uid and every
relative-path call 404'd. Item parsing now treats ~ as path-shaped and
expands it to the actor's home with the same helper the legacy FS
routes use, on share, revoke, and the shares listing.

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

* fix(share): walk a directory revoke by parent linkage, not path prefix

listByFsentrySubtree matched descendants with fsentry_id = ? OR path
LIKE ?, which has two problems: fsentries.path is lazily backfilled
and NULL on old rows, so those descendants' shares silently survived a
directory revoke, and the OR'd predicates forced a scan of every
active share. A recursive CTE over parent_id — the same shape the
lineage resolver already uses — covers every descendant and runs on
idx_parentId_name.

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

* fix(gui): give each item its own share dialog

single_instance keyed the dialog on the app id alone, so opening
Share… on a second file focused the first file's dialog — typing a
recipient there granted access to the wrong file, with only the title
hinting at it. The dialog is now instanced per path: same item
refocuses, different item opens fresh. Also stops pre-encoding the
title, which UIWindow encodes again.

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

* feat(permissions): let manage answer a write check

A manage grant let its holder re-share a folder but not work in it: the
ACL mode family stops at write, and the fs exploder had no rule for the
narrowest mode, so `manage:fs:<uid>` never satisfied `fs:<uid>:write`.

Fold manage into the candidate list for every non-manage mode, in both
the access-token branch and the scan branch, and give `write` an (empty)
exploder rule so the manage arm is emitted for it too.

* fix(fs): authorize restructuring by write on the parent

* fix(fs): let a share recipient work inside a shared folder

rename, remove and move refused outright when the entry belonged to
someone else, so a recipient with write could neither delete nor rename
anything inside a folder shared with them. The GUI compounded it by
hiding Delete for any item it did not own.

Authorize the three by ACL write on the entry's parent. For an owner
that is the same answer; for a recipient it grants the inside of a
shared folder and withholds the folder itself, whose parent is the
owner's private tree.

Deleting sends the item to its owner's trash rather than the deleter's,
so it leaves the recipient's view without leaving the owner's account
and without changing hands. A move may not otherwise carry someone
else's entry out of their tree.

* fix(fs): give a new entry to the owner of the folder it lands in

A file a share recipient added to a shared folder was recorded as
theirs while living in the owner's tree, so a subtree could hold rows
belonging to several people — and the storage it consumed was checked
against the writer while being counted against the owner.

Take the owner from the parent row at every insert, charge the
allowance to that owner, and hand a moved entry over to the tree it
moves into. An entry now always belongs to whoever owns the directory
holding it.

* feat(fs): address shared entries as ~/share/<uid>

A recipient could read the owner's whole path off any shared entry —
where they keep the file and what sits beside it, neither of which the
share is about.

Give shares their own namespace. `~/share/<entry-uid>/rel/path` resolves
to the real path on the way in, and outgoing paths are rewritten to it
on the way out. Entries the actor owns pass through untouched, so no
existing client contract moves.

* revert(fs): mask only the directory bar, not the addressing

fe535d598 made `~/share/<uid>` the actual address for every shared
entry. That reached far past the intent: item names became uuids, the
Shared views rendered uuids instead of filenames, and navigation
addressed entries through a namespace nothing else understood.

Put real paths back everywhere — responses, the share listing, and
request handling — and do the masking where it was wanted, in the
window's directory bar. A recipient sees `Shared › Contents › sub`
while every crumb keeps the real path it navigates to.

The share listing now carries the entry's name, content type, owner and
a signed thumbnail. A share row has no fsentry behind it for a client
to stat, and the stored thumbnail is an `s3://bucket/key` URI that no
client can render and none should see.

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

* fix: path obfuscation, webdav + small ui stuff

* test(share): assert the masked share path by its exact shape

The substring check tripped on the scratch files' own names, which start
with `sharing-`; the exact-equality assertion on `/<owner>/<uid>/<name>`
already proves nothing above the share leaks.

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

* fix(share): rename policy, shared-view guards, webdav share parent, quota/bucket invariants

- Rename: a directly-shared FILE renames with write on it; a shared
  folder root stays fixed (its name is the owner's tree structure).
  GUI can_rename mirrors the backend, guards all editor entry points.
- Up from a share root goes to the Shared view on both surfaces; the
  Shared view gets a single crumb, a disabled Up button, and refuses
  drops, New/Paste, uploads and ctrl+V everywhere (it is a query, not
  a directory).
- WebDAV: PROPFIND on /owner/uuid answers as a virtual collection
  holding the share root (ACL-gated; 404 for strangers).
- Storage allowance override no longer crosses user boundaries: a
  recipient's plan cannot raise the owner's cap.
- Overwrites stay in the bucket the entry already lives in instead of
  repointing to the handling server's bucket and stranding the old
  object.
- manage mode documented as implying write (matches enforcement);
  share dialog label now "Can edit & share". Documented that fs socket
  events are owner-only.
- Sidebar: saved orders gain the Shared entry once the user has shares.

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

* fix(share): close review findings, and retire /auth/grant-user-user

Security
- Recipient writes no longer echo the owner's real path back. The pending-write
  event and the upload-progress meta both go to the *acting* user, so a write
  into a shared folder handed out the layout above the share root.
- `maskerFor` failed open: the first caller fixed the actor, and one that ran
  before the request knew who was acting pinned `undefined` — after which every
  path published unmasked. It now adopts the first real actor and rebuilds for
  a different one.
- `/auth/grant-user-user` returns 501. It wrote user-to-user grants straight to
  the permission tables with no share row, so nothing could list or cascade a
  revoke over them — and the new `manage` mode meant a delegate could reach it
  for the owner's files. Filesystem access goes through `/share`; nothing else
  is meant to pass between two users. Undocumented (its docs page was never
  written) and no callers. Revoking is untouched, so grants made before this
  can still be withdrawn.
- `#assertCanManage` asks about the permission actually being granted rather
  than a fixed `read`, so sharing at `manage` is refused at the share layer
  instead of by `grantUserUserPermission` two levels down.
- `listSharedWithMe` checks the grants, not just the index: withdrawing access
  any other way left the row publishing name, size and a signed thumbnail URL.
- Moving an item into another tree now retires its shares. Grants are keyed on
  uuid, so they followed it and left the new owner with recipients they never
  agreed to.

Correctness
- `acl.check` gets the real path again, not the masked one. ACL matches on the
  path string, and a mask hides the `AppData/<appUid>` shape it needs.
- "Leave this share" works for a grant that predates the index; the fallback
  scoped the delete to the caller as issuer, which can never match.
- `move`/`copy` reject a name with a slash or a `.`/`..` segment, as `rename`
  already did. It matters more here: a move into the owner's Trash skips the
  destination write check.
- Descendant walks scope by path, not by owner, so rows predating the
  one-owner-per-subtree invariant aren't orphaned by their parent's deletion.

Performance
- Index `user_to_user_permissions(permission)`. Retiring a node's grants is a
  prefix match, but the primary key is (issuer, holder, permission), so it was
  a full scan.
- Retirement is coalesced and chunked. `remove()` emits one event per
  descendant, so deleting a directory fired one unindexed lookup per file, all
  at once and unawaited.
- `listReaching` is a plain `fsentry_id IN (...)` on `idx_share_fsentry`. It
  runs behind every file write, and the join-plus-OR it replaced was
  unindexable.

---------

Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Daniel Salazar <[email protected]>
2026-08-17 09:08:23 -04:00
Ayush tiwari fc861da89f feat: add Gemini 3.7 Flash (#3587) 2026-08-16 23:51:37 -07:00
Parman Mohammadalizadeh facb844747 fix: derive express subdomain offset from the configured root domain (#3575)
Express derives `req.subdomains` by dropping a fixed number of labels
from the right of the hostname, defaulting to 2. A deployment whose
`domain` has more than two labels therefore reads its own root domain as
an active subdomain, so the user-site redirect sends the root origin to
the static hosting domain, which sends it back. Self-hosting docs
recommend exactly that shape (`puter.example.com`).

Set the offset from the label count of `config.domain`. Two-label
domains keep the express default, so existing deployments are unchanged.

Fixes #3561
2026-08-16 23:48:20 -07:00
Yashwarhade8010 3b791bb334 docs: update web search examples to gpt-5.6-luna (#3589) 2026-08-16 23:45:02 -07:00
Daniel Salazar a64a444a17 feat: batch delete for flush + metering fixes (#3593) 2026-08-16 15:26:33 -07:00
Daniel Salazar d3c9828207 fix: metering gui maths (#3592) 2026-08-16 14:57:29 -07:00
Daniel Salazar 87180b1081 fix: metering (#3591) 2026-08-16 14:19:05 -07:00
Daniel Salazar d22b656df8 fix: metering issues with addons (#3588) 2026-08-15 22:06:51 -07:00
Nariman Jelveh 19cd5f4476 feat: add BytePlus ModelArk providers (chat, image, video) (#3498)
* feat: add BytePlus ModelArk chat provider

Adds BytePlus ModelArk as a provider for the puter-chat-completion
driver, following the MiniMax/ZAI providers as reference per
doc/contributing-apis.md.

- OpenAI-compatible endpoint at ark.ap-southeast.bytepluses.com/api/v3
  (apiBaseUrl config selects the region)
- Static catalog of 16 chat models (Seed 2.x/1.x incl. vision, GLM,
  DeepSeek, GPT-OSS) with limits and per-token pricing from the
  official docs
- Passes Ark's thinking/response_format/stop params through custom;
  normalizes reasoning_content to reasoning
- Bare deepseek-v4-* names stay with the first-party DeepSeek
  provider; BytePlus only claims prefixed aliases
- Offline unit tests (mocked SDK against a real test server) plus an
  env-gated integration test

* feat: add BytePlus image and video providers

Extends the BytePlus ModelArk integration to the puter-image-generation
and puter-video-generation drivers, reusing the same services.byteplus
API key and regional apiBaseUrl as the chat provider.

Image (Seedream/SeedEdit via OpenAI-compatible /images/generations):
- dola-seedream-5-0-pro (pixel-tier pricing + billed input images from
  the 2nd on), seedream-5-0-lite, 4-5, 4-0, and seededit-3-0-i2i
- quality tiers 1K/1.5K/2K; aspect ratios resolve to Ark's documented
  pixel sizes; explicit WxH passes through with Ark's bounds enforced

Video (Seedance via Ark's async /contents/generations/tasks + polling):
- Seedance 2.0 / 2.0 Fast / 2.0 Mini / 1.5 Pro / 1.0 Pro / 1.0 Pro Fast
  (2.5 is priced but its API isn't live yet, so it's excluded)
- per-video-token billing from usage.completion_tokens, with per-second
  estimates feeding the credit cap; audio vs silent rates for 1.5 Pro
- first/last frame and reference-image inputs; generate_audio param
  added to IGenerateVideoParams

Pricing and capabilities hardcoded from the official docs (ModelArk
pages 1544106, 1330310, 1520757, 1521309, 1541523). Offline unit tests
mock the SDK / global fetch; integration tests are env-gated on
PUTER_TEST_AI_BYTEPLUS_API_KEY.

* fix: correct BytePlus catalogs and validation against the live API

Verified the three BytePlus providers against ModelArk with a real key;
these are the mismatches that surfaced.

- Drop seededit-3-0-i2i-250628. Ark reports it as Shutdown and every
  request 404s. Its now-unreachable image-to-image branches in the
  provider go with it.
- seedream-4-5 and the 5.0 series enforce a 3,686,400 pixel minimum, so
  they only accept the 2K tier. Mark them 2k-only and snap an
  unsupported tier up to the nearest allowed one, which also keeps the
  aspect-ratio table from mapping to a sub-minimum size.
- glm-4-7 has a 204,800 token context, not 256K.
- Guard the actor in the image provider like the video provider does.
- Round a sub-minimum video duration up to the shortest supported clip
  instead of reporting it as insufficient funds.
- Gate video resolution on the model's own dimensions; the dims table is
  shared across a family and accepts more than any one model does.

* Tighten BytePlus AI provider handling

Extract shared reasoning-content normalization for OpenAI-style chat providers, and harden BytePlus image/video behavior. This updates image tier and size validation, normalizes aspect ratios and input image refs, prevents mismatched BytePlus key/base URL fallback config, makes video resolution matching case-insensitive, and rejects excess reference images instead of silently truncating them. Tests were expanded to cover the new BytePlus request and validation paths.
2026-08-15 21:08:17 -07:00
404oops fc56075115 Merge pull request #3584 from rupesh0001-tech/rupesh
feat(ai-chat): add grok-4.6 model to xai provider
2026-08-15 21:22:01 +02:00
404oops e1998e39ab Increase max_tokens limit to 500,000 2026-08-15 21:20:45 +02:00
Daniel Salazar fb7968a1c7 fix: metering hardening; handle burst of unfinished ai requests (#3585) 2026-08-15 10:52:29 -07:00
rupesh0001-tech c88eeb3868 feat(ai-chat): add grok-4.6 model to xai provider
- Add grok-4.6 model definition with pricing and context specs
- Add aliases x-ai/grok-4.6 and grok-4.6-latest
- Add unit tests for alias resolution and metering calculation

Closes #3562
2026-08-15 20:32:22 +05:30
Reynaldi ChernandoandCopilot Autofix powered by AI 9e25ce1401 [PUT-1478] Add KV commands to CLI (#3579)
* Add KV commands to CLI

* docs

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-08-15 11:43:30 +07:00
Daniel Salazar f15d835eeb fix: restrict openai and anthropic compatible endpoints to be subscription (#3583) 2026-08-14 18:55:15 -07:00
Daniel SalazarandClaude Fable 5 27c4506e02 feat: usage rendered as credits on a 50c base, free allowance doubled (#3569)
* feat: usage rendered as credits on a 50c base, free allowance doubled

Usage is now displayed in credits: a configurable creditsPerDollar rate
(default 2,000, making the free tier's 50c allowance an even
1,000-credit base) ships with the metering usage response, and the
dashboard's usage cards and per-API table show raw credit numbers
through one shared formatter. The dashboard budget math is also fixed —
capacity is spend plus server-netted remainder, so held purchased
credit can never render as negative usage. The registered-user free
allowance doubles to 50c, tier display names move to Basic/Plus/Pro,
and dead referral-promise strings leave the English dictionary.

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

* refactor: the credits display rate exists only in deployment config

No default in code and no rate on any wire the plan surfaces read: a
deployment that doesn't configure creditsPerDollar renders usage in
dollars, exactly as before credits existed. The usage endpoint sends
the rate only when configured, and nothing in code or comments states
what any deployment's rate is.

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

* refactor: metering reports pre-scaled credits, never raw amounts

The usage, per-app usage, and cost-catalogue endpoints multiply every
monetary field by the config multiplier (renamed creditMultiplier)
before responding, and flag the unit; the multiplier itself never
ships. Counts, units, and byte figures pass through untouched. With no
multiplier configured the endpoints report raw amounts and clients
render dollars, as before credits existed.

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

* fix: absent addon fields stay absent when scaling to credits

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-14 16:29:01 -07:00
Neal Shah 6a7c09f54e blacklist dynamic workers (#3582) 2026-08-14 18:35:34 -04:00
Reynaldi Chernando 6254714484 deprecate gemini 2 flash and lite (#3581)
* deprecate gemini 2 flash and lite

* fix test
2026-08-14 17:27:48 -04:00
Neal Shah efa2cb4441 add more extensibility to workers driver (#3565) 2026-08-14 17:24:36 -04:00
Nariman Jelveh 4bc969b99e Update RecommendedAppsService.ts 2026-08-14 14:05:44 -07:00
Reynaldi Chernando 65f6d38432 update gpt 5.6 luna and terra pricing (#3580) 2026-08-14 16:38:13 -04:00
Daniel SalazarandClaude Fable 5 d641f6f7a1 tests: count only this test's dynamo reads in the block-window assertion (#3576)
The spy sits on the test server's shared dynamo client, so a raw call
count also picks up background work and the async tail of earlier tests
in the file — which made the block-window test flakily report a third
read. Each test runs in its own random namespace, so filtering the
spy's calls to that namespace removes the cross-talk without loosening
the assertion.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-14 11:05:25 -07:00
Daniel SalazarandClaude Fable 5 00828b9998 fix: concurrency limiter heals legacy INCR keys instead of failing open (#3574)
The zset rework of the redis concurrent backend (#3529) kept the same
key names the old INCR counter used. Against a leftover string key every
zset command in the acquire MULTI fails WRONGTYPE while the trailing
EXPIRE still succeeds — so steady traffic refreshes the stale key
forever, and the failed zcard result turned into NaN, which admitted
every caller unbounded. Release then errored WRONGTYPE on each request
(the '[concurrent] release failed' log storm).

Surface per-command MULTI errors instead of coercing them to NaN, and on
WRONGTYPE delete the legacy key and re-acquire against a clean zset.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-14 01:40:05 -07:00
Daniel SalazarandClaude Fable 5 126ec09556 docs: publish rate limits and quotas, and make limit changes move the docs (#3568)
Developer-facing reference for every rate limit, concurrency cap,
quota, and error shape — an advanced page, since puter.js already turns
the common failures into prompts. AGENTS.md and CONTRIBUTING.md now
carry the rule that a PR moving any of these numbers updates the page
in the same PR: an undisclosed limit is one developers discover as a
service failure.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-14 00:41:01 -07:00
jelveh 25a5799088 Update RecommendedAppsService.ts 2026-08-14 00:38:05 -07:00
Daniel SalazarandClaude Fable 5 ea2f9967a6 fix: over-quota batch uploads surface the storage prompt instead of failing quietly (#3566)
* fix: over-quota batch uploads surface the storage prompt instead of failing quietly

Partial batch failures now carry each item's code/status, and when every
failed item failed the same way the shared code/status is hoisted onto
the rejection itself — so an upload that exceeds the storage quota
rejects with storage_limit_reached/413 and the SDK's upload handler
shows the free-up-space prompt. A partial failure is also no longer
misread as the signed-batch endpoint being unavailable.

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

* fix: out-of-storage copies prompt to upgrade the same way uploads do

Any fs operation the server refuses with 413 storage_limit_reached now
surfaces the upgrade prompt — the check that lived inline in upload's
error handler moves to a shared helper wired into the operation
scaffold's reject path, so copy/move/mkdir/rename get it too. The
desktop's copy/paste suppresses its generic alert for that code, since
the SDK dialog already explains the refusal and carries the fix.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-14 00:26:07 -07:00
Daniel Salazar ba9b885a5a Revert "feat: dashboard app search continues into the App Center (#3567)" (#3572)
This reverts commit 6cc241ac31.
2026-08-13 21:00:02 -07:00
Daniel SalazarandClaude Fable 5 6cc241ac31 feat: dashboard app search continues into the App Center (#3567)
A search that matches none of the user's own apps now puts the same
query to the App Center catalogue and renders the hits under the empty
state — debounced, filtered to apps the user doesn't already have, and
launchable in place.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-13 20:48:03 -07:00
Nariman Jelveh 7f8e24f06d fix: keep "Add an app" in the grid while searching
Typing in the Apps tab's search dropped the tail tile — deliberately, on the
grounds that a query asks which apps you HAVE and a tile matching nothing has
no business in the answer. That reads the search too narrowly: looking for an
app you don't have is the same gesture as looking for one you do, and the
moment the search comes up short is exactly when the way to GET an app is
worth having on screen.

So the tile is pushed unconditionally now, and a query that matches nothing no
longer replaces the grid with a bare "No apps match your search" — the line
moves above a grid holding just that tile. Keeping it a real grid, rather than
parking a tile inside the empty state, is also what keeps it reachable: arrows
and Enter only walk tiles inside .myapps-page, and the tail tile carries
tabindex -1 for the pager's roving focus.

The no-apps-at-all state is untouched: with nothing installed and nothing
typed, the empty state still stands in for the whole grid.
2026-08-13 18:53:59 -07:00
Nariman Jelveh 9b6627b1ea fix: an app's background helper must stay private to it
`launchApp(name, args, { background: true })` hid the child's window and
nothing else, so the instance was hidden but not private: it kept its
taskbar item, lit the running dot on its dashboard tile, and — because
the dashboard enforces one instance per app — WAS what clicking that tile
opened. Minimize the parent, click the child's tile, and Puter handed the
user the window the parent was mid-conversation with; the app, which only
ever meant that instance for its caller, said so on screen. Showing it
also dropped the never-shown marker, so from then on the helper outlived
its launcher and owned the tile.

Hidden now means unreachable. `user_facing_windows` is the one rule: of
an app's windows, the ones that are the user's. Everything the user
drives filters through it — the tile's reopen and its running dot and
Quit item, the taskbar item's click, window list and show/hide/close-all,
the file row's switcher and open-file dot, and the dashboard's
Back/Forward — so a helper is never focused, restored, listed, or counted
as running. With none of the user's own left, the tile simply launches a
fresh instance and the helper keeps serving whoever launched it. Paths
that act on the APP rather than on a window it happens to have open —
uninstall, and a launcher's close taking its helpers down with it —
deliberately keep the unfiltered list.

The taskbar item is now what a hidden launch EARNS by becoming visible,
rather than something it advertises from the start; makeWindowVisible
adds it (and fires the dots event) before showing, since focusWindow
marks an item active and needs it to exist. Windows stamp data-in_taskbar
when they take their place in data-open-windows, so the close path only
decrements a count it actually joined — otherwise a helper closing beside
the user's own window would zero the count and strip the item off a
window still open. For the same reason a helper's close no longer pops
the dashboard URL: it never claimed an entry (the push only happens for a
window created visible), and consuming one would minimize the user's
window of the same app.

is_unseen_background_window gains the other half of its own definition:
launched hidden AND launched BY another app. An app that is always
windowless (`background` on the app record) starts hidden too, but when
the user opens it there is nobody it is serving — it is the instance
their tile has to find, or every click would start another one they
cannot see.
2026-08-13 18:38:13 -07:00
Nariman Jelveh 7576e0b03a fix: route dashboard file menus by input modality, not touch capability
A mouse right-click on a file row in the dashboard Files tab opened the
mobile context-menu sheet (centered over the row, nowhere near the
cursor) on any touch-capable laptop or desktop, because phone/tablet
detection used navigator.maxTouchPoints > 0. The same check applied the
.touch-device class, degrading mouse selection and drag on those
machines.

Route by how the menu was invoked (the row's tracked pointer type) plus
isTouchPrimaryDevice() — coarse pointer and no hover — which is false on
touch-capable laptops but true on iPads whose UA claims macOS. Also let
sidebar-folder and background menus accept taphold on such iPads, where
they were previously unreachable (taphold dismissed, and iOS never fires
native contextmenu).
2026-08-13 17:46:58 -07:00
Nariman Jelveh d980a9f590 fix: don't invert full-color icons in the dashboard context menu 2026-08-13 17:33:14 -07:00
Nariman Jelveh d18ea1adb5 fix: a background app must not outlive the app that launched it
An app launched with `background: true` gets a real window from the moment it
starts, just hidden. Nothing ever took it down: when the app that launched it
closed, the child kept running with no way to reach it and no reason to be
there. On the dashboard the only sign was a running dot on a tile the user had
never lit up, and clicking that tile did nothing at all.

Three things were wrong, and all three had to go:

A window nobody has seen now dies with its launcher. UIWindow's close path
closes hidden children the closing app launched, keyed on a marker stamped at
creation. makeWindowVisible drops that marker the first time the window becomes
visible — showing itself with `puter.ui.showWindow()`, or the user showing it —
because from then on the window is the user's, and keeps running.

The dashboard tile is a real handle again. focusExistingAppWindow only routed
MINIMIZED windows through showWindow(); a hidden one fell through to
focusWindow(), which leaves it invisible while handing it the keyboard. With no
taskbar in dashboard mode the tile is the only handle on a background app, so
that click had nowhere else to go. It now asks whether the window is on screen
at all. The Files tab's row-click had the same one-line defect.

And a background instance can't take a tile from the user's own session: an
on-screen window wins, then a window the user has seen, then a hidden one.

Removing the child then hit a crash of its own: ExecService's `remove` handler
dereferenced the launcher's iframe to say goodbye, and the launcher was already
gone. Throwing there aborts jQuery's remove() itself, so the window stayed in
the DOM — running dot and all. That one also hit anyone closing a background app
from the taskbar after its launcher had closed.

The predicates behind all of this live in one helper, window_visibility.js, with
showWindow() reading the same `hidden, not minimized` rule it spelled out inline
before.
2026-08-13 14:36:08 -07:00
Nariman Jelveh 08f075d774 Update RecommendedAppsService.ts 2026-08-13 11:45:28 -07:00
Nariman Jelveh 664de0016b fix: a hidden window must not hold the keyboard
Launching an app to serve another one took focus away from the app the user
was working in, and they had to click it to get it back. Typing a guest name
into Calendar's event editor — which launches Contacts to read the address
book — dropped the user out of the field mid-word.

Four things conspired, all of them variations on "a window nobody can see is
treated as the window in front":

`window-active` was hardcoded into every window's markup, including windows
created hidden. focusWindow() — the thing that normally grants that class,
and strips it from everyone else — is already skipped for a hidden window, so
two windows ended up claiming it. It now follows the same rule as the
window_stack push a few lines above: visible windows only.

Both "focus the window once its IPC attaches" sites read that class to decide,
so a hidden child window pulled focus a beat after launching. Neither focuses
a window with data-is_visible="0" any more.

makeWindowInvisible left `window-active` on the window it had just hidden.
Since focusWindow() disables pointer events on every other app's iframe, an
app calling puter.ui.hideWindow() on itself left whoever launched it both
unfocused AND unclickable until the next click. It now hands activation back
to the top of the window stack, the way closing a window does, and drops
itself out of the activation order until it is shown again.

showWindow only ever un-minimized: for a window hidden by hideWindow() (which
keeps its geometry and writes no data-orig-*) every branch left it hidden and
stamped NaN geometry on it from the missing attributes. It now routes such a
window to makeWindowVisible. That is what makes the taskbar item a real
handle on a background app — clicking the item calls showWindow on the group.
2026-08-13 09:59:37 -07:00
Nariman Jelveh e273431f14 feat: let an app launch another app in the background
`puter.ui.launchApp(name, args)` had no way to say "I need this app's API,
not its window". That matters because we create and show an app's window
before the app's own code runs, so an app launched purely to serve another
one cannot avoid appearing on screen: the best it can do is call
`puter.ui.hideWindow()` once it boots, which reads as a window flashing
open and shut. In dashboard mode it was worse than a flash — the child
maximized into the tab and minimized its parent behind it, so asking a
service app a question took the user's app away from them.

So `launchApp` now accepts `background: true`, and the window starts
hidden. The app is otherwise entirely normal: it keeps its taskbar item,
so a user can see that it is running, show it, or close it, and it can
show itself with `puter.ui.showWindow()` whenever it has something to say.
Only a literal `true` counts, since the flag arrives over IPC from another
app.

The decision now lives in one predicate, `starts_hidden(app_info, options)`,
which folds this together with the existing app-level `background` flag and
is used everywhere the old flag was read — including the dashboard's
minimize-the-parent branch. `show_in_taskbar` deliberately still keys on the
app-level flag alone: an app that is always windowless has nothing to put in
the taskbar, while a background *launch* should stay visible there.

Existing callers are unaffected: with `background` unset, both paths
evaluate exactly as they did.
2026-08-13 09:47:35 -07:00
Daniel Salazar 8ed8feed4b fix: don't let an aborted upload take down the process (#3557) 2026-08-13 01:02:17 -07:00
Daniel Salazar 22f5bf5429 fix: duplicate emails (#3556) 2026-08-12 22:00:48 -07:00
Nariman Jelveh 198184f986 fix: drop the feedback dialog's rate limit entirely
Reopening still failed intermittently: any call the backoff refused looks
exactly like the user closing the dialog, so an app's "Send feedback"
button just did nothing, with nothing to see anywhere.

Rate limiting this dialog is not worth that. The other modals an app can
open with no user gesture — requestPermission, alert, prompt — have no
rate limit either, so one dialog at a time is the only rule left, and a
refusal now says so in the console.
2026-08-12 17:46:05 -07:00
Nariman Jelveh 026ae46530 fix: let an app reopen the feedback dialog after the user closes it
The abuse guard counted every dismissal that sent nothing as a strike
against the app, so closing the dialog blocked the app's next
showFeedbackDialog() for 10s, then 60s, then for the rest of the page's
life. The key is the app uuid, so relaunching the app did not clear it,
and a blocked call replies sent:false — the app just sees nothing happen.

Key the guard off how fast the app comes back instead. A reopen within a
second of the app's last dialog activity is machine-paced and gets the
same escalating backoff; attempts made while backed off count as activity
too, so a loop cannot wait out a tier and start from zero strikes. Any
human-paced reopen clears the app's record, so the tiers are only reached
by an app that reopens at machine speed several times running.

One dialog at a time, and a successful send clearing the record, are
unchanged.
2026-08-12 17:35:54 -07:00
Nariman Jelveh 0615c3aa3c feat: add "Show hidden" to the dashboard's folder context menu
The dashboard's files tab filtered out dot-files unconditionally, so
there was no way to reach them the way the desktop Explorer allows.
Add the same "Show hidden" toggle next to Refresh, driven by the
shared show_hidden_files preference so the two stay in sync, and dim
revealed rows with item-revealed like the Explorer does.

The socket-driven item.added path applies the same rule, so a hidden
file created elsewhere can't appear in a view that filtered its
siblings out.
2026-08-12 17:26:04 -07:00
Nariman Jelveh 748461aabb fix: show the new folder's row in the dashboard before mkdir answers (#3555)
* fix: show the new folder's row in the dashboard before mkdir answers

Both New Folder paths in the dashboard Files tab awaited mkdir before
drawing anything, so on a slow connection the button appeared to do
nothing for seconds. The toolbar path then did a full renderDirectory on
top of that -- a second round-trip, and with the default eventual
consistency the new folder could miss the listing entirely, leaving the
rename editor unopened.

Both now go through one createFolderInstant(), which renders the row
first: a locally predicted name (mirroring the backend's " (N)" dedupe
convention), a temporary uid, and the name editor already open. mkdir is
still asked for the plain "New Folder" path with rename: true, so the
server keeps owning deduplication; the prediction is only what we draw in
the meantime, and the row corrects itself if the two disagree. On failure
the row is withdrawn and the error is shown rather than swallowed.

Renaming is the next thing the user does and it needs the real uid, so
rename() awaits the create promise parked on the row. Everything else
that acts on the item -- open, menus, drag -- sits out while the row is
pending. mkdir's item.added comes back to the originating client (the
event carries no original_client_socket_id) and cannot match a temporary
uid, so _creatingItem still suppresses it, now as a counter: as a flag,
one create finishing uncovered another still in flight.

Two things found on the way:

- directory_depth_limit_exceeded had no translation key, so that alert
  (including the pre-existing desktop create_folder path) rendered the
  raw slug.
- The empty-directory notice is now restorable without refetching, since
  a withdrawn create has to put it back. It carries its own class because
  the loading overlay shares the same container.

Verified end to end against a dev backend with mkdir slowed to 3s:
toolbar and context-menu paths, rename and Escape before the response,
two overlapping creates, and a rejected mkdir.

* fix: don't draw the instant new-folder row while a listing is rebuilding

renderDirectory() clears .files before its readdir resolves, so a row
drawn during a load survives the clear and is left behind in whatever
directory the load lands on. Clicking the new-folder button while a
folder is still opening produced a row for `<old dir>/New Folder` sitting
in the new directory's listing, with the rename editor open on it —
clicking it navigated elsewhere and renaming it renamed a folder the user
wasn't looking at.

Drop the click while a rebuild is in flight, the same way renderDirectory
already drops navigation clicks that arrive while it renders.

* fix: stop drops onto a not-yet-created folder row from mangling files

A row drawn ahead of its mkdir carries a predicted path, but it was still
registered as a live drop target. Dropping a file on it called
move_items() with a destination that does not exist yet, and move treats
a non-existent destination as a rename target — so the dragged file was
silently renamed to "New Folder" instead of moving into the folder.
Reproduced against a slow mkdir: victim.txt became a 5-byte file named
"New Folder".

Sit the row out of the jQuery UI droppable (drop and the spring-load
hover) until the real fsentry lands, matching the guards already on
opening it, its menus and dragging it. The native-file dragster drop gets
the same guard: it uploads into the same predicted path.
2026-08-12 17:16:44 -07:00
Daniel Salazar 78cfe0c0f6 metrics: add metering buffer store coutns (#3554) 2026-08-12 15:55:03 -07:00
Nariman Jelveh 81f9f00fc0 fix: stop app feedback dialog clicks from focusing the iframe beneath
Clicking inside the feedback overlay (e.g. the textarea) reached
initgui's global mousedown -> focusWindow path: mouseover_window is
computed geometrically and is blind to the overlay, so focusWindow
focused the app window's iframe underneath — stealing keyboard focus
from the textarea and forwarding the click into the app.

Suppress it the established way (same fix as the dashboard app-group
panel): set window.mouseover_window = undefined in the overlay's own
mousedown handler, which runs before the document-level one; undefined
is the only value initgui's guard skips.
2026-08-12 15:00:32 -07:00
Nariman Jelveh 556380cf16 fix: keep app feedback send button blue on hover
The generic .app-feedback-btn:hover:not(:disabled) rule out-ranked the
.app-feedback-btn-primary:hover override (the :disabled argument inside
:not() counts toward specificity), so hovering Send repainted it with
the light --afb-hover background under white text. Strengthen the
primary hover selector so the blue gradient wins.
2026-08-12 13:42:11 -07:00
Daniel Salazar 908ec23eda fix: sanitize user data (#3552) 2026-08-12 12:08:54 -07:00
Juan Fernando Castro d5ae5a0049 🔧 PUR-1072: Flatten driver permissions to hardcoded values (#3545)
* refactor(permissions): drop hardcoded group permission map for a flat default

* test(drivers): assert credential-gate intent instead of a 403 proxy
2026-08-12 12:08:38 -07:00
Daniel Salazar 419d0aaa89 doc: update bounty doc 2 (#3551) 2026-08-12 09:56:34 -07:00
Daniel Salazar c3a0c28705 docs: update bug bountu (#3550) 2026-08-12 09:55:13 -07:00
Nariman Jelveh 7ceb2090b7 feat: app user feedback system (#3546)
* feat: app user feedback system

Add puter.ui.showFeedbackDialog(), letting users send feedback to an
app's developer. In the app environment the Puter desktop renders the
dialog; on a third-party website a puter.com popup hosts it. The message
is stored in a new app_feedback table and emailed to the app owner's
confirmed email — it never passes through the app's own code.

Feedback is strictly opt-in per app via a new apps.feedback_enabled
column (a real column, not an app-metadata key, so Dev Center's
whole-blob metadata saves can't silently erase it), settable through the
existing puter.apps.update path (feedbackEnabled).

Backend follows the layered stack: AppFeedbackStore (durable count
queries) -> AppFeedbackService (opt-in check, message normalization,
abuse caps, best-effort owner email) -> AppFeedbackController
(POST /app-feedback, GET /app-feedback/target). New app-user-feedback
email template uses the escaping-safe nl2br triple-stash.

Defensive by design:
- requireUserActor blocks app tokens, so feedback can't be submitted
  programmatically; guiOriginOnly keeps cross-origin pages out.
- App identity comes only from the validated IPC sender (desktop) or the
  browser-attested opener origin (popup), never from message contents.
- The send-feedback popup action is in NON_AUTH_POPUP_ACTIONS, so it
  never delivers a token to the opener.
- Layered limits: route rate limits, plus DB-count caps that fail closed
  when the limiter backend is down, plus a per-app daily owner-email cap.
- Owner email is fully best-effort: an unconfigured transport,
  unconfirmed/unsubscribed/suspended owner, or send failure never fails
  the request or blocks storage.
- The dialog and SDK method are resolve-only and always settle, so a
  caller is never left hanging.

Migrations for sqlite/mysql/postgres, puter.js types, docs, backend
tests (sqlite + postgres), and a Playwright e2e spec are included.

* feat: add feedback control to the dashboard app-drawer

Surface the feedback dialog directly from the app window's chrome in
dashboard mode: apps that opt in (apps.feedbackEnabled) get a "Send
Feedback" button in the dashboard app-drawer, next to minimize/close.
It opens the same UIWindowAppFeedback dialog, targeting this app by uid.

The control is only rendered when the app opted in — feedback_enabled is
threaded from the launched app's metadata into the window options — and
the dialog still re-checks opt-in server-side, so a stale flag can't send
anywhere. Reuses the existing .dashboard-app-drawer-btn styling and the
app_feedback_title i18n string, so no new CSS or strings.

Adds e2e coverage: the control appears and opens the dialog for an
opted-in app, and is absent for an app that hasn't opted in.

* feat: enable app feedback by default in Dev Center

New apps created in Dev Center now have feedbackEnabled set on creation,
so users can send the developer feedback without any extra setup. A "User
Feedback" toggle in the app's settings lets developers turn it off (and
back on); it's wired into the save payload, the dirty-state tracking, and
the reset-to-original path like the neighboring toggles.

The Save update omits feedbackEnabled unless the toggle is present, and
the backend leaves an omitted field untouched, so the default survives
the create-then-save flow Dev Center runs. Add an SDK apps-suite guard
covering that round-trip (create-on -> unrelated update keeps it -> can
be turned off).

* fix: feedback modal polish + share sender email

Address four issues with the app feedback UI:

- Dashboard app-drawer: the extra "feedback" control pushed the close
  button past the drawer's derived width and clipped it. A `has-feedback`
  modifier widens the surface by one button + gap so all three controls
  fit. The control's glyph is now a message bubble with text lines, which
  reads more clearly at 14px than the previous bare speech bubble.

- The feedback dialog is no longer a UIWindow. It's a from-scratch
  overlay modal in the spirit of the dashboard modals (uninstall,
  add-app): a fixed scrim + centered card with self-contained,
  theme-aware color tokens (light default + dark override), a bottom-sheet
  layout on narrow screens, backdrop/Escape close, and an entrance
  transition. This renders consistently across the three contexts it's
  opened from (desktop app-IPC, dashboard drawer, standalone popup), so
  the callers no longer pass UIWindow-specific window_options.

- Feedback now shares the sender's email (not just their username) with
  the developer so they can respond: the owner email sets Reply-To to the
  sender and shows the address in the body — but only when the sender's
  email is verified (an unverified address could be anyone's, so it's
  never used as a reply target). EmailClient.send gains an optional
  replyTo. The dialog note now says the email will be shared.

Tests: e2e updated for the new modal (7 pass); backend feedback suite
covers the verified/unverified sender-email split (sqlite + postgres);
EmailClient + GUI unit suites pass; type-check clean.

* fix: resolve 'app-'-prefixed app names in feedback target lookup

APP_NAME_REGEX allows names beginning with "app-" (e.g. the seeded
app-center), but resolveTargetApp's startsWith('app-') heuristic sent
those to a uid-only lookup with no name fallback, so feedback for such
apps 403'd even when enabled. Use AppStore.resolveApp (uid, then name)
like the rest of the codebase.

* fix: make feedback daily caps fail closed under concurrent submissions

The per-user and per-user-per-app caps were check-then-insert and the
per-app email cap was count-then-send, so parallel requests (or multiple
nodes, or the route limiter failing open) could all read a stale
under-cap count and push past every limit — the exact scenario the
DB-backed caps exist to stop.

Now the user caps recount after the insert (own row included) and roll
the row back with 429 if a burst breached them, and the email cap claims
its slot (email_sent=1) before sending, recounts, and releases the slot
if over cap or if the send fails.

* docs: disclose the Dev Center's feedback-on-by-default in SDK docs/types

The Dev Center deliberately creates apps with feedbackEnabled (see
32c950de5), but the SDK docs, apps.d.ts, and AppFeedbackService's class
doc all described feedback as strictly opt-in / default-false with no
qualification — so a Dev Center developer reading them would wrongly
conclude feedback is off for their app. State the Dev Center behavior
alongside the API default, and correct the update-path docs: an omitted
feedbackEnabled leaves the current value unchanged rather than
defaulting to false.

* fix: don't mint a user-app token as a side effect of the feedback popup

Every embedded_in_popup boot ran the user-app token exchange, and the
exchange is a write: /auth/get-user-app-token bootstraps an app row for
the opener origin, grants flag:app-is-authenticated (what makes the
site count as connected to the account), and creates its AppData dir.
So merely opening — or immediately cancelling — a send-feedback popup
recorded a user<->site relationship the read-only feedback flow never
needs: the server resolves the feedback target from the attested origin
without any of it.

Gate the exchange behind runsUserAppTokenExchange(action) in all three
popup paths that mint (main postAuthActions exchange, temp-user signup
success, manual signup fallback). request-permission keeps the exchange
since grants are written against the app row it bootstraps.

* fix: refuse feedback when the deployment cannot deliver it

With no email transport configured (the common self-hosted default),
submissions were stored in app_feedback — a table with no read path
beyond the abuse-cap COUNTs — the owner email was silently skipped, and
the sender was still shown 'Feedback sent. Thank you!'. The developer
never learns the feedback exists while the user believes it was
delivered.

Gate acceptsFeedback on clients.email.isConfigured so the pre-flight
reports enabled:false (the dialog shows its 'not accepting feedback'
pane) and submit returns 403 instead of swallowing messages. Owner-level
store-without-email cases (unconfirmed owner email, per-app email cap
overflow) keep their existing deliberate semantics.

* fix: settle showFeedbackDialog instead of hanging on older host GUIs

In the app environment showFeedbackDialog awaited an IPC reply with no
capability check. A host GUI that predates this feature (self-hosted
Puter running the live js.puter.com SDK) has no handler for the message
and never replies, so the promise documented as 'never rejects' also
never resolved.

The GUI now advertises the IPC dialogs it can answer via a
puter.gui_features param on the app iframe URL, and the SDK resolves
false when 'feedback-dialog' isn't listed. A reply timeout could not
substitute: legitimate replies only arrive when the user closes the
dialog, so any timeout would false-negative while the user is typing.
Older SDKs ignore the extra param.

* fix: guard app-triggered feedback dialog against desktop-lockout loops

The showFeedbackDialog IPC handler had no re-entry or abuse guard, and
the dialog it opens is a full-viewport overlay above the taskbar and
every window — so 'while (true) await puter.ui.showFeedbackDialog()'
kept the desktop permanently covered (for signed-out users, the same
loop spams the full-page signup window instead). Every dismissal just
settled the promise and let the app immediately reopen it.

Allow one dialog at a time, and back off reopens per app after each
dismissal that sent nothing: 10s, then 60s, then blocked until page
reload. A successful send resets the backoff, and user-initiated paths
(dashboard drawer) are unaffected since they don't go through IPC.

* fix: carry feedback_enabled in suggested/recommended app summaries

launch_app uses options.app_obj verbatim when provided, and the
suggested-apps launch paths (open_item.js, UIWindowSearch.js,
UIDesktop.js) pass summaries from toAppSummary — which omitted
feedback_enabled. So an opted-in editor launched by opening a .txt file
showed no Send Feedback control in the dashboard drawer, while the same
app launched from the Apps tab (full puter.apps.get object) did.

* fix: gate feedback dialog Cancel/X on an in-flight submit

Escape and backdrop clicks were already ignored while the POST was
pending, but the X, Close, and Cancel buttons weren't — clicking one
mid-send settled the promise false and tore down the overlay while the
submission still landed server-side: the developer got the email, the
app was told sent=false, and a user who resubmitted 'the failed one'
sent a duplicate and burned a daily-cap slot.

Apply the same !sending gate to the buttons and disable them visually
while the send is in flight.

* fix: reject feedback origins longer than the source_origin column

readTargetParam accepted values up to 3000 chars but the raw origin is
stored verbatim into source_origin VARCHAR(2048) (MySQL/Postgres), so a
2049-3000 char origin passed every validation and then blew up the
INSERT with an HTTP 500 on Postgres/strict MySQL — or was silently
truncated on non-strict MySQL, corrupting the abuse-forensics value the
column exists for. Cap the param at the column size.

* fix: make the feedback dialog's privacy note match what is shared

The note unconditionally said 'Your email address will be shared with
the developer so they can respond', but AppFeedbackService shares the
username always and the email only when it exists and is verified — an
unverified or temp-user sender was promised a reply path that never
materializes, and nobody was told about the username.

Show 'username and email' when the signed-in user's email is verified,
and 'username' otherwise.

* fix: stop HTML-escaping email subject lines

Subjects were compiled with default Handlebars escaping, so the
app-user-feedback subject rendered a title like "Bob's App & Games" as
"Bob&#x27;s App &amp; Games" — literal entities in the recipient's mail
client. Subjects are plain-text headers, not HTML; compile them with
noEscape. Header safety is unaffected: the transport encodes newlines
and free-form values collapse whitespace upstream.

* docs: state exactly what showFeedbackDialog shares with the developer

The doc claimed the dialog 'tells the user their username will be
shared', while the dialog's note talked only about the email address and
the implementation shares the username always plus the email (as
Reply-To) only when verified. Describe the actual disclosure: username
always, email when verified.

* docs: document the COOP false-resolve limitation of showFeedbackDialog

Under COOP the popup's opener link is severed, so the SDK deliberately
resolves false while the popup stays open and the user can still submit
(a feedback submission has no server read-back the way a permission
grant does). The documented contract ('resolves to true if the user
submitted feedback') was silently wrong on cross-origin-isolated pages —
state the limitation in the doc and the SDK jsdoc: false means 'not
confirmed', not 'not sent'.

* feat: point the feedback email footer at the Dev Center

The footer told developers to turn feedback off via a puter.apps.update
one-liner, but the toggle lives in the Dev Center app settings — and
Dev Center is where apps get feedback enabled by default in the first
place. Reword it to 'manage it in the Dev Center' with a link built
from config.origin (like app_link) so it holds on self-hosted
deployments.

* test: cover the feedback service and store layers directly

The service owns every feedback business rule — target resolution,
eligibility, message normalization, the durable caps, and the owner-email
preconditions — but was only reachable through the controller's tests. Give
it and the store their own suites so a regression names the layer it broke.

Service coverage adds the branches the route tests could not reach: a
blocked origin resolving to null rather than surfacing a 403, owners who are
suspended or unsubscribed, length measured after normalization, the 24h cap
window boundary, subject-header injection via the app title, and the email
links being rooted at config.origin.

The two describes already labelled `AppFeedbackService ...` move out of the
controller test, which keeps only the caller-facing promise that a failed
send still returns success.

* refactor: prep the feedback cap error once instead of via a factory

Both throw sites are in one call and only one can ever run, so a plain
const reads the same and drops a function that existed only to defer a
constructor.

* Require verified users for app feedback API

Add `requireVerified: true` to both app feedback routes (`GET /target` and `POST /`) in `AppFeedbackController`. This tightens access control so only verified user accounts can fetch feedback targets or submit app feedback.
2026-08-12 09:49:24 -07:00
1020 changed files with 155216 additions and 16312 deletions

No files matched your search

+61
View File
@@ -0,0 +1,61 @@
# Default owner, including backend and SDK areas without a specialist below.
# The last matching rule takes precedence.
* @Salazareo
# GUI
/src/gui/ @jelveh @Salazareo
# Documentation, developer site, and CLI
/doc/ @reynaldichernando
/src/docs/ @reynaldichernando
/src/dev-center/ @reynaldichernando
/src/cli/ @reynaldichernando
# Backend: AI, hosting, workers, networking, and WebDAV
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland
/src/backend/drivers/util/ @ProgrammerIn-wonderland
/src/backend/drivers/workers/ @ProgrammerIn-wonderland
/src/backend/services/localworker/ @ProgrammerIn-wonderland
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland
/extensions/workerSandbox* @ProgrammerIn-wonderland
/src/worker/ @ProgrammerIn-wonderland
/src/worker-types/ @ProgrammerIn-wonderland
/src/backend/controllers/peer/ @ProgrammerIn-wonderland
# Backend: sharing, permissions, and teams
/src/backend/controllers/share/ @jfcastro92
/src/backend/controllers/team/ @jfcastro92
/src/backend/services/share/ @jfcastro92
/src/backend/services/permission/ @jfcastro92
/src/backend/services/acl/ @jfcastro92
/src/backend/services/team/ @jfcastro92
/src/backend/services/apps/AppPermissionService* @jfcastro92
/src/backend/stores/share/ @jfcastro92
/src/backend/stores/permission/ @jfcastro92
/src/backend/stores/group/ @jfcastro92
/src/backend/stores/team/ @jfcastro92
# SDK: AI, hosting, workers, and networking
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland
# SDK: sharing and permissions
/src/puter-js/src/modules/perms/ @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92
+20
View File
@@ -0,0 +1,20 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
@@ -17,10 +17,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '24'
cache: 'npm'
+54 -25
View File
@@ -1,28 +1,39 @@
name: Backend Tests
# Runs on backend and extension changes. Tests are co-located with the
# code (src/backend/**/*.test.ts, extensions/**/*.test.ts), so test-only
# changes match the same globs. puter.js SDK changes are covered by
# puterjs-tests.yaml instead.
# Always triggers so it can be a required PR check; the `changes` job below
# decides whether backend/extension paths actually changed (test-only changes
# match the same globs, since tests are co-located with the code), and every
# other job is skipped — not absent — when they didn't, which still satisfies
# a required check. puter.js SDK changes are covered by puterjs-tests.yaml.
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'src/backend/**'
- 'extensions/**'
- 'tools/**'
- 'package.json'
- 'package-lock.json'
# The type-check job below reads these.
- 'tsconfig.json'
- 'tsconfig.build.json'
- '.github/workflows/backend-tests.yaml'
permissions:
contents: read
pull-requests: write
jobs:
changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend:
- 'src/backend/**'
- 'extensions/**'
- 'tools/**'
- 'package.json'
- 'package-lock.json'
- 'tsconfig.json'
- 'tsconfig.build.json'
- '.github/workflows/backend-tests.yaml'
# `tsconfig.build.json` builds with `noCheck: true`, so nothing else in CI
# runs the type checker and a missing export compiles to `undefined`,
# surfacing only when the call is finally reached at runtime. This diffs
@@ -35,13 +46,15 @@ jobs:
# Its own job rather than a step in `test`: that one runs a base/PR matrix for
# coverage comparison, and the check only needs the PR ref, once.
typecheck:
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '24'
cache: 'npm'
@@ -52,7 +65,19 @@ jobs:
- name: Type check (new errors only)
run: npm run typecheck
# `needs.changes` gates the steps below, not this job, on purpose: a
# job-level `if` on a matrix job skips the whole job as one unexpanded
# check ("test (${{ matrix.artifact }})", literally, since there's no
# matrix context to fill it in) instead of the two real per-artifact
# checks a required rule pins to ("test (base)" / "test (pr)") — so the
# required check never gets reported and the PR blocks forever. Letting
# the matrix always expand keeps the check names stable either way.
test:
needs: changes
# Overrides the default matrix naming, which would otherwise bake the
# base/head branch name (matrix.ref) into the check name — making it a
# different string on every PR and impossible to pin as a required check.
name: test (${{ matrix.artifact }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -65,49 +90,53 @@ jobs:
steps:
- name: Checkout ${{ matrix.ref }}
uses: actions/checkout@v4
if: needs.changes.outputs.backend == 'true'
uses: actions/checkout@v7
with:
ref: ${{ matrix.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Set up Node.js
uses: actions/setup-node@v4
if: needs.changes.outputs.backend == 'true'
uses: actions/setup-node@v7
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies
if: needs.changes.outputs.backend == 'true'
run: npm ci
- name: Run backend tests with coverage
if: needs.changes.outputs.backend == 'true'
run: npm run test:backend -- --coverage
env:
CI: 'true'
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@v4
if: needs.changes.outputs.backend == 'true' && !cancelled()
uses: actions/upload-artifact@v7
with:
name: backend-coverage-${{ matrix.artifact }}
path: src/backend/coverage/
retention-days: 14
report-coverage:
needs: test
if: always()
needs: [changes, test]
if: always() && needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Download PR coverage
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: backend-coverage-pr
path: src/backend/coverage
- name: Download base coverage
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: backend-coverage-base
path: coverage-base
@@ -0,0 +1,25 @@
name: Dependabot auto-merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
automerge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v3
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Enable auto-merge for patch updates
if: steps.metadata.outputs.update-type == 'version-update:semver-patch'
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+7 -7
View File
@@ -45,17 +45,17 @@ jobs:
fi
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Log in to GitHub Package Container registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -64,11 +64,11 @@ jobs:
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
@@ -82,7 +82,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@v5
uses: docker/build-push-action@v7
with:
platforms: linux/amd64,linux/arm64
context: .
@@ -95,7 +95,7 @@ jobs:
cache-to: type=gha,mode=max
- name: Sync README to Docker Hub
uses: peter-evans/dockerhub-description@v4
uses: peter-evans/dockerhub-description@v5
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+2 -2
View File
@@ -11,10 +11,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '24'
cache: 'npm'
+2 -2
View File
@@ -9,7 +9,7 @@ jobs:
update-release-pr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v7
- name: Setup Git
run: |
git config user.name github-actions
@@ -20,7 +20,7 @@ jobs:
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
branch: release
- uses: actions/checkout@v4
- uses: actions/checkout@v7
if: steps.find-pr.outputs.number == ''
with:
ref: release
+61 -27
View File
@@ -1,28 +1,42 @@
name: Puter.js API Tests
# Runs on puter.js SDK changes — the suites live in src/puter-js/tests,
# so test changes are covered by the same glob — plus the pieces the
# runners depend on: the worker preamble, the in-memory test env in
# testUtil, and the backend vitest config the API-tests config extends.
# Backend and extension changes run backend-tests.yaml instead.
# Always triggers so it can be a required PR check; the `changes` job below
# decides whether puter.js-relevant paths actually changed (the suites live
# in src/puter-js/tests, so test-only changes are covered by the same glob,
# plus the pieces the runners depend on: the worker preamble, the in-memory
# test env in testUtil, and the backend vitest config the API-tests config
# extends), and every other job is skipped — not absent — when they didn't,
# which still satisfies a required check. Backend and extension changes run
# backend-tests.yaml instead.
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'src/puter-js/**'
- 'src/worker/**'
- 'src/backend/testUtil.ts'
- 'src/backend/vitest.config.ts'
- 'tools/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/puterjs-tests.yaml'
permissions:
contents: read
pull-requests: write
jobs:
changes:
runs-on: ubuntu-latest
outputs:
puterjs: ${{ steps.filter.outputs.puterjs }}
steps:
- uses: actions/checkout@v7
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
puterjs:
- 'src/puter-js/**'
- 'src/worker/**'
- 'src/backend/testUtil.ts'
- 'src/backend/vitest.config.ts'
- 'tools/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/puterjs-tests.yaml'
# The JSDoc in src/puter-js/src is the source of truth for the SDK's public
# types. The declarations shipped to npm are generated from it at build time
# and never committed, so what needs guarding is the JSDoc itself: this
@@ -32,14 +46,16 @@ jobs:
# Its own job rather than a step in `test`: that one builds bundles and
# installs a browser, and this needs neither.
types:
needs: changes
if: needs.changes.outputs.puterjs == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '24'
cache: 'npm'
@@ -51,14 +67,16 @@ jobs:
run: npm run check:puterjs:types
test:
needs: changes
if: needs.changes.outputs.puterjs == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '24'
cache: 'npm'
@@ -83,7 +101,18 @@ jobs:
# SDK coverage on both refs, reported as a PR comment — same shape as
# backend-tests.yaml. This run uses the istanbul-instrumented bundle
# (the `test` job above keeps exercising the production build).
# `needs.changes` gates the steps below, not this job, on purpose: a
# job-level `if` on a matrix job skips the whole job as one unexpanded
# check ("coverage (${{ matrix.artifact }})", literally, since there's no
# matrix context to fill it in) instead of two real per-artifact checks —
# confusing to read, and unusable if this ever gets pinned as a required
# check. Letting the matrix always expand keeps the check names stable.
coverage:
needs: changes
# Overrides the default matrix naming, which would otherwise bake the
# base/head branch name (matrix.ref) into the check name — making it a
# different string on every PR and impossible to pin as a required check.
name: coverage (${{ matrix.artifact }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -96,34 +125,39 @@ jobs:
steps:
- name: Checkout ${{ matrix.ref }}
uses: actions/checkout@v4
if: needs.changes.outputs.puterjs == 'true'
uses: actions/checkout@v7
with:
ref: ${{ matrix.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Set up Node.js
uses: actions/setup-node@v4
if: needs.changes.outputs.puterjs == 'true'
uses: actions/setup-node@v7
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies
if: needs.changes.outputs.puterjs == 'true'
run: npm ci
- name: Install Playwright chromium
if: needs.changes.outputs.puterjs == 'true'
run: npx playwright install --with-deps chromium
# The base leg is best-effort — it only enriches the PR comment with
# a comparison, and the base ref may predate the coverage script.
- name: Run puter.js API tests with coverage
if: needs.changes.outputs.puterjs == 'true'
continue-on-error: ${{ matrix.artifact == 'base' }}
run: npm run test:puterjs:coverage
env:
CI: 'true'
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@v4
if: needs.changes.outputs.puterjs == 'true' && !cancelled()
uses: actions/upload-artifact@v7
with:
name: puterjs-coverage-${{ matrix.artifact }}
path: src/puter-js/coverage/coverage-*.json
@@ -131,15 +165,15 @@ jobs:
if-no-files-found: ignore
report-coverage:
needs: coverage
if: always()
needs: [changes, coverage]
if: always() && needs.changes.outputs.puterjs == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Download PR coverage
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: puterjs-coverage-pr
path: src/puter-js/coverage
@@ -149,7 +183,7 @@ jobs:
- name: Download base coverage
id: base-coverage
continue-on-error: true
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: puterjs-coverage-base
path: coverage-base
+2 -2
View File
@@ -45,14 +45,14 @@ jobs:
fi
- name: Log in to GitHub Package Container registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Create alias tag
env:
+7 -5
View File
@@ -35,6 +35,8 @@ These apply everywhere — backend, puter.js, and GUI.
Keep comments light; prefer self-documenting code. Comment only when the _why_ is non-obvious or a usage detail would trip the next reader. Use `//` for single lines and `/** ... */` JSDoc when it genuinely needs more — if a comment runs long, it's probably too long. Don't restate the code and don't reference the current task, PR, or version — those rot. **No ticket references** (`PUT-1234`, `// fix for FOO-99`) in code, comments, or test names; describe the why in domain terms, not project-management terms. Use plain ASCII `-` in comment section dividers (`// -- Section --`), never box-drawing characters.
**Be concise — a comment is a sentence or two, not a paragraph block.** One or two lines for `//`, a few for JSDoc. Don't narrate the history ("used to swallow the failure, which meant…"), don't argue the design at length, and don't explain each parameter when the signature already does. State the constraint and stop. If the reasoning genuinely needs paragraphs, it belongs in a design doc, not above the function.
### Security & privacy
Before opening a PR, scan the diff for:
@@ -52,6 +54,7 @@ When in doubt, return less. Auth-, permission-, or data-export-related changes d
- **Test new behavior.** Every new function, endpoint, driver method, or logic branch gets a test; every bug fix gets a regression test that fails before the fix. If something is genuinely hard to test, skip it but say so in the PR.
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo or dead import in files you're already touching; don't ride a refactor along with a bug fix.
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't defend in review is not.
- **A limit change is not done until the docs change with it.** Every rate limit, concurrency cap, quota, and allowance in the code is published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) — an undisclosed limit is one developers discover as a service failure. If a PR moves any of these numbers, the same PR updates that page. The numbers live in `src/backend/controllers/fs/limits.ts` (filesystem), `src/backend/drivers/util/aiLimits.ts` (all AI drivers), per-driver `rateLimit`/`concurrent` configs, and `src/backend/data/subPolicies/` (free-tier allowances).
---
@@ -91,6 +94,7 @@ Follow the same layered structure inside an extension — unless it only needs a
- Vitest; test files sit next to the code they test (`*.test.ts` / `*.test.js`). Run with `npm run test:backend`.
- **Mock data, not methods.** Stub inputs (fixtures, fake rows, payloads), not the function under test or the layer beneath it — over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service).
- **Prefer the test server over mocking deps.** `setupPuterTestEnv()` in [src/backend/testUtil.ts](src/backend/testUtil.ts) boots a fully in-memory backend; hit a real database/client shape where reasonable — integration shapes catch what mocked unit tests miss.
- **Test code, not docs.** Never write a test that reads a file under `src/docs/` and asserts on its wording or numbers. Docs are kept in step by the PR (see the limits rule above) and checked in review; a test that greps a markdown page fails on every rewording and verifies nothing about behavior.
---
@@ -111,17 +115,15 @@ Declare a shape where it belongs, and reference it with an `import(...)` type fr
Use `@typedef {Object}` + `@property` for any shape whose fields need documenting — it is the only JSDoc form that carries a doc comment per field into the generated declaration. Keep the inline object-literal form for small internal shapes with nothing to say about each field, and prefer `unknown` over `*`:
```js
/** @typedef {{ key: string, value: unknown }} KVEntry */
/** @typedef {{ key: string; value: unknown }} KVEntry */
```
Public (exposed) methods must carry JSDoc types — parameters and return value — with one `@overload` block per accepted call form, since those overloads *are* the published signature. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean. Members tagged `@internal` are stripped from the generated declarations, so use that tag rather than `@private` to keep something off the public surface.
Public (exposed) methods must carry JSDoc types — parameters and return value — with one `@overload` block per accepted call form, since those overloads _are_ the published signature. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean. Members tagged `@internal` are stripped from the generated declarations, so use that tag rather than `@private` to keep something off the public surface.
Typing in JS files is encouraged: annotate with JSDoc `@type`/`@param`/`@returns` using the TypeScript type system, and define shared shapes with `@typedef`. API types must not be `unknown` or untyped `...args` — spell out the real parameter and return shapes; the only exception is values passed through transparently to an upstream layer that owns their type. For example:
```js
/**
* @typedef {{key:string, value: unknown}} KVEntry
*/
/** @typedef {{ key: string; value: unknown }} KVEntry */
/** @type {KVEntry[]} */
let entries = [];
+39 -35
View File
@@ -6,33 +6,33 @@ We at **Puter** are committed to maintaining a secure experience for our users a
The following are in scope for this program:
* **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
* **`puter.com`**
* **`api.puter.com`**
- **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
- **`puter.com`**
- **`api.puter.com`**
Out-of-scope:
* Third-party services, applications, or libraries not maintained by Puter.
* Social engineering attacks (e.g., phishing against staff).
* Denial of Service (DoS), spam, or volumetric attacks.
* Physical security issues.
- Third-party services, applications, or libraries not maintained by Puter.
- Social engineering attacks (e.g., phishing against staff).
- Denial of Service (DoS), spam, or volumetric attacks.
- Physical security issues.
## Known Non-Issues (Please Check Before Submitting)
The following have already been reviewed and determined **not to be vulnerabilities**. Reports that only re-describe one of these are **not eligible for a reward** and will be closed as non-issues — even if they include new code references. Please review this list before submitting:
* **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we *do* evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
* **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
* **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
* **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
* **App metadata or app user-count "leaks".** This information is currently public by design.
* **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
* **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
* **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
* **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
* **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
* **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
* **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
- **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we _do_ evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
- **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
- **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
- **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
- **App metadata or app user-count "leaks".** This information is currently public by design.
- **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
- **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
- **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
- **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
- **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
- **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
- **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
If you believe you have a **genuinely new** exploit chain that defeats one of these rationales (for example, demonstrating a sensitive credential that really is reachable on `api.puter.com`), say so explicitly and show why the reasoning above does not apply.
@@ -40,7 +40,7 @@ If you believe you have a **genuinely new** exploit chain that defeats one of th
To participate, you must:
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see *Proof of Reproduction* below).
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see _Proof of Reproduction_ below).
2. **Do no harm**: Do not exfiltrate, modify, or delete data. Only access your own account or test data.
3. **Respect availability**: Do not perform denial-of-service attacks or automated scans that degrade service.
4. **Follow disclosure policy**: Do not publicly disclose vulnerabilities until we have confirmed and patched the issue.
@@ -51,26 +51,30 @@ Reports that do not meet these guidelines may not be eligible for a reward.
## Proof of Reproduction
Reports must demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
Reports **MUST** demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
* Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
* The **observed** result versus the **expected** result.
* For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
* For server-side bugs: a runnable proof-of-concept.
- Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
- The **observed** result versus the **expected** result.
- For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
- For server-side bugs: a runnable proof-of-concept and screenshot or video showing the attack process.
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and are generally not eligible**. If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and not eligible for bounty**.
If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
Please submit **one issue per report**. Bundled "audit packs" of many speculative findings will be declined; send each confirmed issue separately.
Please submit **one issue per report**.
Bundled "audit packs" of many speculative findings will not be elligible for bounty; send each confirmed issue separately.
Additionally please **DO NOT** submit too many issues at once; these will be treated as a bundled report, and will not be elligible for bounty.
## Reporting Process
To report a vulnerability, email us at: **[[email protected]](mailto:[email protected])**.
Include:
* A description of the vulnerability
* Steps to reproduce
* Potential impact
* Suggested remediation (if available)
- A description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested remediation (if available)
- Paypal id where we can send the bounty (if eligible)
We aim to acknowledge receipt within **72 hours** and provide a resolution timeline.
@@ -78,10 +82,10 @@ We aim to acknowledge receipt within **72 hours** and provide a resolution timel
We offer monetary rewards based on the severity of the vulnerability, as determined by our internal assessment (using CVSS as a guide).
* **Critical: \$1,000 – \$2,000**
* **High: \$500 – \$1,000**
* **Medium: \$200 – \$500**
* **Low: \$50 – \$100**
- **Critical: \$1,000 – \$2,000**
- **High: \$500 – \$1,000**
- **Medium: \$200 – \$500**
- **Low: \$50 – \$100**
Non-security issues, suggestions, and best practices feedback are always welcome, but may not qualify for a reward.
If multiple researchers report the same issue, the bounty will be awarded to the first eligible report we receive.
+2
View File
@@ -32,6 +32,8 @@ Don't commit code you couldn't have written, debugged, or defended yourself. Rea
If you add or change a public API (an endpoint, driver method, or puter-js method), follow [doc/contributing-apis.md](doc/contributing-apis.md) — backward compatibility, [developer docs](src/docs/), types, and tests all move in the same PR.
The same rule applies to limits: rate limits, concurrency caps, quotas, and allowances are published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md), and a PR that changes one of those numbers updates that page in the same PR. A limit nobody published is a limit developers only discover as a service failure.
## 6. Boy Scout Rule — leave it 1% better
![Boy Scout Rule](https://imgs.search.brave.com/DMmIWl5-NuZVtrR9kXBb06AKF8kturkgSW9UMb2-6m4/rs:fit:860:0:0:0/g:ce/aHR0cHM6Ly9sYXdz/b2Zzb2Z0d2FyZWVu/Z2luZWVyaW5nLmNv/bS9pbWFnZXMvbGF3/cy9ib3ktc2NvdXQt/cnVsZS5wbmc)
+2 -7
View File
@@ -12,10 +12,6 @@
·
<a href="https://developer.puter.com" target="_blank">Developers</a>
·
<a href="https://discord.com/invite/PQcx7Teh8u">Discord</a>
·
<a href="https://reddit.com/r/puter">Reddit</a>
·
<a href="https://twitter.com/HeyPuter">X</a>
</p>
@@ -82,10 +78,7 @@ Puter is available as a hosted service at [**puter.com**](https://puter.com).
Connect with the maintainers and community through these channels:
- Bug report or feature request? Please [open an issue](https://github.com/HeyPuter/puter/issues/new/choose).
- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u)
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
- Mastodon: [mastodon.social/@puter](https://mastodon.social/@puter)
- Security issues or abuse reports? [[email protected]](mailto:[email protected])
- Email maintainers at [[email protected]](mailto:[email protected])
@@ -120,7 +113,9 @@ This repository, including all its contents, sub-projects, modules, and componen
- [Korean / 한국어](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ko.md)
- [Malay / Bahasa Malaysia](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.my.md)
- [Malayalam / മലയാളം](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ml.md)
- [Dutch / Nederlands](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.nl.md)
- [Polish / Polski](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pl.md)
- [Odia / ଓଡ଼ିଆ](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.od.md)
- [Portuguese / Português](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pt.md)
- [Punjabi / ਪੰਜਾਬੀ](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.pa.md)
- [Romanian / Română](https://github.com/HeyPuter/puter/blob/main/doc/i18n/README.ro.md)
+3
View File
@@ -22,6 +22,9 @@
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
"storage_capacity": 104857600,
"disable_user_signup": false,
"teams_enabled": false,
"max_teams_per_user": 1,
"max_seats_per_team": 50,
"strict_email_verification_required": false,
"gui_assets_root": "./src/gui",
"puterjs_root": "./src/puter-js/dist",
+70 -3
View File
@@ -251,6 +251,53 @@
}
},
// ── Sharing ─────────────────────────────────────────────────────────
// Email a recipient who already has an account about a new share. On
// unless set to false; they opt out via the unsubscribe link, or by
// blocking a sender. An invite to an address with no account always goes.
// "share_email_notifications": false,
//
// New shares one account may create per UTC day. Default 200.
// "share_daily_limit": 200,
//
// How often a share may interrupt its recipient — the notification pushed
// to their screen and the email with it. Over budget the share still
// succeeds and their notification is still brought up to date; only the
// interruption is dropped. The recipient bounds are what stop many senders
// burying one person between them. A non-positive value removes a bound.
// "share_notify_limits": {
// "pairWindowSeconds": 900,
// "pairDaily": 20,
// "recipientHourly": 10,
// "recipientDaily": 50,
// "emailBatchSeconds": 90
// },
// ── Teams / teams ──────────────────────────────────────────────
// Off by default, and off is the state every existing install stays in.
// The tables ship either way and sit inert; this gates whether `/teams`
// is registered at all, so with it off the paths do not exist rather than
// existing and refusing. It is also the backout: turning it off removes
// the feature without touching data.
"teams_enabled": false,
//
// Live teams one user may own. Soft-deleted ones do not count, so
// deleting yours frees the slot. Default 1.
"max_teams_per_user": 1,
//
// Seats one team may provision. Each seat is a real Puter account
// taking a name from the global username pool, so this is what bounds a
// team's blast radius until per-seat billing lands. Lowering it below
// a team's current seat count blocks new provisioning and disables
// nobody. Default 50.
"max_seats_per_team": 50,
// ── Notifications ───────────────────────────────────────────────────
// How long a notification is kept, in days from creation. Acknowledged or
// not, a row past this is swept. Off by default; any value above 0 arms
// the sweep.
// "notificationRetentionDays": 14,
// ── Alarms / alerting ───────────────────────────────────────────────
// Where system alarms go. Severity is the routing decision — each
// transport takes everything at or above its own `minSeverity`:
@@ -324,6 +371,10 @@
"apiURL": ""
},
"gemini": { "apiKey": "" },
// Meta Model API (Muse Spark). `muse-spark-1.2-contributor`
// trades a ~12x discount for Meta training on its prompts and
// completions, so it is only served to callers that name it.
"meta": { "apiKey": "" },
"groq": { "apiKey": "" },
"deepseek": { "apiKey": "" },
"mistral": { "apiKey": "" },
@@ -341,6 +392,20 @@
"apiKey": "",
"apiBaseUrl": "https://llm.onerouter.pro/v1"
},
// BytePlus ModelArk. One key powers chat (Seed/GLM/DeepSeek), image
// generation (Seedream) and video generation (Seedance); `apiBaseUrl`
// selects the region; see
// https://docs.byteplus.com/en/docs/ModelArk/1330310 for options.
"byteplus": {
"apiKey": "",
"apiBaseUrl": "https://ark.ap-southeast.bytepluses.com/api/v3"
},
"zai": { "apiKey": "" },
"alibaba": { "apiKey": "" },
"hoonify": {
"apiKey": "",
"apiBaseUrl": "https://api.hoonify.ai/v1"
},
"together-ai": { "apiKey": "" },
// Local Ollama. `enabled: false` skips the auto-probe at startup
// (otherwise Puter logs ECONNREFUSED on every boot when no Ollama
@@ -362,7 +427,6 @@
"xai-image-generation": { "apiKey": "" },
// ─ Video generation ─
"openai-video-generation": { "apiKey": "" },
"together-video-generation": { "apiKey": "" },
"gemini-video-generation": { "apiKey": "" },
@@ -448,8 +512,11 @@
// operations that spend it — file transfers, KV calls. Recording is
// unaffected either way. `workers` extends the same refusal to
// worker-driven calls, which are exempt by default because a deployed
// worker has nowhere to surface a payment prompt.
// "meteringEnforcement": { "enabled": true, "workers": false },
// worker has nowhere to surface a payment prompt. `subscriptions` covers
// the separate question of surfaces reserved for paid plans (the
// vendor-compatible AI endpoints): turn it off on a deployment with no
// paid plans, or every account is refused them.
// "meteringEnforcement": { "enabled": true, "workers": false, "subscriptions": true },
// Fleet-wide spend rate, in micro-cents per minute, past which metering
// raises the `metering:excessiveGlobalUsageRate` alarm. Omit it (the
+22 -2
View File
@@ -44,7 +44,15 @@ pass one explicitly unless you really mean "page someone".
event; the 429 is the whole signal, and alarming on it only produces noise
proportional to traffic. An *upstream provider* rate-limiting us is the
opposite case and still alarms (`upstream_rate_limited`, `info`) — that one
is not something we chose.
is not something we chose. The exception is a free model: nothing is billed,
nothing is actionable, and the throttling is the price of the model, so the
AI chat driver marks those `noAlarm` and only paid models still record.
`noAlarm` is the code-level mute: an `HttpError` carrying it skips the
terminal gate entirely, no matter its status or code. It is for failures the
call site *already knows* are expected and traffic-proportional — reach for it
there, not as a way to quiet an alarm you haven't diagnosed
(`severityOverrides` below is the knob for that).
An extension whose signals are all one tier can default its own local
`raiseAlarm` helper to that tier instead of repeating it at every call site —
@@ -70,6 +78,11 @@ this.clients.alarm.create(alarmId, message, fields, 'critical', {
The HTTP error handler uses it: its id is route + error signature, so a hot
loop of the same crash is one incident with N occurrences instead of N pages.
Dedup and Slack's repeat throttle mean a responder sees only the latest
occurrence, so the handler attaches the `HttpError`'s `fields` to the alarm as
`details` — put what a responder needs (which upstreams failed, and how)
there. Everything in `fields` is also returned to the client in the response
body, so it has to be safe to show the caller.
Reach for it anywhere else only when the id is that specific — otherwise a
per-request alarm can flood the pager.
@@ -113,7 +126,14 @@ config always has the last word.
### Repeat throttling
The chat transport won't repost the same alarm id within
Two levels, and they stack.
The client itself backs off before a repeat is reported at all: every
occurrence is reported up to a small burst, then one per interval. A fault
recurring hundreds of times a second still counts every occurrence, but it
does not write a log line or build an alert payload for each one.
Below that, the chat transport won't repost the same alarm id within
`repeatThrottleMs` (default 15 minutes). The first occurrence always posts,
and the next one that gets through reports how many piled up in between —
so a hot loop reads as one message with a count, not a wall of them.
+12
View File
@@ -96,6 +96,18 @@ extension.on('user.signup', (_key, data) => {
});
```
The `app.recommended` event lets extensions customize desktop recommendations.
Its `appNames` array starts as a fresh copy of the default ordered names on each
call. Listeners may mutate or replace it, including setting it to an empty array.
Async listeners are awaited before names are resolved to app summaries; names
that do not exist are skipped.
```ts
extension.on('app.recommended', (_key, data) => {
data.appNames = ['editor', 'camera'];
});
```
## Conventions
- **TypeScript preferred** in new code where feasible. Existing JS is fine; convert opportunistically when you're already touching a file.
+4 -1
View File
@@ -53,7 +53,10 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
#### Middleware and gates
- **Controller routes** (and extension routes — same options) take [`RouteOptions`](../src/backend/core/http/types.ts): auth gates (`requireAuth`, `requireUserActor`, `noUserSession`, `adminOnly`, `allowedAppIds`, and the access-token controls), `subdomain` routing, per-route `rateLimit`, body parsers, and arbitrary extra `middleware`. The auth flavors are subtle and default-deny — read the JSDoc on each field before picking.
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), and `noUserSession`. The `/drivers/call` surface enforces them.
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), `requireSubscription`, `requireReputation`, and `noUserSession`. The `/drivers/call` surface enforces them.
- **A surface only paying accounts should reach** declares `requireSubscription` — the route option on a controller endpoint, the per-method `@Driver({ requireSubscription })` block on a driver (`/drivers/call` is one shared route, so a driver's requirement can't live in route options). `true` accepts any plan that isn't free, so a plan registered by an extension counts without core naming it; an array of policy ids (`['business', 'pro']`) accepts only those; `false` is "no requirement", the same as leaving it out. An empty array is a boot error rather than a silent no-op — it reads as subscribers-only while admitting everyone. Off unless asked for, and answered from the metering service's cached per-actor subscription, so it costs a map lookup. Distinct from `requireCredits`: this asks which plan the account is on, not whether it has budget left. Deployments with no paid plans (self-hosted installs) turn the whole thing off with `meteringEnforcement.subscriptions: false`.
- **A surface only a trusted-enough account should reach** declares `requireReputation` — the route option on a controller endpoint, the per-method `@Driver({ requireReputation })` block on a driver. The value names a tier; what that tier takes is `reputationGate.tiers` in config, so the score a surface is worth is a deployment call and can be retuned without editing the surface. A tier the running config doesn't define is inert and everyone passes — an install that doesn't score its accounts must not start turning traffic away on a score it never computed — and `reputationGate.enabled: false` stops every declared gate at once. Opt-in, implies `requireAuth`, and denies with a bare 403 `reputation_required` that names neither the score nor the tier. Nothing in the tree declares one yet: the mechanism ships ahead of the enrollment.
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for.
- **An endpoint that spends metered resources** on the caller's behalf — moving file content, making object-store requests, anything else the account is billed for — also declares `requireCredits: true`, which turns an account with nothing left of its budget away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to be able to see what it has, clear it, and reach its billing pages. Drivers have no route options to declare this on, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
### 3. puter.js
+17
View File
@@ -122,6 +122,10 @@ cat > puter/config/config.json <<EOF
"ollama": { "enabled": false }
},
"meteringEnforcement": {
"subscriptions": false
},
"trust_proxy": 1
}
EOF
@@ -136,6 +140,7 @@ Why these knobs:
- `database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
- `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
- `meteringEnforcement.subscriptions: false` — a few surfaces (the OpenAI/Anthropic-compatible AI endpoints) are declared paid-plan-only. A self-hosted install has no paid plans, so every account sits on the free policy and those routes would answer `402 subscription_required` for everyone. Turning the plan gates off opens them; nothing else about metering changes.
- `providers.ollama.enabled: false` — Puter auto-probes a local Ollama at `127.0.0.1:11434` by default; without one running you'd see `ECONNREFUSED` on every boot. To run a bundled Ollama, see [Optional: local LLM (Ollama)](#optional-local-llm-ollama) below.
- `s3.s3Config.forcePathStyle: true` — RustFS / MinIO / fauxqs need path-style URLs (`<endpoint>/<bucket>`). Real AWS S3 wants virtual-hosted (`<bucket>.<endpoint>`) — drop this flag (or set `false`) when you swap to real S3.
- `s3.s3Config.publicEndpoint` — `endpoint` (`http://s3:9000`) only resolves inside the docker network; presigned upload/download URLs handed to the browser need a host-reachable URL. Caddy routes the `s3.<domain>` subdomain to RustFS internally and preserves the Host header end-to-end (required for S3 signature validation), so the browser hits the same port/protocol as the rest of the app — no separate published port, no mixed-content surprises when you turn on TLS. Switch to `https://s3.<your-domain>` once you enable TLS in Step 3. Real AWS S3 doesn't need this — its endpoint is already public; drop the field entirely.
@@ -295,6 +300,18 @@ Used for password resets, email confirmation, and notifications. Without it thos
To require email confirmation before login, also set `"strict_email_verification_required": true`.
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
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
```
```json
"email": { "from": "\"Puter\" <[email protected]>", "host": "127.0.0.1", "port": 1025, "secure": false, "ignoreTLS": true }
```
Share email is on once a transport exists: a recipient who already has an account is emailed as well as notified in-app, and they opt out with the unsubscribe link the mail carries. Set `"share_email_notifications": false` to keep those in the app only — an address with no account is still emailed, since there is nothing else to reach them with.
### Sign in with Google (or another OIDC provider)
```json
+40 -1
View File
@@ -9,6 +9,7 @@ import {
vi,
} from 'vitest';
import { runWithContext } from '../src/backend/core/context.ts';
import { extension } from '../src/backend/extensions.ts';
import { PuterServer } from '../src/backend/server.ts';
import { setupTestServer } from '../src/backend/testUtil.ts';
import { handleInstalledApps } from './installedApps.ts';
@@ -51,13 +52,14 @@ const seedUser = async () => {
});
};
const seedApp = async (ownerUserId: number) => {
const seedApp = async (ownerUserId: number, overrides = {}) => {
const slug = Math.random().toString(36).slice(2, 8);
return server.stores.app.create(
{
name: `iaapp_${slug}`,
title: `Installed App ${slug}`,
index_url: `https://example.com/${slug}`,
...overrides,
},
{ ownerUserId },
);
@@ -145,6 +147,43 @@ describe('installedApps extension — handleInstalledApps', () => {
).toBe(false);
});
it('pairs iconUrl with the direct subdomain URL for a generated icon', async () => {
const user = await seedUser();
// The resize pipeline leaves an http(s) icon column behind, which is
// what says the sized PNG is on the icons subdomain.
const app = await seedApp(user.id as number, {
icon: 'https://api.puter.com/app-icon/x',
});
await grantInstalled(app!.id as number, user.id as number);
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleInstalledApps(makeReq({}), res),
);
const list = captured.body as Array<Record<string, unknown>>;
const { protocol, static_hosting_domain } = extension.config;
expect(list[0].iconCdnUrl).toBe(
`${protocol}://puter-app-icons.${static_hosting_domain}/${app!.uid}-256.png`,
);
});
it('reports no subdomain URL for an app with no generated icon', async () => {
const user = await seedUser();
const app = await seedApp(user.id as number);
await grantInstalled(app!.id as number, user.id as number);
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleInstalledApps(makeReq({}), res),
);
const list = captured.body as Array<Record<string, unknown>>;
expect(list[0].iconCdnUrl).toBeNull();
});
it('flags apps with no owner_user_id as external', async () => {
const user = await seedUser();
const slug = Math.random().toString(36).slice(2, 8);
+6 -1
View File
@@ -2,7 +2,10 @@ import type { Request, Response } from 'express';
import { Context } from '@heyputer/backend/src/core';
import { HttpError } from '@heyputer/backend/src/core/http';
import { extension } from '@heyputer/backend/src/extensions';
import { getAppIconUrl } from '@heyputer/backend/src/util/appIcon.js';
import {
getAppIconCdnUrl,
getAppIconUrl,
} from '@heyputer/backend/src/util/appIcon.js';
const clients = extension.import('client');
@@ -73,6 +76,8 @@ export const handleInstalledApps = async (
return {
...rest,
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
// Direct subdomain URL for the client to try before iconUrl.
iconCdnUrl: getAppIconCdnUrl(app, extension.config),
external,
};
}),
+107 -3
View File
@@ -6,6 +6,10 @@ import {
servicesContainers,
} from '@heyputer/backend/src/exports';
import { extension } from '@heyputer/backend/src/extensions';
import {
creditMultiplierFrom,
toCredits,
} from '@heyputer/backend/src/services/metering/utils';
import type { Request, Response } from 'express';
const services = extension.import('service');
@@ -50,6 +54,42 @@ function collectAllCosts(): Record<string, unknown>[] {
return all;
}
// -- Credits scaling ---------------------------------------------------
//
// When the deployment configures a credit multiplier, every monetary amount
// is scaled to display credits BEFORE it leaves the server — raw metered
// amounts are never reported. Counts, units, and byte figures pass through
// untouched. A response that was scaled says so (`unit: 'credits'`); without
// a configured multiplier amounts pass through raw and clients render them
// as dollars.
/** The per-usage-type records of a UsageByType map, costs scaled. */
const scaleUsageByType = (
usage: Record<string, unknown>,
multiplier: number,
): Record<string, unknown> => {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(usage)) {
// `allowanceUsed` is the month's allowance-charged spend — monetary,
// scalar, scaled like the total.
if (key === 'total' || key === 'allowanceUsed') {
out[key] = toCredits(Number(value) || 0, multiplier);
} else if (
value &&
typeof value === 'object' &&
typeof (value as { cost?: unknown }).cost === 'number'
) {
out[key] = {
...(value as Record<string, unknown>),
cost: toCredits((value as { cost: number }).cost, multiplier),
};
} else {
out[key] = value;
}
}
return out;
};
export const handleMeteringUsage = async (
_req: Request,
res: Response,
@@ -61,7 +101,44 @@ export const handleMeteringUsage = async (
services.metering.getActorCurrentMonthUsageDetails(actor),
services.metering.getAllowedUsage(actor),
]);
res.json({ ...actorUsage, allowanceInfo });
const multiplier = creditMultiplierFrom(extension.config);
if (!multiplier) {
res.json({ ...actorUsage, allowanceInfo });
return;
}
res.json({
usage: scaleUsageByType(
actorUsage.usage as unknown as Record<string, unknown>,
multiplier,
),
appTotals: Object.fromEntries(
Object.entries(actorUsage.appTotals).map(([appId, totals]) => [
appId,
{ ...totals, total: toCredits(totals.total, multiplier) },
]),
),
allowanceInfo: {
...allowanceInfo,
remaining: toCredits(allowanceInfo.remaining, multiplier),
monthUsageAllowance: toCredits(
allowanceInfo.monthUsageAllowance,
multiplier,
),
// Monetary addon fields scale; absent ones stay absent rather
// than materializing as NaN/null. purchasedStorage is bytes.
addons: Object.fromEntries(
Object.entries(allowanceInfo.addons ?? {}).map(([k, v]) => [
k,
(k === 'purchasedCredits' ||
k === 'consumedPurchaseCredits') &&
typeof v === 'number'
? toCredits(v, multiplier)
: v,
]),
),
unit: 'credits',
},
});
};
export const handleMeteringUsageForApp = async (
@@ -92,7 +169,18 @@ export const handleMeteringUsageForApp = async (
actor,
appId,
);
res.json(appUsage);
const multiplier = creditMultiplierFrom(extension.config);
if (!multiplier) {
res.json(appUsage);
return;
}
res.json({
...scaleUsageByType(
appUsage as unknown as Record<string, unknown>,
multiplier,
),
unit: 'credits',
});
};
export const handleMeteringGlobalUsage = async (
@@ -109,7 +197,23 @@ export const handleMeteringAllCosts = async (
res: Response,
): Promise<void> => {
if (!cachedAllCosts) {
cachedAllCosts = collectAllCosts();
const raw = collectAllCosts();
const multiplier = creditMultiplierFrom(extension.config);
// Price the catalogue in the same unit the usage endpoints report:
// per-unit credits when a multiplier is configured, raw otherwise.
cachedAllCosts = multiplier
? raw.map(({ ucentsPerUnit, costs_currency: _cc, ...rest }) =>
typeof ucentsPerUnit === 'number'
? {
...rest,
creditsPerUnit: toCredits(
ucentsPerUnit,
multiplier,
),
}
: rest,
)
: raw;
}
res.json({ costs: cachedAllCosts });
};
+123
View File
@@ -9,6 +9,7 @@ import {
vi,
} from 'vitest';
import { runWithContext } from '../src/backend/core/context.ts';
import { configContainer } from '../src/backend/exports.ts';
import { PuterServer } from '../src/backend/server.ts';
import { setupTestServer } from '../src/backend/testUtil.ts';
import { handleWhoami } from './whoami.ts';
@@ -118,6 +119,50 @@ describe('whoami extension — handleWhoami', () => {
expect(body).toHaveProperty('taskbar_items');
});
it('reports the SMS-to-card fallback only once a send has opened it', async () => {
const user = await server.stores.user.create({
username: `wuser_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'hashedpw',
email: `${Math.random().toString(36).slice(2, 8)}@example.com`,
requires_phone_verification: true,
} as never);
const prev = configContainer.phone_verification_card_fallback;
configContainer.phone_verification_card_fallback = {
enabled: true,
} as never;
try {
const before = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleWhoami(makeReq(), before.res),
);
// Phone-gated, but the user has attempts left — no offer yet.
expect(
before.captured.body as Record<string, unknown>,
).toMatchObject({ card_fallback_available: false });
// Exhausting SMS attempts stamps this flag; whoami is then the only
// thing that can still tell a reloading GUI about the offer, since
// further sends are rejected by the route's own rate limit.
await server.stores.kv.set({
key: `card-fallback-open:${user.id}`,
value: true,
});
const after = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleWhoami(makeReq(), after.res),
);
expect(
after.captured.body as Record<string, unknown>,
).toMatchObject({ card_fallback_available: true });
} finally {
configContainer.phone_verification_card_fallback = prev;
}
});
it('only forwards allow-listed feature flags', async () => {
const user = await seedUser();
const { res, captured } = makeRes();
@@ -206,6 +251,84 @@ describe('whoami extension — handleWhoami', () => {
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
});
it('never exposes phone, card fingerprint or signup identity', async () => {
const user = await seedUser();
await server.stores.user.update(user.id as number, {
phone: '+15551234567',
card_fingerprint: 'fp_ABC123',
requires_phone_verification: false,
requires_card_verification: false,
});
// Guard against a vacuous assertion below: the columns really do hold
// the values we then expect never to see on the wire.
const stored = await server.stores.user.getById(user.id as number, {
cached: false,
force: true,
});
expect(stored?.phone).toBe('+15551234567');
expect(stored?.card_fingerprint).toBe('fp_ABC123');
for (const actor of [
{ user: { uuid: user.uuid, id: user.id as number } },
{
user: { uuid: user.uuid, id: user.id as number },
app: { uid: 'app-test-actor' },
},
]) {
const { res, captured } = makeRes();
await runWithContext({ actor }, () =>
handleWhoami(makeReq(), res),
);
const body = captured.body as Record<string, unknown>;
expect(body.phone).toBeUndefined();
expect(body.card_fingerprint).toBeUndefined();
expect(body.password).toBeUndefined();
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, …).
const serialized = JSON.stringify(body);
expect(serialized).not.toContain('5551234567');
expect(serialized).not.toContain('fp_ABC123');
// The verification flags, which the GUI acts on, still ship.
expect(body).toHaveProperty('requires_phone_verification');
expect(body).toHaveProperty('requires_card_verification');
}
});
it('scrubs sensitive keys added to metadata, and leaves the cached row intact', async () => {
const user = await seedUser();
await server.stores.user.updateMetadata(user.id as number, {
tmp_password: 'bootstrap-secret',
billing: { card_fingerprint: 'fp_NESTED', tier: 'pro' },
});
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>;
const metadata = body.metadata as Record<string, unknown>;
expect(metadata.tmp_password).toBeUndefined();
// Nested sensitive keys are removed too; siblings survive.
expect(metadata.billing).toEqual({ tier: 'pro' });
expect(JSON.stringify(body)).not.toContain('fp_NESTED');
// The scrub works on a copy — server-side state is untouched.
const fresh = await server.stores.user.getById(user.id as number, {
cached: false,
force: true,
});
expect(fresh?.metadata?.tmp_password).toBe('bootstrap-secret');
expect(
(fresh?.metadata?.billing as Record<string, unknown>)
?.card_fingerprint,
).toBe('fp_NESTED');
});
it('marks the user as oidc_only when password is null', async () => {
const slug = Math.random().toString(36).slice(2, 8);
const oidcUser = await server.stores.user.create({
+113 -6
View File
@@ -1,5 +1,7 @@
import { Context } from '@heyputer/backend/src/core';
import { extension } from '@heyputer/backend/src/extensions';
import { isCardFallbackEligible } from '@heyputer/backend/src/util/cardFallback.js';
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js';
import type { Request, Response } from 'express';
import TimeAgo from 'javascript-time-ago';
@@ -33,6 +35,68 @@ const CLIENT_VISIBLE_FEATURE_FLAGS: ReadonlySet<string> = new Set([
'prompt_user_when_navigation_away_from_puter',
]);
// Keys that must never leave the server, whoever put them on the response.
// `details` is an explicit pick, but the `whoami.details` event hands
// listeners the full UserRow next to the object they may write to, and
// `metadata` is a free-form blob — so any of these can arrive on the
// response without an edit to the pick above. The scrub runs last, over the
// whole payload, and is the one place that decides what "sensitive" means.
//
// Credentials and single-use tokens, the payment/phone identifiers used for
// verification (`card_fingerprint` is the Stripe fingerprint, stable per
// card number), the network identity recorded at signup, and internal
// anti-abuse bookkeeping. `requires_phone_verification` /
// `requires_card_verification` stay: they are the flags the GUI acts on, and
// they carry no identifier.
const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
'password',
'tmp_password',
'pass_recovery_token',
'email_confirm_code',
'email_confirm_token',
'change_email_confirm_token',
'otp_secret',
'otp_recovery_codes',
'card_fingerprint',
'phone',
'clean_email',
'signup_ip',
'signup_ip_forwarded',
'signup_user_agent',
'signup_origin',
'signup_server',
'audit_metadata',
]);
// Depth-limited, cycle-safe walk deleting every SENSITIVE_KEYS entry it finds
// at any level (`metadata` and `taskbar_items` are both nested structures).
const scrubSensitive = (
value: unknown,
seen: Set<object> = new Set(),
depth = 0,
): void => {
if (depth > 8 || value === null || typeof value !== 'object') return;
if (seen.has(value as object)) return;
seen.add(value as object);
if (Array.isArray(value)) {
for (const entry of value) scrubSensitive(entry, seen, depth + 1);
return;
}
for (const key of Object.keys(value as Record<string, unknown>)) {
if (SENSITIVE_KEYS.has(key)) {
delete (value as Record<string, unknown>)[key];
continue;
}
scrubSensitive(
(value as Record<string, unknown>)[key],
seen,
depth + 1,
);
}
};
export const handleWhoami = async (
req: Request,
res: Response,
@@ -51,13 +115,12 @@ export const handleWhoami = async (
}
const oidcOnly = user.password === null;
const ALLOWED_ICON_SIZES = new Set([16, 32, 64, 128, 256, 512]);
const rawIconSize =
typeof req.query?.icon_size === 'string'
? Number(req.query.icon_size)
: undefined;
const iconSize =
rawIconSize !== undefined && ALLOWED_ICON_SIZES.has(rawIconSize)
rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize)
? rawIconSize
: undefined;
const noIcons = !iconSize;
@@ -74,8 +137,12 @@ export const handleWhoami = async (
}
}
const metadata = user.metadata ? { ...user.metadata } : user.metadata;
if (metadata) delete metadata.tmp_password;
// Deep-copied (it is decoded JSON) so the scrub below edits the response
// and not the cached UserRow. Sensitive keys inside it — tmp_password and
// anything else on the denylist — are removed by scrubSensitive.
const metadata = user.metadata
? structuredClone(user.metadata)
: user.metadata;
const details: Record<string, unknown> = {
username: user.username,
@@ -84,14 +151,47 @@ export const handleWhoami = async (
unconfirmed_email: user.email,
email_confirmed: user.email_confirmed || user.username === 'admin',
requires_email_confirmation: user.requires_email_confirmation,
phone: user.phone,
// The phone number itself is deliberately absent: nothing on the
// client reads it, and it is PII that would otherwise be handed to
// every app actor. Only the verification flag ships.
requires_phone_verification: user.requires_phone_verification,
requires_card_verification: user.requires_card_verification,
// The SMS-to-card escape hatch: true once this user is out of SMS send
// attempts and may verify a card instead. It has to ship from here
// because /send-confirm-phone can no longer say so — by the time the
// fallback opens, further sends are rejected by that route's own rate
// limit before any handler runs, so a page reload would otherwise lose
// an offer that stays valid for 24 hours. Only for user actors, and it
// costs no KV read unless the account is actually phone-gated.
card_fallback_available: isUser
? await isCardFallbackEligible(
extension.config,
user,
async (key) => (await stores.kv.get({ key })).res,
{
smsConfigured: () =>
Boolean(clients.prelude?.isConfigured()),
probeCardVerification: async () => {
const status = { enabled: null as boolean | null };
await clients.event?.emitAndWait(
'puter.card-verification.status',
status,
{},
);
return status.enabled;
},
},
)
: false,
desktop_bg_url: user.desktop_bg_url,
desktop_bg_color: user.desktop_bg_color,
desktop_bg_fit: user.desktop_bg_fit,
is_temp: user.password === null && user.email === null,
is_user_token: true,
// Present only once the account has actually asked for a code (see the
// referral extension) — null until then, and never minted from here:
// this endpoint is polled, and a mint is a write.
referral_code: user.referral_code,
oidc_only: oidcOnly,
taskbar_items: isUser
? await getTaskbarItems(
@@ -101,6 +201,7 @@ export const handleWhoami = async (
stores,
services,
apiBaseUrl: String(extension.config.api_base_url ?? ''),
config: extension.config,
},
{ iconSize, noIcons },
)
@@ -172,6 +273,8 @@ export const handleWhoami = async (
delete details.created_ts;
delete details.is_user_token;
delete details.metadata;
// An app has no business reading the code its user earns credit with.
delete details.referral_code;
}
if (actor.app) {
@@ -189,13 +292,17 @@ 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;
delete subscription.offering.price_id;
}
// Last word on what ships, after every listener has had its say.
scrubSensitive(details);
res.json(details);
};
+9
View File
@@ -131,6 +131,11 @@ S3_SECRET_KEY=$S3_SECRET_KEY
S3_BUCKET=puter-local
EOF
# `meteringEnforcement.subscriptions` is off here because a self-hosted
# install has no paid plans to be on: every account resolves to the free
# policy, so leaving the plan gates armed would 402 the surfaces that
# declare one (the OpenAI/Anthropic-compatible AI endpoints) for everybody.
# The code default is on — this is a property of the install, not of Puter.
log "writing puter/config/config.json"
cat > puter/config/config.json <<EOF
{
@@ -191,6 +196,10 @@ EOF
"ollama": { "enabled": false }
},
"meteringEnforcement": {
"subscriptions": false
},
"trust_proxy": $PUTER_TRUST_PROXY
}
EOF
+2673 -3943
View File
File diff suppressed because it is too large. Load diff
+15 -26
View File
@@ -13,14 +13,9 @@
"@babel/core": "^7.29.7",
"@eslint/js": "^9.35.0",
"@playwright/test": "^1.56.1",
"@stylistic/eslint-plugin": "^5.3.1",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.0",
"@types/mime-types": "^3.0.1",
"@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.46.1",
"@vitest/coverage-v8": "^4.0.14",
"@vitest/ui": "^4.0.14",
"babel-loader": "^10.1.1",
"babel-plugin-istanbul": "^8.0.0",
"chalk": "^4.1.0",
@@ -30,25 +25,19 @@
"eslint": "^9.35.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-rule-composer": "^0.3.0",
"globals": "^15.15.0",
"html-webpack-plugin": "^5.6.0",
"husky": "^9.1.7",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"license-check-and-add": "^4.0.5",
"nodemon": "^3.1.0",
"prettier": "^3.8.3",
"prettier-plugin-jsdoc": "^1.8.1",
"simple-git": "^3.32.3",
"typescript": "^5.4.5",
"uglify-js": "^3.17.4",
"vite-plugin-static-copy": "^3.3.0",
"vitest": "^4.1.5",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.1",
"yaml": "^2.8.1"
"webpack-cli": "^5.1.1"
},
"scripts": {
"test:backend": "npm run setupExtensions && npm run build:workerLib && vitest run --config src/backend/vitest.config.ts ",
@@ -73,9 +62,7 @@
"setupExtensions": "node ./tools/extensionSetup.mjs"
},
"workspaces": [
"src/*",
"tools/*",
"experiments/js-parse-and-output"
"src/*"
],
"nodemonConfig": {
"ext": "js, json, mjs, jsx, svg, css",
@@ -85,14 +72,11 @@
]
},
"dependencies": {
"@ai-sdk/openai": "^3.0.25",
"@aws-sdk/client-s3": "^3.1020.0",
"@aws-sdk/s3-request-presigner": "^3.1028.0",
"@heyputer/putility": "^1.0.2",
"ai": "^6.0.73",
"dedent": "^1.5.3",
"javascript-time-ago": "^2.5.11",
"libphonenumber-js": "1.13.6",
"libphonenumber-js": "1.13.12",
"miniflare": "^4.20260617.1",
"open": "^10.1.0"
},
@@ -101,12 +85,17 @@
},
"allowScripts": {
"@google/genai": false,
"bcrypt@5.1.1": true,
"better-sqlite3@12.11.1": true,
"browser-tabs-lock@1.3.0": true,
"classic-level@3.0.0": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true
"bcrypt": true,
"better-sqlite3": true,
"browser-tabs-lock": true,
"classic-level": true,
"esbuild": true,
"fsevents": true,
"protobufjs": true,
"sharp": true,
"workerd": true
},
"overrides": {
"undici": "^7.29.0"
}
}
+54 -4
View File
@@ -375,18 +375,18 @@ describe('AlarmClient alarm registry', () => {
const client = makeClient();
const seen = capture(client);
for (let i = 0; i < 50; i++) {
for (let i = 0; i < 500; i++) {
client.create('hot', `occurrence ${i}`, { i });
}
const alarm = client.get('hot')!;
expect(alarm.count).toBe(50);
expect(alarm.count).toBe(500);
expect(alarm.occurrences).toHaveLength(20);
expect(alarm.timestamps).toHaveLength(20);
// The window kept is the most recent one, not the oldest.
expect(alarm.occurrences[19].message).toBe('occurrence 49');
expect(alarm.occurrences[19].message).toBe('occurrence 499');
// Trimming history must not rewind what the transports are told.
expect(seen[49]).toMatchObject({ repeatCount: 50, isRepeat: true });
expect(seen.at(-1)).toMatchObject({ repeatCount: 500, isRepeat: true });
});
it('names anonymous handlers by their registration order', () => {
@@ -519,3 +519,53 @@ describe('AlarmClient known-error rules', () => {
expect(chat[0].severity).toBe('info');
});
});
describe('AlarmClient repeat reporting', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
pdEvent.mockClear();
});
it('reports every occurrence up to the burst, then backs off', () => {
const client = makeClient();
const seen = capture(client);
for (let i = 0; i < 12; i++) client.create('flap', 'same fault');
expect(seen).toHaveLength(10);
expect(seen[9].repeatCount).toBe(10);
});
it('reports again once the interval is reached', () => {
const client = makeClient();
const seen = capture(client);
for (let i = 0; i < 500; i++) client.create('flap', 'same fault');
expect(seen).toHaveLength(11);
expect(seen[10].repeatCount).toBe(500);
});
it('keeps a fast-repeating fault out of the log after the burst', () => {
const client = makeClient();
capture(client);
const warn = vi.mocked(console.warn);
warn.mockClear();
for (let i = 0; i < 100; i++) client.create('flap', 'same fault');
// Occurrences 2-10 only; the first is an ACTIVE line on console.error.
expect(warn.mock.calls).toHaveLength(9);
});
it('skips the payload entirely when no transport accepts the severity', () => {
const client = makeClient();
const seen = capture(client, 'critical');
client.create('quiet', 'nobody is listening', {}, 'info');
expect(seen).toHaveLength(0);
});
});
+27
View File
@@ -69,6 +69,15 @@ const FALLBACK_SEVERITY: PagerSeverity = 'critical';
* and `count` keeps that.
*/
const OCCURRENCE_HISTORY_LIMIT = 20;
// A fast-recurring fault repeats far faster than it is useful to report, and
// every report allocates a log line and a payload. Report every occurrence up
// to the burst, then one per interval; `count` still tracks all of them.
const REPEAT_REPORT_BURST = 10;
const REPEAT_REPORT_INTERVAL = 500;
const shouldReportRepeat = (count: number): boolean =>
count <= REPEAT_REPORT_BURST || count % REPEAT_REPORT_INTERVAL === 0;
/** Keeps `info` alarms out of the paging system unless config says otherwise. */
const DEFAULT_PAGERDUTY_MIN_SEVERITY: PagerSeverity = 'warning';
/** Slack's ceiling once a pager exists: chat gets what doesn't page. */
@@ -456,6 +465,8 @@ export class AlarmClient extends PuterClient {
private handleRepeat(alarm: Alarm): void {
this.applyKnownErrors(alarm);
if (!shouldReportRepeat(alarm.count)) return;
console.warn(
`[alarm] REPEAT ${displayId(alarm)} :: ${alarm.message} (${alarm.count})`,
);
@@ -483,6 +494,18 @@ export class AlarmClient extends PuterClient {
);
}
private hasHandlerFor(severity: PagerSeverity): boolean {
for (const { minSeverity, maxSeverity } of this.alertHandlers) {
if (
meetsMinSeverity(severity, minSeverity) &&
withinMaxSeverity(severity, maxSeverity)
) {
return true;
}
}
return false;
}
private dispatchAlert(alarm: Alarm): void {
const resolved = this.resolveSeverity(alarm);
if (resolved === 'mute') {
@@ -494,6 +517,10 @@ export class AlarmClient extends PuterClient {
}
alarm.severity = resolved;
// Build nothing for a severity no transport accepts — the payload
// (cleaned fields plus the stack, twice) is the expensive part.
if (!this.hasHandlerFor(resolved)) return;
const fieldsClean = cleanFields(alarm.fields);
const repeatCount = alarm.count;
@@ -18,7 +18,7 @@
*/
import Database from 'better-sqlite3';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -27,7 +27,15 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
const CURRENT_SCHEMA_VERSION = 61;
const CURRENT_SCHEMA_VERSION = 79;
/**
* These suites migrate real files on disk. Idle they finish in well under a
* second, but the whole migration chain runs against the filesystem — enough
* that a loaded runner blows the 5s default and reports a timeout where there
* is no fault.
*/
const DISK_MIGRATION_TIMEOUT_MS = 30_000;
const SYSTEM_USER_UUID = '5d4adce0-a381-4982-9c02-6e2540026238';
const sqliteConfig = (
@@ -52,7 +60,7 @@ const userVersionOf = async (client: SqliteDatabaseClient): Promise<number> => {
return row.user_version as number;
};
describe('SqliteDatabaseClient — boot and migrations', () => {
describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATION_TIMEOUT_MS }, () => {
let client: SqliteDatabaseClient;
beforeEach(async () => {
@@ -73,6 +81,250 @@ describe('SqliteDatabaseClient — boot and migrations', () => {
expect(await userVersionOf(client)).toBe(CURRENT_SCHEMA_VERSION);
});
it('stamps a version whose migration did not run (known off-by-one)', async () => {
// Existing behaviour, not an endorsement: the stamp overshoots the
// applied work by one entry, so 70 is reported without 0074 running.
const partial = await bootClient({ targetVersion: 70 });
try {
expect(await userVersionOf(partial)).toBe(70);
await expect(
partial.read(
"SELECT 1 FROM pragma_table_info('group') WHERE name = 'handle'",
),
).resolves.toEqual([]);
} finally {
partial.onServerShutdown();
}
});
it('applies the team columns and indexes from 0076', async () => {
const columnsOf = async (table: string) =>
(
(await client.read(
`SELECT name FROM pragma_table_info('${table}')`,
)) as { name: string }[]
).map((r) => r.name);
expect(await columnsOf('group')).toEqual(
expect.arrayContaining([
'kind',
'name',
'handle',
'deleted_at',
]),
);
expect(await columnsOf('jct_user_group')).toContain('org_owned');
expect(await columnsOf('user')).toContain('requires_password_change');
const indexes = (await client.read(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name IN (?, ?, ?)",
['idx_group_handle', 'idx_group_owner', 'idx_jct_user_group_group'],
)) as { name: string }[];
expect(indexes.map((r) => r.name).sort()).toEqual([
'idx_group_handle',
'idx_group_owner',
'idx_jct_user_group_group',
]);
});
it('applies the event_subscriptions indexes from 0081', async () => {
const indexes = (await client.read(
"SELECT name FROM sqlite_master WHERE type = 'index' AND name IN (?, ?, ?)",
[
'idx_event_subscriptions_app_handler',
'idx_event_subscriptions_expires',
'idx_event_subscriptions_suspended',
],
)) as { name: string }[];
expect(indexes.map((r) => r.name).sort()).toEqual([
'idx_event_subscriptions_app_handler',
'idx_event_subscriptions_expires',
'idx_event_subscriptions_suspended',
]);
});
it('keeps `handle` unique but lets the seeded groups share a NULL one', async () => {
const seeded = (await client.read(
'SELECT COUNT(*) AS n FROM `group` WHERE `handle` IS NULL',
)) as { n: number }[];
expect(seeded[0].n).toBeGreaterThan(1);
await client.write(
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
['taken'],
);
await expect(
client.write(
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MAX(`id`) FROM `group`)',
['taken'],
),
).rejects.toThrow(/UNIQUE/iu);
});
it('treats `handle` case-insensitively, as usernames are treated', async () => {
await client.write(
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
['design-team'],
);
// Without NOCASE this differs per engine: mysql rejects, the others accept.
await expect(
client.write(
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MAX(`id`) FROM `group`)',
['Design-Team'],
),
).rejects.toThrow(/UNIQUE/iu);
});
it('matches a handle case-insensitively on lookup, not just on insert', async () => {
// Index-only NOCASE would leave `WHERE handle = ?` case-sensitive here while
// mysql's utf8mb4_unicode_ci column matched -- one query, two behaviours.
await client.write(
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
['design-team'],
);
await expect(
client.read('SELECT `handle` FROM `group` WHERE `handle` = ?', [
'Design-Team',
]),
).resolves.toEqual([{ handle: 'design-team' }]);
});
it('rejects a duplicate membership pair after 0078', async () => {
const [{ user_id, group_id }] = (await client.read(
'SELECT (SELECT MIN(`id`) FROM `user`) AS user_id, ' +
'(SELECT MIN(`id`) FROM `group`) AS group_id',
)) as { user_id: number; group_id: number }[];
const insert = () =>
client.write(
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
[user_id, group_id],
);
await insert();
// `GroupStore.addUsers` has no conflict clause, so before 0075 this
// second call silently doubled every group permission the user reads.
await expect(insert()).rejects.toThrow(/UNIQUE/iu);
});
it('applies the audit table and group-share columns from 0079', async () => {
const columns = (await client.read(
"SELECT name FROM pragma_table_info('audit_team_membership')",
)) as { name: string }[];
expect(columns.map((r) => r.name)).toEqual([
'id',
'group_id',
'group_id_keep',
'user_id',
'user_id_keep',
'actor_user_id',
'action',
'reason',
'created_at',
]);
const shareColumns = (await client.read(
"SELECT name FROM pragma_table_info('share')",
)) as { name: string }[];
expect(shareColumns.map((r) => r.name)).toContain('holder_group_id');
const indexes = (await client.read(
"SELECT name FROM sqlite_master WHERE type = 'index' " +
'AND (name LIKE ? OR name LIKE ?)',
['idx_audit_team_membership%', 'idx_share_holder_group%'],
)) as { name: string }[];
expect(indexes.map((r) => r.name).sort()).toEqual([
// The `_fk` three stop a user or group delete scanning the audit table.
'idx_audit_team_membership_actor_fk',
'idx_audit_team_membership_group',
'idx_audit_team_membership_group_fk',
'idx_audit_team_membership_user',
'idx_audit_team_membership_user_fk',
'idx_share_holder_group',
'idx_share_holder_group_entry_issuer',
]);
});
it('keeps an audit row after its group is deleted, blanking only the FK', async () => {
// 0043 leaves `PRAGMA foreign_keys = ON`, so this is real behaviour.
const [group] = (await client.read(
'SELECT MIN(`id`) AS id FROM `group`',
)) as { id: number }[];
const [user] = (await client.read(
'SELECT MIN(`id`) AS id FROM `user`',
)) as { id: number }[];
await client.write(
'INSERT INTO `audit_team_membership` ' +
'(`group_id`, `group_id_keep`, `user_id`, `user_id_keep`, ' +
'`actor_user_id`, `action`) VALUES (?, ?, ?, ?, ?, ?)',
[group.id, group.id, user.id, user.id, user.id, 'reset_member_password'],
);
await client.write('DELETE FROM `group` WHERE `id` = ?', [group.id]);
await expect(
client.read(
'SELECT `group_id`, `group_id_keep`, `action` FROM `audit_team_membership`',
),
).resolves.toEqual([
{
group_id: null,
group_id_keep: group.id,
action: 'reset_member_password',
},
]);
});
it('constrains team shares that the user-holder index cannot', async () => {
const [user] = (await client.read(
'SELECT MIN(`id`) AS id FROM `user`',
)) as { id: number }[];
const groups = (await client.read(
'SELECT `id` FROM `group` ORDER BY `id` LIMIT 2',
)) as { id: number }[];
await client.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `user_id`, `modified`) ' +
'VALUES (?, ?, ?, ?)',
['11111111-1111-4111-8111-111111111111', 'shared.txt', user.id, 0],
);
const [entry] = (await client.read(
'SELECT `id` FROM `fsentries` WHERE `uuid` = ?',
['11111111-1111-4111-8111-111111111111'],
)) as { id: number }[];
const insertShare = (uid: string, groupId: number) =>
client.write(
'INSERT INTO `share` ' +
'(`uid`, `issuer_user_id`, `recipient_email`, `holder_user_id`, ' +
'`holder_group_id`, `fsentry_id`) VALUES (?, ?, ?, NULL, ?, ?)',
[uid, user.id, '[email protected]', groupId, entry.id],
);
await insertShare('share-team-a', groups[0].id);
// Different group, same file and issuer -- allowed.
await insertShare('share-team-b', groups[1].id);
// Both have `holder_user_id` NULL, so the user-holder index permitted them.
await expect(insertShare('share-team-c', groups[0].id)).rejects.toThrow(
/UNIQUE/iu,
);
});
it('matches a handle case-insensitively on lookup, not just on insert', async () => {
// Index-only NOCASE would leave `WHERE handle = ?` case-sensitive here while
// mysql's utf8mb4_unicode_ci column matched -- one query, two behaviours.
await client.write(
'UPDATE `group` SET `handle` = ? WHERE `id` = (SELECT MIN(`id`) FROM `group`)',
['design-team'],
);
await expect(
client.read('SELECT `handle` FROM `group` WHERE `handle` = ?', [
'Design-Team',
]),
).resolves.toEqual([{ handle: 'design-team' }]);
});
it('runs the javascript migrations, not just the .sql ones', async () => {
// The `system` user only exists because 0025 (a .dbmig.js file) ran
// inside the migration VM.
@@ -83,6 +335,289 @@ describe('SqliteDatabaseClient — boot and migrations', () => {
expect(rows).toEqual([{ username: 'system' }]);
});
// 0070 sweeps three by-hand groups no code reads. Both tables it can reach
// cascade on delete, so the guard is the point: a group that still carries
// permissions or members has to survive, or the sweep silently revokes
// them. Re-running the statement is safe — it is a plain conditional DELETE.
describe('0070 drop-orphaned-default-groups', () => {
const MIGRATION = readFileSync(
new URL(
'./migrations/sqlite/0070_drop-orphaned-default-groups.sql',
import.meta.url,
),
'utf8',
);
const seedGroup = async (name: string): Promise<number> => {
const [system] = await client.read(
'SELECT `id` FROM `user` WHERE `uuid` = ?',
[SYSTEM_USER_UUID],
);
const uid = `grp-${name}-${Math.random().toString(36).slice(2, 10)}`;
await client.write(
'INSERT INTO `group` (`uid`, `owner_user_id`, `extra`, `metadata`) ' +
'VALUES (?, ?, ?, ?)',
[uid, system.id, JSON.stringify({ name }), '{}'],
);
const [row] = await client.read(
'SELECT `id` FROM `group` WHERE `uid` = ?',
[uid],
);
return Number(row.id);
};
const stillThere = async (id: number): Promise<boolean> => {
const rows = await client.read(
'SELECT `id` FROM `group` WHERE `id` = ?',
[id],
);
return rows.length > 0;
};
it('drops an orphaned freeai/experimental/dangerous group', async () => {
const ids = await Promise.all(
['freeai', 'experimental', 'dangerous'].map(seedGroup),
);
await client.write(MIGRATION);
for (const id of ids) expect(await stillThere(id)).toBe(false);
});
it('spares one that still carries a permission row', async () => {
const id = await seedGroup('freeai');
const [system] = await client.read(
'SELECT `id` FROM `user` WHERE `uuid` = ?',
[SYSTEM_USER_UUID],
);
await client.write(
'INSERT INTO `user_to_group_permissions` ' +
'(`user_id`, `group_id`, `permission`, `extra`) VALUES (?, ?, ?, ?)',
[system.id, id, 'service:some-paid-thing', '{}'],
);
await client.write(MIGRATION);
expect(await stillThere(id)).toBe(true);
// And the grant it carries is still intact, not cascaded away.
const perms = await client.read(
'SELECT `permission` FROM `user_to_group_permissions` WHERE `group_id` = ?',
[id],
);
expect(perms).toEqual([{ permission: 'service:some-paid-thing' }]);
});
it('spares one that still has members', async () => {
const id = await seedGroup('experimental');
const [system] = await client.read(
'SELECT `id` FROM `user` WHERE `uuid` = ?',
[SYSTEM_USER_UUID],
);
await client.write(
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
[system.id, id],
);
await client.write(MIGRATION);
expect(await stillThere(id)).toBe(true);
});
it('leaves the groups the platform actually depends on alone', async () => {
// system, admin, user and temp are named by config or code.
await client.write(MIGRATION);
const rows = await client.read(
"SELECT json_extract(`extra`, '$.name') AS name FROM `group`",
);
const names = rows.map((r) => String(r.name));
for (const name of ['system', 'admin', 'user']) {
expect(names).toContain(name);
}
});
});
// 0072 rebuilds `notification` for the scope tuple, and picks up the
// user_id index and the delete cascade mysql and postgres already had.
describe('0072 notification-scope', () => {
// Only the backfill re-runs: the rebuild half is once-only by
// construction, but mysql and postgres replay their whole file on
// every boot, so these statements have to be safe twice.
const BACKFILL = readFileSync(
new URL(
'./migrations/sqlite/0072_notification-scope.sql',
import.meta.url,
),
'utf8',
)
.split(/;\s*\n/)
.map((s) => s.trim())
.filter((s) => /(^|\n)UPDATE /.test(s));
const seedUser = async (): Promise<number> => {
const name = `notif-${Math.random().toString(36).slice(2, 10)}`;
await client.write(
'INSERT INTO `user` (`username`, `uuid`) VALUES (?, ?)',
[name, `${name}-uuid`],
);
const [row] = await client.read(
'SELECT `id` FROM `user` WHERE `username` = ?',
[name],
);
return Number(row.id);
};
const seedLegacy = async (
userId: number,
value: Record<string, unknown> | string,
): Promise<string> => {
const uid = `n-${Math.random().toString(36).slice(2, 12)}`;
await client.write(
'INSERT INTO `notification` (`uid`, `user_id`, `value`) VALUES (?, ?, ?)',
[
uid,
userId,
typeof value === 'string' ? value : JSON.stringify(value),
],
);
return uid;
};
const scopeOf = async (uid: string) => {
const [row] = await client.read(
'SELECT `type`, `audience`, `app_uid` FROM `notification` WHERE `uid` = ?',
[uid],
);
return row;
};
const runBackfill = async () => {
for (const stmt of BACKFILL) await client.write(stmt);
};
it('carries every backfill statement', () => {
expect(BACKFILL).toHaveLength(3);
});
it('classifies the markers legacy rows actually carried', async () => {
const userId = await seedUser();
const received = await seedLegacy(userId, {
source: 'sharing',
template: 'file-shared-with-you',
});
const claimed = await seedLegacy(userId, {
source: 'sharing',
template: 'file-shared-before-you-joined',
});
const deployed = await seedLegacy(userId, {
source: 'worker',
title: 'Successfully deployed https://x.puter.work',
template: 'user-requesting-share',
});
const failed = await seedLegacy(userId, {
source: 'worker',
title: 'Failed to deploy x! boom',
template: 'user-requesting-share',
});
await runBackfill();
expect(await scopeOf(received)).toEqual({
type: 'share.received',
audience: 'account',
app_uid: null,
});
expect(await scopeOf(claimed)).toEqual({
type: 'share.claimed',
audience: 'account',
app_uid: null,
});
// The app is unrecoverable — the payload only ever named a worker.
expect(await scopeOf(deployed)).toEqual({
type: 'app.worker.deployed',
audience: 'developer',
app_uid: null,
});
expect(await scopeOf(failed)).toEqual({
type: 'app.worker.deployFailed',
audience: 'developer',
app_uid: null,
});
});
it('leaves anything it does not recognise reading as legacy', async () => {
const userId = await seedUser();
const unknown = await seedLegacy(userId, {
source: 'sharing',
template: 'something-else',
});
const bare = await seedLegacy(userId, { title: 'hi' });
const notJson = await seedLegacy(userId, 'plain text');
await runBackfill();
for (const uid of [unknown, bare, notJson]) {
expect(await scopeOf(uid)).toEqual({
type: '',
audience: 'account',
app_uid: null,
});
}
});
it('does not reclassify on replay', async () => {
const userId = await seedUser();
const uid = await seedLegacy(userId, {
source: 'sharing',
template: 'file-shared-with-you',
});
await runBackfill();
// A row already classified as something else must survive a
// second pass untouched.
await client.write(
'UPDATE `notification` SET `audience` = ?, `app_uid` = ? WHERE `uid` = ?',
['app-user', 'app-1234', uid],
);
await runBackfill();
expect(await scopeOf(uid)).toEqual({
type: 'share.received',
audience: 'app-user',
app_uid: 'app-1234',
});
});
it('indexes user_id and the scope tuple', async () => {
const rows = await client.read('PRAGMA index_list(`notification`)');
const names = rows.map((r) => String(r.name));
expect(names).toContain('idx_notification_user_id');
expect(names).toContain('idx_notification_scope');
});
it('retires a deleted user rows', async () => {
const [{ foreign_keys: enforcing }] = await client.read(
'PRAGMA foreign_keys',
);
expect(enforcing).toBe(1);
const userId = await seedUser();
const uid = await seedLegacy(userId, { title: 'orphan-to-be' });
await client.write('DELETE FROM `user` WHERE `id` = ?', [userId]);
expect(
await client.read(
'SELECT `uid` FROM `notification` WHERE `uid` = ?',
[uid],
),
).toEqual([]);
});
});
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(
'idx_notification_created_at',
);
});
it('leaves an already-migrated database untouched on a second boot', async () => {
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-'));
const path = join(dir, 'nested', 'puter.sqlite');
@@ -116,6 +651,66 @@ describe('SqliteDatabaseClient — boot and migrations', () => {
}
});
it('deduplicates pre-existing membership rows when 0078 applies', async () => {
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-dedup-'));
const path = join(dir, 'puter.sqlite');
try {
// Stop at 67 so the duplicates exist the way a live database's do:
// written before the unique index, not after it.
const before = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path, targetVersion: 67 }),
);
await before.onServerStart();
const [{ user_id, group_id }] = (await before.read(
'SELECT (SELECT MIN(`id`) FROM `user`) AS user_id, ' +
'(SELECT MIN(`id`) FROM `group`) AS group_id',
)) as { user_id: number; group_id: number }[];
for (const pair of [
[user_id, group_id],
[user_id, group_id],
[user_id, group_id],
[user_id, group_id + 1],
]) {
await before.write(
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
pair,
);
}
const seeded = (await before.read(
'SELECT `id`, `group_id` FROM `jct_user_group` ORDER BY `id`',
)) as { id: number; group_id: number }[];
expect(seeded).toHaveLength(4);
before.onServerShutdown();
const after = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path }),
);
await after.onServerStart();
expect(await userVersionOf(after)).toBe(CURRENT_SCHEMA_VERSION);
// Three copies collapse to the lowest id; the distinct pair is left
// alone. Losing the higher ids costs nothing -- `addUsers` writes
// only the two id columns, so `extra` and `metadata` are NULL.
await expect(
after.read(
'SELECT `id`, `group_id` FROM `jct_user_group` ORDER BY `id`',
),
).resolves.toEqual([seeded[0], seeded[3]]);
await expect(
after.write(
'INSERT INTO `jct_user_group` (`user_id`, `group_id`) VALUES (?, ?)',
[user_id, group_id],
),
).rejects.toThrow(/UNIQUE/iu);
after.onServerShutdown();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('stops early at a configured target version', async () => {
const partial = await bootClient({ targetVersion: 5 });
try {
@@ -137,7 +732,10 @@ describe('SqliteDatabaseClient — boot and migrations', () => {
});
});
describe('SqliteDatabaseClient — legacy version inference', () => {
describe(
'SqliteDatabaseClient — legacy version inference',
{ timeout: DISK_MIGRATION_TIMEOUT_MS },
() => {
let dir: string;
beforeEach(() => {
@@ -95,6 +95,24 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[58, ['0063_add_suspended_reason.sql']],
[59, ['0064_abuse-moderation-events.sql']],
[60, ['0065_app-feedback.sql']],
[61, ['0066_owned-email-unique.sql']],
[62, ['0067_share_entries.sql']],
[63, ['0068_referral-code-unique.sql']],
[64, ['0069_user-block.sql']],
[65, ['0070_drop-orphaned-default-groups.sql']],
[66, ['0071_share_issuer_index.sql']],
[67, ['0072_notification-scope.sql']],
[68, ['0073_notification-created-at.sql']],
[69, ['0074_add_user_home.sql']],
[70, ['0075_event-subscriptions.sql']],
[71, ['0076_event-handlers.sql']],
[72, ['0077_teams.sql']],
[73, ['0078_jct-user-group-pair-unique.sql']],
[74, ['0079_team-audit-and-group-shares.sql']],
[75, ['0080_kv-share-handles.sql']],
[76, ['0081_event-subscriptions-indexes.sql']],
[77, ['0082_temp-password-expiry.sql']],
[78, ['0083_team-directory.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -1060,6 +1060,7 @@ CREATE TABLE IF NOT EXISTS `user` (
`signup_server` varchar(255) DEFAULT NULL,
`metadata` json DEFAULT (json_object()),
`reputation` smallint DEFAULT '100',
`home` varchar(64) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uid` (`uuid`),
UNIQUE KEY `username` (`username`),
@@ -1151,6 +1152,7 @@ CALL _puter_add_col('user', 'signup_user_agent', '`signup_user_agent` varchar(51
CALL _puter_add_col('user', 'signup_origin', '`signup_origin` varchar(255) DEFAULT NULL');
CALL _puter_add_col('user', 'signup_server', '`signup_server` varchar(255) DEFAULT NULL');
CALL _puter_add_col('user', 'metadata', '`metadata` json DEFAULT (json_object())');
CALL _puter_add_col('user', 'home', '`home` varchar(64) DEFAULT NULL');
CALL _puter_add_col('user', 'reputation', '`reputation` smallint DEFAULT ''100''');
--
@@ -0,0 +1,128 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Enforce "at most one account owns an email address". Mirrors SQLite
-- migration 0066.
--
-- `user.email` is deliberately not UNIQUE: several rows may legitimately hold
-- the same address while unconfirmed (admin-provisioned placeholders, signups
-- that were never confirmed, temp accounts on their way to becoming real). What
-- must never happen is two rows both *owning* an address — owning meaning the
-- row is confirmed, or holds a password and so can drive password recovery for
-- that inbox.
--
-- Signup, save-account, change-email, OIDC and admin provisioning each check for
-- an owner before writing, but a check and an insert are not one operation: two
-- requests can both read "free" and both write. This index is what actually
-- holds the invariant; the application checks just produce a nicer error most of
-- the time.
--
-- SQLite expresses that with a partial index. MySQL has none, so the predicate
-- lives in a generated column that evaluates to NULL for every row that does not
-- own its address — and NULLs do not collide in an InnoDB unique index, which is
-- exactly the "unlimited unconfirmed placeholders" behaviour we need.
--
-- The column is VIRTUAL, not STORED, on purpose: adding a stored generated
-- column rebuilds the table, while a virtual one is a metadata-only change and
-- the index that follows builds INPLACE. On a `user` table of any size with
-- read replicas attached, that is the difference between a routine change and an
-- outage. The ALGORITHM/LOCK clauses are spelled out so a server that cannot
-- honour them refuses the statement instead of quietly copying the table.
--
-- Matching is on the canonical address so provider aliases
-- (`[email protected]` vs `[email protected]`) collide. `clean_email` is
-- written on every modern write path; the COALESCE covers rows old enough to
-- predate the column. Run the `clean_email` backfill before this migration or
-- alias collisions among those rows go unnoticed.
--
-- Idempotent: both steps are guarded on INFORMATION_SCHEMA so the directory
-- replays safely.
--
-- If the index creation fails with ER_DUP_ENTRY, the DB already contains
-- duplicate owners. Collapse them first (admin → One-off Jobs → Collapse
-- Duplicate Emails) — there is no safe automatic merge of two accounts.
DROP PROCEDURE IF EXISTS _puter_add_owned_email;
DELIMITER //
CREATE PROCEDURE _puter_add_owned_email()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND COLUMN_NAME = 'owned_email'
) THEN
ALTER TABLE `user`
ADD COLUMN `owned_email` VARCHAR(256)
CHARACTER SET latin1 COLLATE latin1_swedish_ci
GENERATED ALWAYS AS (
CASE
WHEN `email` IS NOT NULL
AND (`email_confirmed` = 1 OR `password` IS NOT NULL)
THEN COALESCE(`clean_email`, LOWER(`email`))
ELSE NULL
END
) VIRTUAL,
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_owned_email'
) THEN
ALTER TABLE `user`
ADD UNIQUE KEY `idx_user_owned_email` (`owned_email`),
ALGORITHM=INPLACE, LOCK=NONE;
END IF;
END//
DELIMITER ;
CALL _puter_add_owned_email();
DROP PROCEDURE IF EXISTS _puter_add_owned_email;
-- One Puter account per external identity. OIDCStore.link already assumes this
-- constraint exists — it catches the unique violation to tell "re-linking the
-- same account" apart from "this sub belongs to someone else" — but the table
-- never actually had it, so two concurrent first-time logins could each create
-- an account and each link the same sub. Subsequent logins then resolved to
-- whichever row came back first.
--
-- Dedupe `user_oidc_providers` before applying this: keep the lowest `id` per
-- (provider, provider_sub) and point it at the account the collapse job kept.
DROP PROCEDURE IF EXISTS _puter_add_oidc_sub_unique;
DELIMITER //
CREATE PROCEDURE _puter_add_oidc_sub_unique()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_oidc_providers'
AND INDEX_NAME = 'idx_user_oidc_provider_sub'
) THEN
ALTER TABLE `user_oidc_providers`
ADD UNIQUE KEY `idx_user_oidc_provider_sub` (`provider`, `provider_sub`);
END IF;
END//
DELIMITER ;
CALL _puter_add_oidc_sub_unique();
DROP PROCEDURE IF EXISTS _puter_add_oidc_sub_unique;
@@ -0,0 +1,106 @@
-- 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/>.
-- Grow `share` from a pending-email-invite table into the index of active
-- shares. See sqlite/0067_share_entries.sql for the column rationale.
--
-- Idempotent: columns go through _puter_add_col (from mig_1), indexes and
-- foreign keys through the guarded procedure below. There is no per-file
-- applied-state tracking, so every statement must tolerate a re-run.
CALL _puter_add_col('share', 'holder_user_id', '`holder_user_id` int unsigned DEFAULT NULL');
CALL _puter_add_col('share', 'fsentry_id', '`fsentry_id` int unsigned DEFAULT NULL');
CALL _puter_add_col('share', 'mode', '`mode` varchar(20) DEFAULT NULL');
CALL _puter_add_col('share', 'applied_at', '`applied_at` timestamp NULL DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_share_index_constraints;
DELIMITER //
CREATE PROCEDURE _puter_add_share_index_constraints()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'share'
AND INDEX_NAME = 'idx_share_holder'
) THEN
ALTER TABLE `share` ADD INDEX `idx_share_holder` (`holder_user_id`, `id`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'share'
AND INDEX_NAME = 'idx_share_fsentry'
) THEN
ALTER TABLE `share` ADD INDEX `idx_share_fsentry` (`fsentry_id`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'share'
AND INDEX_NAME = 'idx_share_holder_entry_issuer'
) THEN
ALTER TABLE `share` ADD UNIQUE INDEX `idx_share_holder_entry_issuer`
(`holder_user_id`, `fsentry_id`, `issuer_user_id`);
END IF;
-- Retiring a deleted node's grants looks them up by permission text
-- (`fs:<uuid>` and `fs:<uuid>:%`); the subject lives in that text rather
-- than a column, so no foreign key can cascade it. The primary key is
-- (issuer, holder, permission), which never puts `permission` first, so
-- without this both the equality and the left-anchored LIKE degrade to a
-- full scan on the delete path. The column is ascii varchar(255), well
-- inside InnoDB's key limit, so it is indexed whole.
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_to_user_permissions'
AND INDEX_NAME = 'idx_user_to_user_permissions_permission'
) THEN
ALTER TABLE `user_to_user_permissions`
ADD INDEX `idx_user_to_user_permissions_permission` (`permission`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'share'
AND CONSTRAINT_NAME = 'share_holder_user_fk'
) THEN
ALTER TABLE `share` ADD CONSTRAINT `share_holder_user_fk`
FOREIGN KEY (`holder_user_id`) REFERENCES `user` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
-- The cascade that retires a share with its file.
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'share'
AND CONSTRAINT_NAME = 'share_fsentry_fk'
) THEN
ALTER TABLE `share` ADD CONSTRAINT `share_fsentry_fk`
FOREIGN KEY (`fsentry_id`) REFERENCES `fsentries` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END//
DELIMITER ;
CALL _puter_add_share_index_constraints();
DROP PROCEDURE IF EXISTS _puter_add_share_index_constraints;
@@ -0,0 +1,35 @@
-- 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/>.
-- One user refusing contact from another. See sqlite/0068_user-block.sql for
-- the rationale. `created_at` is unix seconds.
--
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
-- so the directory can replay safely.
CREATE TABLE IF NOT EXISTS `user_block` (
`id` INT NOT NULL AUTO_INCREMENT,
`blocker_user_id` INT UNSIGNED NOT NULL,
`blocked_user_id` INT UNSIGNED NOT NULL,
`created_at` BIGINT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_user_block_pair` (`blocker_user_id`, `blocked_user_id`),
CONSTRAINT `fk_user_block_blocker` FOREIGN KEY (`blocker_user_id`)
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_user_block_blocked` FOREIGN KEY (`blocked_user_id`)
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,61 @@
-- 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/>.
-- Widen `notification.shown` and `notification.acknowledged` to hold the unix
-- second the store has been writing to them since the backend rework.
--
-- Both arrived from the v1 schema as `tinyint(1)`, where they were flags. The
-- rework changed the writes to timestamps and sqlite (`INTEGER`) and postgres
-- (`bigint`) took them; only mysql was left too narrow, so every `markShown`
-- and `markAcknowledged` there fails with ER_WARN_DATA_OUT_OF_RANGE and the
-- column stays NULL. Dismissing a notification therefore never sticks, and one
-- already delivered is re-sent on every reconnect.
--
-- No backfill: every reader tests `IS NULL` / `IS NOT NULL` only, so a legacy
-- `1` keeps meaning "yes" once widened.
--
-- Guarded on the current type rather than run unconditionally. Changing a
-- column type copies the table, and this one grows without bound — a migration
-- directory that replays on every boot must not rebuild it every time.
DROP PROCEDURE IF EXISTS _puter_widen_notification_stamps;
DELIMITER //
CREATE PROCEDURE _puter_widen_notification_stamps()
BEGIN
IF EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'notification'
AND COLUMN_NAME = 'shown'
AND DATA_TYPE = 'tinyint'
) THEN
ALTER TABLE `notification` MODIFY `shown` BIGINT DEFAULT NULL;
END IF;
IF EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'notification'
AND COLUMN_NAME = 'acknowledged'
AND DATA_TYPE = 'tinyint'
) THEN
ALTER TABLE `notification` MODIFY `acknowledged` BIGINT DEFAULT NULL;
END IF;
END //
DELIMITER ;
CALL _puter_widen_notification_stamps();
DROP PROCEDURE IF EXISTS _puter_widen_notification_stamps;
@@ -0,0 +1,22 @@
-- Drops three groups that no code has ever read: `freeai`, `experimental` and
-- `dangerous`. No migration creates them — they were added by hand back when
-- hardcoded permissions were keyed by group name. That map is now a flat
-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js),
-- so a group nothing looks up grants nothing, and the rows are dead weight.
--
-- Guarded, and deliberately so: `user_to_group_permissions.group_id` and
-- `jct_user_group.group_id` both cascade on delete, so removing a group that
-- still carries permissions or members would silently revoke them from every
-- member. Only a group with neither is dropped. A group that survives this
-- migration has dependents and needs a deliberate decision, not a sweep --
-- query `user_to_group_permissions` by `group_id` to see what it holds.
--
-- The joins rather than `NOT IN` subqueries: MySQL will not read from the table
-- it is deleting from inside a subquery on the same statement.
DELETE g FROM `group` g
LEFT JOIN `user_to_group_permissions` p ON p.`group_id` = g.`id`
LEFT JOIN `jct_user_group` j ON j.`group_id` = g.`id`
WHERE JSON_UNQUOTE(JSON_EXTRACT(g.`extra`, '$.name'))
IN ('freeai', 'experimental', 'dangerous')
AND p.`group_id` IS NULL
AND j.`group_id` IS NULL;
@@ -0,0 +1,80 @@
-- 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/>.
-- Give `notification` a scope tuple: what the row is (`type`), the app it is
-- about (`app_uid`, NULL for platform), and who the recipient is in relation
-- to it (`audience`). Attribution lived inside the `value` JSON as two
-- free-form markers, `source` and `template`, that could disagree; these
-- columns replace them and are what a scoped list can be indexed on.
--
-- `app_uid` is char(40) to match `apps`.`uid`, which is `app-` plus a uuid.
--
-- Idempotent: columns go through `_puter_add_col` (mig_1), the index through
-- the guarded procedure. There is no per-file applied-state tracking, so every
-- statement here tolerates a re-run.
CALL _puter_add_col('notification', 'app_uid', '`app_uid` char(40) COLLATE utf8mb4_unicode_ci DEFAULT NULL');
CALL _puter_add_col('notification', 'audience', '`audience` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''account''');
CALL _puter_add_col('notification', 'type', '`type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''''');
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_index;
DELIMITER //
CREATE PROCEDURE _puter_add_notification_scope_index()
BEGIN
-- Serves both the scoped list and the unacknowledged count.
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'notification'
AND INDEX_NAME = 'idx_notification_scope'
) THEN
ALTER TABLE `notification` ADD INDEX `idx_notification_scope`
(`user_id`, `audience`, `app_uid`, `acknowledged`);
END IF;
END //
DELIMITER ;
CALL _puter_add_notification_scope_index();
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_index;
-- Backfill from the markers the payload actually carried. `type = ''` is the
-- not-yet-classified state, so re-running matches nothing the first pass
-- claimed, and a row matching no marker keeps the defaults (`''` / 'account')
-- and reads as legacy.
UPDATE `notification`
SET `type` = 'share.received', `audience` = 'account', `app_uid` = NULL
WHERE `type` = ''
AND JSON_UNQUOTE(JSON_EXTRACT(`value`, '$.template')) = 'file-shared-with-you';
UPDATE `notification`
SET `type` = 'share.claimed', `audience` = 'account', `app_uid` = NULL
WHERE `type` = ''
AND JSON_UNQUOTE(JSON_EXTRACT(`value`, '$.template')) = 'file-shared-before-you-joined';
-- Worker rows carried only `source: 'worker'` and a title, so the app they
-- belong to is unrecoverable and they stay unattributed. The title prefix is
-- the sole surviving signal of which way the deploy went.
UPDATE `notification`
SET `audience` = 'developer',
`app_uid` = NULL,
`type` = CASE
WHEN JSON_UNQUOTE(JSON_EXTRACT(`value`, '$.title')) LIKE 'Successfully deployed %'
THEN 'app.worker.deployed'
ELSE 'app.worker.deployFailed'
END
WHERE `type` = ''
AND JSON_UNQUOTE(JSON_EXTRACT(`value`, '$.source')) = 'worker';
@@ -0,0 +1,42 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Index `notification`.`created_at`. The retention sweep selects by age alone
-- and by nothing else, so no existing index on the table narrows it — every
-- pass would otherwise scan the whole table to find the oldest few hundred
-- rows.
--
-- Idempotent: the guarded procedure, as mig_26. There is no per-file
-- applied-state tracking, so a replay has to be a no-op.
DROP PROCEDURE IF EXISTS _puter_add_notification_created_at_index;
DELIMITER //
CREATE PROCEDURE _puter_add_notification_created_at_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'notification'
AND INDEX_NAME = 'idx_notification_created_at'
) THEN
ALTER TABLE `notification` ADD INDEX `idx_notification_created_at`
(`created_at`);
END IF;
END //
DELIMITER ;
CALL _puter_add_notification_created_at_index();
DROP PROCEDURE IF EXISTS _puter_add_notification_created_at_index;
@@ -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/>.
-- Home region column. Mirrors SQLite migration 0074. Home region this
-- account's data belongs in — a node id, not a storage region. NULL for
-- accounts that predate the column, which fall back to `signup_server`.
--
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
-- leaves it resident for later migrations).
CALL _puter_add_col('user', 'home', '`home` VARCHAR(64) DEFAULT NULL');
@@ -0,0 +1,59 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Subscriptions that outlive the connection that made them. See
-- sqlite/0075_event-subscriptions.sql for the column rationale.
--
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
-- as mig_23. There is no per-file applied-state tracking, so a replay has to
-- be a no-op.
CREATE TABLE IF NOT EXISTS `event_subscriptions` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`sub_id` varchar(80) NOT NULL,
`token` varchar(255) NOT NULL,
`owner_user_id` int unsigned NOT NULL,
`holder_user_id` int unsigned NOT NULL,
-- Matches `apps`.`uid` exactly, charset included, so an equality against
-- one never falls back to a conversion. No foreign key: a row outlives the
-- app that made it, which is what keeps it revocable afterwards.
`app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci
DEFAULT NULL,
`subject` varchar(4096) NOT NULL,
`anchor_uid` char(36) NOT NULL,
`anchor_path` varchar(4096) NOT NULL,
`match` varchar(1024) DEFAULT NULL,
`delivery` varchar(16) NOT NULL,
`ops` varchar(64) DEFAULT NULL,
`handler_name` varchar(128) DEFAULT NULL,
`targets` json NOT NULL,
`context` text,
`permission` varchar(32) NOT NULL,
`suspended_at` bigint DEFAULT NULL,
`suspended_reason` varchar(64) DEFAULT NULL,
`expires_at` bigint DEFAULT NULL,
`created_at` bigint NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_event_subscriptions_sub_id` (`sub_id`),
KEY `idx_event_subscriptions_token` (`token`),
KEY `idx_event_subscriptions_holder` (`holder_user_id`, `app_uid`),
KEY `idx_event_subscriptions_owner` (`owner_user_id`),
CONSTRAINT `fk_event_subscriptions_owner` FOREIGN KEY (`owner_user_id`)
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_event_subscriptions_holder` FOREIGN KEY (`holder_user_id`)
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,43 @@
-- 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/>.
-- `event_subscriptions`.`anchor_uid` was sized for a filesystem node uuid.
-- A key-value subscription anchors on an app instead, and `apps`.`uid` is
-- `char(40)`, so the column has to hold one.
--
-- Idempotent: the guarded procedure, as mig_27. There is no per-file
-- applied-state tracking, so a replay has to be a no-op.
DROP PROCEDURE IF EXISTS _puter_widen_event_subscriptions_anchor_uid;
DELIMITER //
CREATE PROCEDURE _puter_widen_event_subscriptions_anchor_uid()
BEGIN
IF EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'event_subscriptions'
AND COLUMN_NAME = 'anchor_uid'
AND CHARACTER_MAXIMUM_LENGTH < 40
) THEN
ALTER TABLE `event_subscriptions`
MODIFY COLUMN `anchor_uid` varchar(40) NOT NULL;
END IF;
END //
DELIMITER ;
CALL _puter_widen_event_subscriptions_anchor_uid();
DROP PROCEDURE IF EXISTS _puter_widen_event_subscriptions_anchor_uid;
@@ -0,0 +1,37 @@
-- 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/>.
-- Named handlers an app deploys once. See sqlite/0076_event-handlers.sql for
-- the column rationale.
--
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
-- as mig_29. There is no per-file applied-state tracking, so a replay has to
-- be a no-op.
CREATE TABLE IF NOT EXISTS `event_handlers` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
-- Matches `apps`.`uid` exactly, charset included, so an equality against
-- one never falls back to a conversion.
`app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
`name` varchar(128) NOT NULL,
`source` mediumtext NOT NULL,
`source_hash` char(64) NOT NULL,
`created_at` bigint NOT NULL,
`updated_at` bigint NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_event_handlers_app_name` (`app_uid`, `name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,58 @@
-- 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/>.
-- Teams. See sqlite/0077_teams.sql for the column rationale.
-- No per-file applied-state tracking, so every statement must tolerate a re-run:
-- columns go through _puter_add_col, indexes through the guarded procedure below.
CALL _puter_add_col('group', 'kind', '`kind` varchar(20) DEFAULT NULL');
CALL _puter_add_col('group', 'name', '`name` varchar(255) DEFAULT NULL');
CALL _puter_add_col('group', 'handle', '`handle` varchar(64) DEFAULT NULL');
CALL _puter_add_col('group', 'deleted_at', '`deleted_at` timestamp NULL DEFAULT NULL');
CALL _puter_add_col('jct_user_group', 'org_owned', '`org_owned` tinyint(1) DEFAULT NULL');
CALL _puter_add_col('user', 'requires_password_change', '`requires_password_change` tinyint(1) NOT NULL DEFAULT ''0''');
DROP PROCEDURE IF EXISTS _puter_add_team_indexes;
DELIMITER //
CREATE PROCEDURE _puter_add_team_indexes()
BEGIN
-- Already case-insensitive: the table is utf8mb4_unicode_ci. NULLs stay distinct.
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'group'
AND INDEX_NAME = 'idx_group_handle'
) THEN
ALTER TABLE `group` ADD UNIQUE INDEX `idx_group_handle` (`handle`);
END IF;
-- Composite, unlike the existing single-column `group_id` key.
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'jct_user_group'
AND INDEX_NAME = 'idx_jct_user_group_group'
) THEN
ALTER TABLE `jct_user_group`
ADD INDEX `idx_jct_user_group_group` (`group_id`, `user_id`);
END IF;
END//
DELIMITER ;
CALL _puter_add_team_indexes();
DROP PROCEDURE IF EXISTS _puter_add_team_indexes;
@@ -0,0 +1,47 @@
-- 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/>.
-- Deduplicate then constrain. See sqlite/0075 for why this is lossless.
-- Both steps sit behind the index check: mysql re-runs this file on every boot, and
-- guarding them together stops a rolling deploy deleting and indexing concurrently.
DROP PROCEDURE IF EXISTS _puter_add_membership_pair_index;
DELIMITER //
CREATE PROCEDURE _puter_add_membership_pair_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'jct_user_group'
AND INDEX_NAME = 'idx_jct_user_group_pair'
) THEN
-- Self-join because mysql refuses `NOT IN (SELECT ... FROM jct_user_group)` (1093).
DELETE dup FROM `jct_user_group` dup
JOIN `jct_user_group` keep
ON dup.`user_id` = keep.`user_id`
AND dup.`group_id` = keep.`group_id`
AND dup.`id` > keep.`id`;
ALTER TABLE `jct_user_group`
ADD UNIQUE INDEX `idx_jct_user_group_pair` (`user_id`, `group_id`);
END IF;
END//
DELIMITER ;
CALL _puter_add_membership_pair_index();
DROP PROCEDURE IF EXISTS _puter_add_membership_pair_index;
@@ -0,0 +1,84 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- See sqlite/0076. Types follow mysql_mig_1; the guarded procedure follows mysql_mig_22.
CREATE TABLE IF NOT EXISTS `audit_team_membership` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`group_id` int unsigned DEFAULT NULL,
`group_id_keep` int unsigned NOT NULL,
`user_id` int unsigned DEFAULT NULL,
`user_id_keep` int unsigned NOT NULL,
`actor_user_id` int unsigned DEFAULT NULL,
`action` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`reason` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_audit_team_membership_group` (`group_id_keep`, `id`),
KEY `idx_audit_team_membership_user` (`user_id_keep`, `id`),
KEY `idx_audit_team_membership_group_fk` (`group_id`),
KEY `idx_audit_team_membership_user_fk` (`user_id`),
KEY `idx_audit_team_membership_actor_fk` (`actor_user_id`),
-- SET NULL, never CASCADE: deleting an account must not erase what was done to it.
CONSTRAINT `fk_audit_team_membership_group` FOREIGN KEY (`group_id`)
REFERENCES `group` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT `fk_audit_team_membership_user` FOREIGN KEY (`user_id`)
REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT `fk_audit_team_membership_actor` FOREIGN KEY (`actor_user_id`)
REFERENCES `user` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CALL _puter_add_col('share', 'holder_group_id', '`holder_group_id` int unsigned DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_group_share_constraints;
DELIMITER //
CREATE PROCEDURE _puter_add_group_share_constraints()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'share'
AND INDEX_NAME = 'idx_share_holder_group'
) THEN
ALTER TABLE `share` ADD INDEX `idx_share_holder_group` (`holder_group_id`, `id`);
END IF;
-- Team shares leave `holder_user_id` NULL, so the existing unique index binds none.
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'share'
AND INDEX_NAME = 'idx_share_holder_group_entry_issuer'
) THEN
ALTER TABLE `share` ADD UNIQUE INDEX `idx_share_holder_group_entry_issuer`
(`holder_group_id`, `fsentry_id`, `issuer_user_id`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'share'
AND CONSTRAINT_NAME = 'share_holder_group_fk'
) THEN
ALTER TABLE `share` ADD CONSTRAINT `share_holder_group_fk`
FOREIGN KEY (`holder_group_id`) REFERENCES `group` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END//
DELIMITER ;
CALL _puter_add_group_share_constraints();
DROP PROCEDURE IF EXISTS _puter_add_group_share_constraints;
@@ -0,0 +1,68 @@
-- 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/>.
-- Opaque names for shared regions of a user's key-value namespace. See
-- sqlite/0080_kv-share-handles.sql for the column rationale.
--
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
-- as mig_29. There is no per-file applied-state tracking, so a replay has to
-- be a no-op.
CREATE TABLE IF NOT EXISTS `kv_share_handles` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`handle` varchar(64) NOT NULL,
`owner_user_id` int unsigned NOT NULL,
`grantee_user_id` int unsigned NOT NULL,
-- Matches `apps`.`uid` exactly, charset included, so an equality against
-- one never falls back to a conversion. No foreign key: a handle outlives
-- the app it was minted against, which is what keeps it revocable.
`app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
`key_prefix` varchar(1024) NOT NULL,
`permission` varchar(1024) NOT NULL,
`created_at` bigint NOT NULL,
`revoked_at` bigint DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_kv_share_handles_handle` (`handle`),
KEY `idx_kv_share_handles_owner` (`owner_user_id`, `id`),
CONSTRAINT `fk_kv_share_handles_owner` FOREIGN KEY (`owner_user_id`)
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_kv_share_handles_grantee` FOREIGN KEY (`grantee_user_id`)
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- A subscription made through a handle stores the grant string it was
-- authorized under rather than an access mode, and a grant string carries a
-- user uuid, an app uid and a key prefix. Guarded on the current length, as
-- mig_24: replaying a column change on a growing table rebuilds it every boot.
DROP PROCEDURE IF EXISTS _puter_widen_subscription_permission;
DELIMITER //
CREATE PROCEDURE _puter_widen_subscription_permission()
BEGIN
IF EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'event_subscriptions'
AND COLUMN_NAME = 'permission'
AND CHARACTER_MAXIMUM_LENGTH < 1024
) THEN
ALTER TABLE `event_subscriptions` MODIFY `permission` varchar(1024) NOT NULL;
END IF;
END //
DELIMITER ;
CALL _puter_widen_subscription_permission();
DROP PROCEDURE IF EXISTS _puter_widen_subscription_permission;
@@ -0,0 +1,60 @@
-- 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/>.
-- `event_subscriptions` grew three hot queries with no index behind them:
-- a handler's own rows, the expiry reaper and the suspended reaper. See
-- sqlite/0081_event-subscriptions-indexes.sql for the query shapes.
--
-- Idempotent: the guarded procedure, as mig_30/mig_34/mig_35. There is no
-- per-file applied-state tracking, so a replay has to be a no-op.
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_indexes;
DELIMITER //
CREATE PROCEDURE _puter_add_event_subscriptions_indexes()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'event_subscriptions'
AND INDEX_NAME = 'idx_event_subscriptions_app_handler'
) THEN
ALTER TABLE `event_subscriptions`
ADD INDEX `idx_event_subscriptions_app_handler` (`app_uid`, `handler_name`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'event_subscriptions'
AND INDEX_NAME = 'idx_event_subscriptions_expires'
) THEN
ALTER TABLE `event_subscriptions`
ADD INDEX `idx_event_subscriptions_expires` (`expires_at`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'event_subscriptions'
AND INDEX_NAME = 'idx_event_subscriptions_suspended'
) THEN
ALTER TABLE `event_subscriptions`
ADD INDEX `idx_event_subscriptions_suspended` (`suspended_at`, `id`);
END IF;
END//
DELIMITER ;
CALL _puter_add_event_subscriptions_indexes();
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_indexes;
@@ -0,0 +1,21 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- See sqlite/0082_temp-password-expiry.sql for the column rationale.
-- No per-file applied-state tracking, so the column goes through _puter_add_col.
CALL _puter_add_col('user', 'temp_password_expires_at', '`temp_password_expires_at` bigint DEFAULT NULL');
@@ -0,0 +1,21 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- See sqlite/0083_team-directory.sql for the column rationale.
-- No per-file applied-state tracking, so the column goes through _puter_add_col.
CALL _puter_add_col('group', 'directory_enabled', '`directory_enabled` tinyint(1) NOT NULL DEFAULT 0');
@@ -72,7 +72,8 @@ CREATE TABLE IF NOT EXISTS "user" (
signup_origin varchar(255),
signup_server varchar(255),
metadata jsonb DEFAULT '{}'::jsonb,
reputation smallint DEFAULT 100
reputation smallint DEFAULT 100,
home varchar(64)
);
CREATE INDEX IF NOT EXISTS idx_user_email ON "user" (email);
@@ -0,0 +1,40 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Enforce "at most one account owns an email address". Mirrors SQLite
-- migration 0066 and MySQL migration 21; see those for the full rationale.
--
-- In short: `user.email` is deliberately not UNIQUE because several rows may
-- hold the same address while unconfirmed. What must not happen is two rows
-- both owning it — confirmed, or holding a password and so able to drive
-- password recovery for that inbox. The application checks for an owner before
-- every write, but a check and a write are not one operation.
--
-- If this fails, the DB already contains duplicate owners; collapse them first
-- (admin → One-off Jobs → Collapse Duplicate Emails).
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_owned_email
ON "user" (COALESCE(clean_email, LOWER(email)))
WHERE email IS NOT NULL
AND (email_confirmed = TRUE OR password IS NOT NULL);
-- One Puter account per external identity. OIDCStore.link already assumes this
-- constraint exists — it catches the unique violation to tell "re-linking the
-- same account" apart from "this sub belongs to someone else" — but the table
-- never actually had it.
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_oidc_provider_sub
ON user_oidc_providers (provider, provider_sub);
@@ -0,0 +1,42 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Grow share from a pending-email-invite table into the index of active
-- shares. See sqlite/0067_share_entries.sql for the column rationale.
ALTER TABLE share
ADD COLUMN IF NOT EXISTS holder_user_id integer
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
ADD COLUMN IF NOT EXISTS fsentry_id integer
REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE,
ADD COLUMN IF NOT EXISTS mode varchar(20),
ADD COLUMN IF NOT EXISTS applied_at timestamp;
CREATE INDEX IF NOT EXISTS idx_share_holder
ON share (holder_user_id, id);
CREATE INDEX IF NOT EXISTS idx_share_fsentry
ON share (fsentry_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_share_holder_entry_issuer
ON share (holder_user_id, fsentry_id, issuer_user_id);
-- Retiring a deleted node's grants looks them up by permission text, and the
-- primary key is (issuer, holder, permission) — `permission` is never the
-- leading column, so the delete path would otherwise scan the table. The
-- text_pattern_ops class is what makes the left-anchored LIKE a range scan
-- under a non-C collation; plain equality still uses it.
CREATE INDEX IF NOT EXISTS idx_user_to_user_permissions_permission
ON user_to_user_permissions (permission text_pattern_ops);
@@ -0,0 +1,33 @@
-- 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/>.
-- One user refusing contact from another. See sqlite/0068_user-block.sql for
-- the rationale. `created_at` is unix seconds.
--
-- Idempotent via IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS user_block (
id BIGSERIAL PRIMARY KEY,
blocker_user_id integer NOT NULL
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
blocked_user_id integer NOT NULL
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
created_at BIGINT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_block_pair
ON user_block (blocker_user_id, blocked_user_id);
@@ -0,0 +1,20 @@
-- Drops three groups that no code has ever read: `freeai`, `experimental` and
-- `dangerous`. No migration creates them — they were added by hand back when
-- hardcoded permissions were keyed by group name. That map is now a flat
-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js),
-- so a group nothing looks up grants nothing, and the rows are dead weight.
--
-- Guarded, and deliberately so: user_to_group_permissions.group_id and
-- jct_user_group.group_id both cascade on delete, so removing a group that
-- still carries permissions or members would silently revoke them from every
-- member. Only a group with neither is dropped. A group that survives this
-- migration has dependents and needs a deliberate decision, not a sweep --
-- query user_to_group_permissions by group_id to see what it holds.
DELETE FROM "group" g
WHERE g.extra ->> 'name' IN ('freeai', 'experimental', 'dangerous')
AND NOT EXISTS (
SELECT 1 FROM user_to_group_permissions p WHERE p.group_id = g.id
)
AND NOT EXISTS (
SELECT 1 FROM jct_user_group j WHERE j.group_id = g.id
);
@@ -0,0 +1,23 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- "Shared by me" pages by keyset: issuer equality, then `id` as the cursor.
-- The single-column issuer index (postgres_mig_1) serves the equality but not
-- the ordering; this composite lets the issued half of that listing range-scan
-- without a sort.
CREATE INDEX IF NOT EXISTS idx_share_issuer
ON share (issuer_user_id, id);
@@ -0,0 +1,55 @@
-- 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/>.
-- Scope tuple for `notification`. See mysql/mysql_mig_26.sql for the rationale.
-- `app_uid` is varchar(40) — the width of `apps`.`uid` — and varchar rather
-- than char for the same reason `uid` is here: blank padding is not what the
-- column means.
--
-- Idempotent via IF NOT EXISTS and the `type = ''` guard on the backfill.
ALTER TABLE notification ADD COLUMN IF NOT EXISTS app_uid varchar(40);
ALTER TABLE notification ADD COLUMN IF NOT EXISTS audience varchar(16) NOT NULL DEFAULT 'account';
ALTER TABLE notification ADD COLUMN IF NOT EXISTS type varchar(64) NOT NULL DEFAULT '';
-- Serves both the scoped list and the unacknowledged count.
CREATE INDEX IF NOT EXISTS idx_notification_scope
ON notification (user_id, audience, app_uid, acknowledged);
UPDATE notification
SET type = 'share.received', audience = 'account', app_uid = NULL
WHERE type = ''
AND value->>'template' = 'file-shared-with-you';
UPDATE notification
SET type = 'share.claimed', audience = 'account', app_uid = NULL
WHERE type = ''
AND value->>'template' = 'file-shared-before-you-joined';
-- Worker rows carried only `source: 'worker'` and a title, so the app they
-- belong to is unrecoverable and they stay unattributed. The title prefix is
-- the sole surviving signal of which way the deploy went.
UPDATE notification
SET audience = 'developer',
app_uid = NULL,
type = CASE
WHEN value->>'title' LIKE 'Successfully deployed %'
THEN 'app.worker.deployed'
ELSE 'app.worker.deployFailed'
END
WHERE type = ''
AND value->>'source' = 'worker';
@@ -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/>.
-- Index `notification`.`created_at` for the retention sweep. See
-- mysql/mysql_mig_27.sql for the rationale.
CREATE INDEX IF NOT EXISTS idx_notification_created_at
ON notification (created_at);
@@ -0,0 +1,23 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Home region column. Mirrors SQLite migration 0074. Home region this
-- account's data belongs in — a node id, not a storage region. NULL for
-- accounts that predate the column, which fall back to `signup_server`.
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS home varchar(64);
@@ -0,0 +1,53 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Subscriptions that outlive the connection that made them. See
-- sqlite/0075_event-subscriptions.sql for the column rationale.
--
-- Idempotent via IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS event_subscriptions (
id BIGSERIAL PRIMARY KEY,
sub_id VARCHAR(80) NOT NULL UNIQUE,
token VARCHAR(255) NOT NULL,
owner_user_id INTEGER NOT NULL
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
holder_user_id INTEGER NOT NULL
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
app_uid VARCHAR(40) DEFAULT NULL,
subject VARCHAR(4096) NOT NULL,
anchor_uid VARCHAR(36) NOT NULL,
anchor_path VARCHAR(4096) NOT NULL,
"match" VARCHAR(1024) DEFAULT NULL,
delivery VARCHAR(16) NOT NULL,
ops VARCHAR(64) DEFAULT NULL,
handler_name VARCHAR(128) DEFAULT NULL,
targets JSONB NOT NULL,
context TEXT DEFAULT NULL,
permission VARCHAR(32) NOT NULL,
suspended_at BIGINT DEFAULT NULL,
suspended_reason VARCHAR(64) DEFAULT NULL,
expires_at BIGINT DEFAULT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_token
ON event_subscriptions (token);
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_holder
ON event_subscriptions (holder_user_id, app_uid);
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_owner
ON event_subscriptions (owner_user_id);
@@ -0,0 +1,35 @@
-- 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/>.
-- `event_subscriptions.anchor_uid` was sized for a filesystem node uuid. A
-- key-value subscription anchors on an app instead, and an app uid is 40
-- characters. See mysql/mysql_mig_30.sql.
--
-- Guarded: an unconditional ALTER COLUMN TYPE takes an ACCESS EXCLUSIVE lock
-- on every replay, even widening to the same type. Skipped once the column is
-- already wide enough.
DO $$
BEGIN
IF (
SELECT character_maximum_length FROM information_schema.columns
WHERE table_name = 'event_subscriptions' AND column_name = 'anchor_uid'
) < 40 THEN
ALTER TABLE event_subscriptions
ALTER COLUMN anchor_uid TYPE VARCHAR(40);
END IF;
END $$;
@@ -0,0 +1,34 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Named handlers an app deploys once. See sqlite/0076_event-handlers.sql for
-- the column rationale.
--
-- Idempotent via IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS event_handlers (
id BIGSERIAL PRIMARY KEY,
app_uid VARCHAR(40) NOT NULL,
name VARCHAR(128) NOT NULL,
source TEXT NOT NULL,
source_hash VARCHAR(64) NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_handlers_app_name
ON event_handlers (app_uid, name);
@@ -0,0 +1,38 @@
-- 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/>.
-- Teams. See sqlite/0077_teams.sql for the column rationale.
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS kind text;
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS name text;
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS handle text;
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS deleted_at timestamp;
-- On lower(handle): text is case-sensitive here, unlike MySQL's utf8mb4_unicode_ci.
-- Handle lookups must therefore compare lower(handle) to hit this index.
CREATE UNIQUE INDEX IF NOT EXISTS idx_group_handle ON "group" (lower(handle));
-- No idx_group_owner here: idx_group_owner_user_id already covers it.
ALTER TABLE jct_user_group ADD COLUMN IF NOT EXISTS org_owned smallint;
-- Composite, unlike the existing single-column idx_jct_user_group_group_id.
CREATE INDEX IF NOT EXISTS idx_jct_user_group_group
ON jct_user_group (group_id, user_id);
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_password_change smallint NOT NULL DEFAULT 0;
@@ -0,0 +1,34 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Deduplicate then constrain. See sqlite/0075 for why this is lossless.
-- Guarded because postgres re-runs every file on each boot; `to_regclass` follows
-- search_path, so it resolves under a test schema too.
DO $mig15$
BEGIN
IF to_regclass('idx_jct_user_group_pair') IS NULL THEN
DELETE FROM jct_user_group
WHERE id NOT IN (
SELECT MIN(id) FROM jct_user_group GROUP BY user_id, group_id
);
END IF;
END
$mig15$;
CREATE UNIQUE INDEX IF NOT EXISTS idx_jct_user_group_pair
ON jct_user_group (user_id, group_id);
@@ -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/>.
-- See sqlite/0076. Types follow `audit_user_to_group_permissions` in postgres_mig_1.
CREATE TABLE IF NOT EXISTS audit_team_membership (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
-- SET NULL, never CASCADE: deleting an account must not erase what was done to it.
group_id integer REFERENCES "group" (id) ON DELETE SET NULL ON UPDATE CASCADE,
group_id_keep integer NOT NULL,
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id_keep integer NOT NULL,
actor_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
action varchar(255) NOT NULL,
reason varchar(255),
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_group
ON audit_team_membership (group_id_keep, id);
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_user
ON audit_team_membership (user_id_keep, id);
-- SET NULL has to find the child rows; postgres does not index FK columns for you.
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_group_fk
ON audit_team_membership (group_id);
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_user_fk
ON audit_team_membership (user_id);
CREATE INDEX IF NOT EXISTS idx_audit_team_membership_actor_fk
ON audit_team_membership (actor_user_id);
ALTER TABLE share ADD COLUMN IF NOT EXISTS holder_group_id integer
REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS idx_share_holder_group
ON share (holder_group_id, id);
-- Default NULLS DISTINCT, matching the other engines, so this binds only team shares.
CREATE UNIQUE INDEX IF NOT EXISTS idx_share_holder_group_entry_issuer
ON share (holder_group_id, fsentry_id, issuer_user_id);
@@ -0,0 +1,64 @@
-- 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/>.
-- Opaque names for shared regions of a user's key-value namespace. See
-- sqlite/0080_kv-share-handles.sql for the column rationale.
--
-- Idempotent via IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS kv_share_handles (
id BIGSERIAL PRIMARY KEY,
handle VARCHAR(64) NOT NULL,
owner_user_id INTEGER NOT NULL
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
grantee_user_id INTEGER NOT NULL
REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
app_uid VARCHAR(40) NOT NULL,
key_prefix VARCHAR(1024) NOT NULL,
permission VARCHAR(1024) NOT NULL,
created_at BIGINT NOT NULL,
revoked_at BIGINT DEFAULT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_kv_share_handles_handle
ON kv_share_handles (handle);
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_owner
ON kv_share_handles (owner_user_id, id);
-- A cascading delete has to find the child rows, and postgres does not index
-- FK columns for you.
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_grantee_fk
ON kv_share_handles (grantee_user_id);
-- A subscription made through a handle stores the grant string it was
-- authorized under rather than an access mode, and a grant string carries a
-- user uuid, an app uid and a key prefix.
--
-- Guarded: an unconditional ALTER COLUMN TYPE takes an ACCESS EXCLUSIVE lock
-- on every replay, even widening to the same type. Skipped once the column is
-- already wide enough.
DO $$
BEGIN
IF (
SELECT character_maximum_length FROM information_schema.columns
WHERE table_name = 'event_subscriptions' AND column_name = 'permission'
) < 1024 THEN
ALTER TABLE event_subscriptions
ALTER COLUMN permission TYPE VARCHAR(1024);
END IF;
END $$;
@@ -0,0 +1,31 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- `event_subscriptions` grew three hot queries with no index behind them:
-- a handler's own rows, the expiry reaper and the suspended reaper. See
-- sqlite/0081_event-subscriptions-indexes.sql for the query shapes.
--
-- Idempotent via IF NOT EXISTS.
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_app_handler
ON event_subscriptions (app_uid, handler_name);
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_expires
ON event_subscriptions (expires_at);
CREATE INDEX IF NOT EXISTS idx_event_subscriptions_suspended
ON event_subscriptions (suspended_at, id);
@@ -0,0 +1,21 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- See sqlite/0082_temp-password-expiry.sql for the column rationale.
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS temp_password_expires_at bigint DEFAULT NULL;
@@ -0,0 +1,21 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- See sqlite/0083_team-directory.sql for the column rationale.
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
ALTER TABLE "group" ADD COLUMN IF NOT EXISTS directory_enabled smallint NOT NULL DEFAULT 0;
@@ -0,0 +1,53 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Enforce "at most one account owns an email address".
--
-- `user.email` is deliberately not UNIQUE: several rows may legitimately hold
-- the same address while unconfirmed (admin-provisioned placeholders, signups
-- that were never confirmed, temp accounts on their way to becoming real). What
-- must never happen is two rows both *owning* an address — owning meaning the
-- row is confirmed, or holds a password and so can drive password recovery for
-- that inbox.
--
-- Signup, save-account, change-email, OIDC and admin provisioning each check for
-- an owner before writing, but a check and an insert are not one operation: two
-- requests can both read "free" and both write. This index is what actually
-- holds the invariant; the application checks just produce a nicer error most of
-- the time.
--
-- Matching is on the canonical address so provider aliases
-- (`[email protected]` vs `[email protected]`) collide. `clean_email` is
-- written on every modern write path; the COALESCE covers rows old enough to
-- predate the column.
--
-- If this CREATE fails, the DB already contains duplicate owners. Collapse them
-- first (admin → One-off Jobs → Collapse Duplicate Emails) — there is no safe
-- automatic merge of two accounts.
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_owned_email
ON user(COALESCE(clean_email, lower(email)))
WHERE email IS NOT NULL
AND (email_confirmed = 1 OR password IS NOT NULL);
-- One Puter account per external identity. OIDCStore.link already assumes this
-- constraint exists — it catches the unique violation to tell "re-linking the
-- same account" apart from "this sub belongs to someone else" — but the table
-- never actually had it, so two concurrent first-time logins could each create
-- an account and each link the same sub. Subsequent logins then resolved to
-- whichever row came back first.
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_oidc_provider_sub
ON user_oidc_providers(provider, provider_sub);
@@ -0,0 +1,60 @@
-- 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/>.
-- Grow `share` from a pending-email-invite table into the index of active
-- shares. Permissions stay the source of truth for access; this is what makes
-- shares listable and gives them a lifecycle.
--
-- - `holder_user_id` : NULL while an invite awaits signup.
-- - `fsentry_id` : the shared node. ON DELETE CASCADE retires the share
-- with the file, which permissions alone don't do.
-- - `mode` : see|list|read|write|manage. Unconstrained on purpose —
-- ACLService owns the mode set and adding one shouldn't
-- need a three-dialect migration.
-- - `applied_at` : set when an invite is claimed. Claiming updates the
-- row rather than deleting it, so the share stays
-- queryable.
ALTER TABLE `share` ADD COLUMN `holder_user_id` INTEGER DEFAULT NULL
REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `share` ADD COLUMN `fsentry_id` INTEGER DEFAULT NULL
REFERENCES `fsentries` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `share` ADD COLUMN `mode` TEXT DEFAULT NULL;
ALTER TABLE `share` ADD COLUMN `applied_at` TIMESTAMP DEFAULT NULL;
-- "Shared with me", keyset-paginated: ORDER BY ends in `id` as the tiebreaker.
CREATE INDEX IF NOT EXISTS `idx_share_holder`
ON `share` (`holder_user_id`, `id`);
-- Who has access to one node — also how an owner sees a manage-delegate's
-- re-grants, which the issuer/holder permission tables can't answer.
CREATE INDEX IF NOT EXISTS `idx_share_fsentry` ON `share` (`fsentry_id`);
-- One row per (holder, node, issuer). Pending invites have a NULL
-- holder_user_id and so aren't covered here; dedup for those belongs with the
-- invite flow.
CREATE UNIQUE INDEX IF NOT EXISTS `idx_share_holder_entry_issuer`
ON `share` (`holder_user_id`, `fsentry_id`, `issuer_user_id`);
-- Retiring a deleted node's grants looks them up by permission text
-- (`fs:<uuid>` and `fs:<uuid>:%`), and the subject only lives in that text —
-- no column, so no foreign key can cascade it. The table's primary key is
-- (issuer, holder, permission), which never puts `permission` first, so
-- without this the equality and the left-anchored LIKE both degrade to a full
-- scan on the delete path. With it, each is a range scan.
CREATE INDEX IF NOT EXISTS `idx_user_to_user_permissions_permission`
ON `user_to_user_permissions` (`permission`);
@@ -0,0 +1,32 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- One referral code, one account. The mysql and postgres baselines have
-- declared `user.referral_code` UNIQUE since the column was introduced; the
-- sqlite baseline only declared the column, so the invariant held on hosted
-- deployments and not on self-hosted ones.
--
-- Codes are minted by picking at random and writing, so this index is not a
-- nicety: it is what makes "did anyone already take this code" a decision the
-- database makes once, instead of a read the next mint can race. A caller that
-- loses the race sees a unique violation and picks again.
--
-- NULL is distinct from NULL in a sqlite unique index, so accounts that have
-- never asked for a code (the majority) are unaffected.
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_referral_code
ON user(referral_code)
WHERE referral_code IS NOT NULL;
@@ -0,0 +1,39 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- One user refusing contact from another. Sharing is what reads it today —
-- a blocked sender's share is refused outright, and their pending invite is
-- dropped when the blocker confirms the address it was aimed at — but the
-- table is deliberately not share-specific: the same answer serves anything
-- else one person can push at another.
--
-- Rows are read on the share path by exact (blocker, blocked) pair, which the
-- unique index answers as a point lookup; the same index's leading column
-- serves "who have I blocked". `created_at` is unix seconds, matching
-- `app_feedback`.
CREATE TABLE IF NOT EXISTS `user_block` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"blocker_user_id" INTEGER NOT NULL
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"blocked_user_id" INTEGER NOT NULL
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"created_at" INTEGER NOT NULL -- unix seconds
);
CREATE UNIQUE INDEX IF NOT EXISTS `idx_user_block_pair`
ON `user_block` (`blocker_user_id`, `blocked_user_id`);
@@ -0,0 +1,33 @@
-- 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/>.
-- Drops three groups that no code has ever read: `freeai`, `experimental` and
-- `dangerous`. No migration creates them — they were added by hand back when
-- hardcoded permissions were keyed by group name. That map is now a flat
-- per-user floor (`default_user_permissions` in data/hardcoded-permissions.js),
-- so a group nothing looks up grants nothing, and the rows are dead weight.
--
-- Guarded, and deliberately so: `user_to_group_permissions.group_id` and
-- `jct_user_group.group_id` both cascade on delete, so removing a group that
-- still carries permissions or members would silently revoke them from every
-- member. Only a group with neither is dropped. A group that survives this
-- migration has dependents and needs a deliberate decision, not a sweep --
-- query `user_to_group_permissions` by `group_id` to see what it holds.
DELETE FROM `group`
WHERE json_extract(`extra`, '$.name') IN ('freeai', 'experimental', 'dangerous')
AND `id` NOT IN (SELECT `group_id` FROM `user_to_group_permissions`)
AND `id` NOT IN (SELECT `group_id` FROM `jct_user_group`);
@@ -0,0 +1,23 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- "Shared by me" pages by keyset: issuer equality, then `id` as the cursor.
-- The issuer foreign key created no index here (unlike mysql, where the FK's
-- index implicitly ends in the primary key), so without this the issued half
-- of that listing walks the whole table.
CREATE INDEX IF NOT EXISTS `idx_share_issuer`
ON `share` (`issuer_user_id`, `id`);
@@ -0,0 +1,104 @@
-- 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/>.
-- Scope tuple for `notification`. See mysql/mysql_mig_26.sql for the rationale.
--
-- Done as the standard 12-step rebuild rather than three ALTERs, because the
-- same table is missing two things mysql and postgres have had since the v1
-- schema: an index on `user_id`, which every `listByUserId` scans without, and
-- the cascade that retires a deleted user's rows. A column add cannot
-- introduce a foreign key here.
--
-- Rows whose user is already gone are dropped rather than carried over: the
-- cascade this migration adds is what would have removed them.
--
-- Wrapped in one transaction: without it, a crash between the `DROP` and the
-- `RENAME` below would lose the table outright rather than leave it
-- unmigrated. `IF EXISTS`/`IF NOT EXISTS` guard a stale `notification_new`
-- from a previous interrupted attempt.
BEGIN;
DROP TABLE IF EXISTS `notification_new`;
CREATE TABLE `notification_new` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL,
"uid" TEXT NOT NULL UNIQUE,
"value" JSON NOT NULL,
"acknowledged" INTEGER DEFAULT NULL,
"shown" INTEGER DEFAULT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"app_uid" TEXT DEFAULT NULL,
"audience" TEXT NOT NULL DEFAULT 'account',
"type" TEXT NOT NULL DEFAULT '',
FOREIGN KEY("user_id") REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO `notification_new` (
`id`, `user_id`, `uid`, `value`, `acknowledged`, `shown`, `created_at`
)
SELECT
`id`, `user_id`, `uid`, `value`, `acknowledged`, `shown`, `created_at`
FROM `notification`
WHERE `user_id` IN (SELECT `id` FROM `user`);
DROP TABLE IF EXISTS `notification`;
ALTER TABLE `notification_new` RENAME TO `notification`;
CREATE INDEX IF NOT EXISTS `idx_notification_user_id`
ON `notification` (`user_id`);
-- Serves both the scoped list and the unacknowledged count.
CREATE INDEX IF NOT EXISTS `idx_notification_scope`
ON `notification` (`user_id`, `audience`, `app_uid`, `acknowledged`);
-- Backfill from the markers the payload actually carried. `type = ''` is the
-- not-yet-classified state, so a re-run matches nothing the first pass
-- claimed, and a row matching no marker keeps the defaults (`''` / 'account')
-- and reads as legacy. `json_valid` guards rows whose `value` was stored as a
-- bare string.
UPDATE `notification`
SET `type` = 'share.received', `audience` = 'account', `app_uid` = NULL
WHERE `type` = ''
AND json_valid(`value`)
AND json_extract(`value`, '$.template') = 'file-shared-with-you';
UPDATE `notification`
SET `type` = 'share.claimed', `audience` = 'account', `app_uid` = NULL
WHERE `type` = ''
AND json_valid(`value`)
AND json_extract(`value`, '$.template') = 'file-shared-before-you-joined';
-- Worker rows carried only `source: 'worker'` and a title, so the app they
-- belong to is unrecoverable and they stay unattributed. The title prefix is
-- the sole surviving signal of which way the deploy went.
UPDATE `notification`
SET `audience` = 'developer',
`app_uid` = NULL,
`type` = CASE
WHEN json_extract(`value`, '$.title') LIKE 'Successfully deployed %'
THEN 'app.worker.deployed'
ELSE 'app.worker.deployFailed'
END
WHERE `type` = ''
AND json_valid(`value`)
AND json_extract(`value`, '$.source') = 'worker';
COMMIT;
@@ -0,0 +1,23 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Index `notification`.`created_at` for the retention sweep. See
-- mysql/mysql_mig_27.sql for the rationale. A plain index add, so no table
-- rebuild.
CREATE INDEX IF NOT EXISTS `idx_notification_created_at`
ON `notification` (`created_at`);
@@ -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/>.
-- Home region this account's data belongs in — a node id, not a storage
-- region. NULL for every account that predates the column, which falls back to
-- `signup_server`. What a name maps to is deployment config, so adding a
-- region needs no migration.
ALTER TABLE `user` ADD COLUMN `home` VARCHAR(64) DEFAULT NULL;
@@ -0,0 +1,90 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Subscriptions that outlive the connection that made them. Session
-- subscriptions never reach this table — they are Redis only — so every row
-- here is one somebody has to be able to find and revoke later.
--
-- - `sub_id` : `<appUid>#<uuid>`, or `user#<uuid>` for a row a user
-- session created. Unique table-wide; it is what
-- `unsubscribe` names.
-- - `owner_user_id` : owner of the anchor node. Dispatch only knows whose
-- resource changed, so this is the side a shared-folder
-- subscription has to be findable from, and the key a
-- region's cache is rebuilt under.
-- - `holder_user_id` : who subscribed — the delivery target, whose access is
-- re-checked, and who the quota counts against.
-- - `app_uid` : NULL for a row a user session created. No foreign
-- key: a row outlives the app that made it, which is
-- what keeps it listable and revocable afterwards.
-- - `anchor_*` : the resolved anchor. `match` is a glob relative to
-- `anchor_path`, so the path is not decoration — the
-- dispatch filter reads it on every event.
-- - `ops` : op filter; a comma-separated set, NULL for every op.
-- - `targets` : JSON array over socket|worker|push.
-- - `context` : plaintext JSON, hard-capped at 4 KB, read only on the
-- delivery path and never returned by `list`.
-- - `permission` : the ACL mode the subscribe check passed under,
-- re-checked per delivery.
-- - `suspended_*` : set when a subscription stops delivering without
-- being removed; the state machine that drives them
-- lands with the delivery classes that need it.
--
-- `created_at` and the two timestamps are unix seconds, matching
-- `app_feedback` and `user_block`.
CREATE TABLE IF NOT EXISTS `event_subscriptions` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"sub_id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"owner_user_id" INTEGER NOT NULL
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"holder_user_id" INTEGER NOT NULL
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"app_uid" TEXT DEFAULT NULL,
"subject" TEXT NOT NULL,
"anchor_uid" TEXT NOT NULL,
"anchor_path" TEXT NOT NULL,
"match" TEXT DEFAULT NULL,
"delivery" TEXT NOT NULL,
"ops" TEXT DEFAULT NULL,
"handler_name" TEXT DEFAULT NULL,
"targets" TEXT NOT NULL,
"context" TEXT DEFAULT NULL,
"permission" TEXT NOT NULL,
"suspended_at" INTEGER DEFAULT NULL,
"suspended_reason" TEXT DEFAULT NULL,
"expires_at" INTEGER DEFAULT NULL,
"created_at" INTEGER NOT NULL -- unix seconds
);
CREATE UNIQUE INDEX IF NOT EXISTS `idx_event_subscriptions_sub_id`
ON `event_subscriptions` (`sub_id`);
-- Dispatch reads the table only when a region's cache is cold; this is the
-- lookup it falls back to.
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_token`
ON `event_subscriptions` (`token`);
-- List, revoke and quota all key here — the index is the scope check rather
-- than a filter over a wider read.
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_holder`
ON `event_subscriptions` (`holder_user_id`, `app_uid`);
-- Rebuilding one region's cache for one owner, and the sweep that follows it.
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_owner`
ON `event_subscriptions` (`owner_user_id`);
@@ -0,0 +1,50 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Named handlers an app deploys once and its users' subscriptions bind to by
-- name. A handler is an addressable object with its own lifecycle: nothing
-- triggers by name, and a row here runs only when a subscription bound to it
-- has a delivery.
--
-- - `app_uid` : the namespace. Names are unique per app, and there is no
-- foreign key for the same reason `event_subscriptions`
-- has none — a row has to stay manageable after the app
-- row moves.
-- - `name` : the identity a subscription binds to, stable across
-- source changes.
-- - `source` : the serialized function. Read only by the delivery path
-- and never returned by `list`.
-- - `source_hash` : change detector and idempotency key. A publish carrying
-- the same hash is a no-op, and a subscription sending an
-- inline hash binds only when it matches this one.
--
-- `created_at` / `updated_at` are unix seconds, matching `event_subscriptions`.
CREATE TABLE IF NOT EXISTS `event_handlers` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"app_uid" TEXT NOT NULL,
"name" TEXT NOT NULL,
"source" TEXT NOT NULL,
"source_hash" TEXT NOT NULL,
"created_at" INTEGER NOT NULL, -- unix seconds
"updated_at" INTEGER NOT NULL
);
-- The name is the identity, and it is unique inside one app. This index is
-- also the lookup a subscribe binding check runs on.
CREATE UNIQUE INDEX IF NOT EXISTS `idx_event_handlers_app_name`
ON `event_handlers` (`app_uid`, `name`);
@@ -0,0 +1,41 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- A `group` row with `kind = 'team'` owns accounts and pays for them; seeded
-- system groups keep `kind` NULL so a team query never returns them.
ALTER TABLE `group` ADD COLUMN `kind` TEXT DEFAULT NULL;
ALTER TABLE `group` ADD COLUMN `name` TEXT DEFAULT NULL;
ALTER TABLE `group` ADD COLUMN `handle` TEXT COLLATE NOCASE DEFAULT NULL;
ALTER TABLE `group` ADD COLUMN `deleted_at` TIMESTAMP DEFAULT NULL;
-- NOCASE on both column and index, so lookups match mysql's utf8mb4_unicode_ci.
CREATE UNIQUE INDEX IF NOT EXISTS `idx_group_handle`
ON `group` (`handle` COLLATE NOCASE);
-- mysql and postgres already index this column; sqlite does not.
CREATE INDEX IF NOT EXISTS `idx_group_owner` ON `group` (`owner_user_id`);
-- 1 = team-created, 0 = the team owner. Decides who pays, not who may read.
ALTER TABLE `jct_user_group` ADD COLUMN `org_owned` INTEGER DEFAULT NULL;
-- Covers "list this team's members"; the unique pair index lands in 0072.
CREATE INDEX IF NOT EXISTS `idx_jct_user_group_group`
ON `jct_user_group` (`group_id`, `user_id`);
-- Fourth `requires_*` flag; assertVerifiedAccount will enforce it in phase 5.
ALTER TABLE `user` ADD COLUMN `requires_password_change` INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,30 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- `GroupStore.addUsers` had no conflict clause, so a repeat call duplicated the pair.
-- `readUserGroupPerms` joins this table on group_id alone, so each duplicate reported
-- every group permission an extra time. The delete clears what accumulated.
-- Dropping the higher id is lossless: `addUsers` writes only the two id columns, and
-- nothing references `jct_user_group.id`.
DELETE FROM `jct_user_group`
WHERE `id` NOT IN (
SELECT MIN(`id`) FROM `jct_user_group` GROUP BY `user_id`, `group_id`
);
CREATE UNIQUE INDEX IF NOT EXISTS `idx_jct_user_group_pair`
ON `jct_user_group` (`user_id`, `group_id`);
@@ -0,0 +1,66 @@
-- 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/>.
-- Insert-only record of what an admin did to an account, plus `share.holder_group_id`.
CREATE TABLE IF NOT EXISTS `audit_team_membership` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
-- Nullable FK beside a NOT NULL `_keep`, as `audit_user_to_group_permissions` since 0019.
"group_id" INTEGER DEFAULT NULL,
"group_id_keep" INTEGER NOT NULL,
"user_id" INTEGER DEFAULT NULL,
"user_id_keep" INTEGER NOT NULL,
"actor_user_id" INTEGER DEFAULT NULL,
"action" TEXT NOT NULL,
"reason" TEXT DEFAULT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- SET NULL, never CASCADE: deleting an account must not erase what was done to it.
FOREIGN KEY("group_id") REFERENCES "group" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
FOREIGN KEY("actor_user_id") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- The admin's view: one team, newest first.
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_group`
ON `audit_team_membership` (`group_id_keep`, `id`);
-- The member's view; the only place a reset becomes visible to its subject.
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_user`
ON `audit_team_membership` (`user_id_keep`, `id`);
-- Without these, SET NULL scans the audit table on every user or group delete.
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_group_fk`
ON `audit_team_membership` (`group_id`);
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_user_fk`
ON `audit_team_membership` (`user_id`);
CREATE INDEX IF NOT EXISTS `idx_audit_team_membership_actor_fk`
ON `audit_team_membership` (`actor_user_id`);
-- Mirrors `holder_user_id` from 0067: a `share` row is a listing entry, not the grant.
ALTER TABLE `share` ADD COLUMN `holder_group_id` INTEGER DEFAULT NULL
REFERENCES `group` (`id`) ON DELETE CASCADE ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS `idx_share_holder_group`
ON `share` (`holder_group_id`, `id`);
-- Team shares leave `holder_user_id` NULL, and NULLs are distinct, so the existing
-- unique index binds none of them.
CREATE UNIQUE INDEX IF NOT EXISTS `idx_share_holder_group_entry_issuer`
ON `share` (`holder_group_id`, `fsentry_id`, `issuer_user_id`);
@@ -0,0 +1,59 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- An opaque name for a region of one user's key-value namespace that somebody
-- else may watch. Key-value subjects carry no owner component, so without this
-- a shared key is unaddressable: the handle is what a filesystem node uid
-- already is, an owner-independent name for a shared root.
--
-- - `handle` : `kvh-<uuid>`, deliberately shaped unlike an app uid so
-- the subject parser can tell which slot it is in. It is
-- the only form the grantee ever sees, and it names
-- neither the owner's username nor their uuid.
-- - `permission` : the user-to-user grant this handle mirrors. Stored
-- rather than derived: it is the key revocation settling
-- matches subscriptions on, and deriving it would tie the
-- settle path to a lookup per row.
-- - `key_prefix` : the granted root, always ending on the key delimiter,
-- so `key_prefix + relative` is a whole key.
-- - `revoked_at` : set rather than deleted. A revoked handle stays visible
-- to its owner, which is the only record of what was
-- shared and when it stopped.
--
-- `created_at` / `revoked_at` are unix seconds, matching `event_subscriptions`.
CREATE TABLE IF NOT EXISTS `kv_share_handles` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"handle" TEXT NOT NULL,
"owner_user_id" INTEGER NOT NULL
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"grantee_user_id" INTEGER NOT NULL
REFERENCES `user` ("id") ON DELETE CASCADE ON UPDATE CASCADE,
"app_uid" TEXT NOT NULL,
"key_prefix" TEXT NOT NULL,
"permission" TEXT NOT NULL,
"created_at" INTEGER NOT NULL, -- unix seconds
"revoked_at" INTEGER DEFAULT NULL
);
-- Resolving a subject is a lookup by handle, on the subscribe path.
CREATE UNIQUE INDEX IF NOT EXISTS `idx_kv_share_handles_handle`
ON `kv_share_handles` (`handle`);
-- The owner's audit listing, and the scope check behind revoke.
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_owner`
ON `kv_share_handles` (`owner_user_id`, `id`);
@@ -0,0 +1,35 @@
-- 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/>.
-- `event_subscriptions` grew three hot queries with no index behind them:
--
-- - `(app_uid, handler_name)` : a handler's own rows (`listByHandler`) —
-- suspending/resuming everything one
-- publish or removal touches.
-- - `(expires_at)` : the expiry reaper's sweep.
-- - `(suspended_at, id)` : the suspended reaper's sweep. The range on
-- `suspended_at` is the win; `id` only keeps
-- the reaper's keyset page off the table.
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_app_handler`
ON `event_subscriptions` (`app_uid`, `handler_name`);
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_expires`
ON `event_subscriptions` (`expires_at`);
CREATE INDEX IF NOT EXISTS `idx_event_subscriptions_suspended`
ON `event_subscriptions` (`suspended_at`, `id`);
@@ -0,0 +1,21 @@
-- 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/>.
-- Unix seconds after which an admin-issued temporary password stops
-- authenticating, so an unused reset dies instead of becoming a standing
-- credential. NULL for every password the account chose itself.
ALTER TABLE `user` ADD COLUMN `temp_password_expires_at` INTEGER DEFAULT 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/>.
-- Whether apps acting for a member may read this team's member list.
-- Members can already enumerate each other through `/teams/:uid/members`,
-- which requires a user actor; this opens the same names to an app actor,
-- so it is off until the team owner turns it on.
ALTER TABLE `group` ADD COLUMN `directory_enabled` INTEGER NOT NULL DEFAULT 0;
+39 -9
View File
@@ -3,18 +3,19 @@
*
* 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.
* 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.
* 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/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { createServer, type Server } from 'node:http';
@@ -302,6 +303,35 @@ describe('DDBClient — batch writes', () => {
const stored = await client.query(TABLE, { pk: 'bulk' });
expect(stored.Items).toHaveLength(60);
});
it('reports no consumed capacity for an empty delete batch', async () => {
await expect(client.batchDel([])).resolves.toEqual({
ConsumedCapacity: [],
});
});
it('batch-deletes across chunks, leaving unrelated items alone', async () => {
const items = Array.from({ length: 30 }, (_, index) => ({
table: TABLE,
item: { pk: 'bulk-del', sk: `item-${index}`, index },
}));
await client.batchPut(items);
// Delete all but the last item — 29 keys still exercises chunking
// paths shared with batchPut while proving deletion is targeted.
const result = await client.batchDel(
items.slice(0, -1).map(({ table, item }) => ({
table,
key: { pk: item.pk, sk: item.sk },
})),
);
expect(result.ConsumedCapacity).toHaveLength(1);
expect(result.ConsumedCapacity[0].TableName).toBe(TABLE);
const stored = await client.query(TABLE, { pk: 'bulk-del' });
expect(stored.Items?.map((item) => item.sk)).toEqual(['item-29']);
});
});
describe('DDBClient — expired item sweep', () => {
+145 -31
View File
@@ -45,6 +45,11 @@ import { HttpError } from '../../core/http';
import type { IConfig, IDynamoConfig } from '../../types';
import { Span } from '../../util/span.js';
import { PuterClient } from '../types';
import {
clampStoredNumber,
isRepairableMarshallError,
repairForMarshall,
} from './marshallRepair.js';
const LOCAL_DYNAMO_MEMORY_PREFIX = ':memory:';
const localDynaliteEndpointPromises = new Map<string, Promise<string>>();
@@ -114,6 +119,49 @@ const sleep = async (ms: number) => {
await new Promise((resolve) => setTimeout(resolve, ms));
};
// One caller writing an out-of-range number usually keeps doing it, so the
// warning is per-target and throttled rather than one line per write.
const REPAIR_WARNING_INTERVAL_MS = 60_000;
const lastRepairWarningAt = new Map<string, number>();
const warnRepaired = (target: string, message: string): void => {
const now = Date.now();
const lastWarnedAt = lastRepairWarningAt.get(target) ?? 0;
if (now - lastWarnedAt < REPAIR_WARNING_INTERVAL_MS) return;
lastRepairWarningAt.set(target, now);
console.warn(
`[ddb] clamped an out-of-range value in ${target}: ${message}`,
);
};
/**
* Send a write, and if the payload was rejected while being encoded for a value
* the store cannot represent, clamp it and send it once more.
*
* Nothing is inspected on the way in: the payload of a successful write is
* never walked, so this costs one `try` on the hot path. The repair only runs
* on the failure, and only retries when it actually changed something —
* otherwise the original error stands.
*/
const sendRepairingValues = async <TPayload, TResult>(
payload: TPayload,
send: (payload: TPayload) => Promise<TResult>,
describe: () => string,
): Promise<TResult> => {
try {
return await send(payload);
} catch (error) {
if (!isRepairableMarshallError(error)) throw error;
const repaired = repairForMarshall(payload);
if (!repaired.changed) throw error;
warnRepaired(describe(), (error as Error).message);
return send(repaired.value as TPayload);
}
};
export class DDBClient extends PuterClient {
#documentClient: DynamoDBDocumentClient | null = null;
#localInitPromise: Promise<void> | null = null;
@@ -171,14 +219,19 @@ export class DDBClient extends PuterClient {
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
async put<T extends Record<string, unknown>>(table: string, item: T) {
const command = new PutCommand({
TableName: table,
Item: item,
ReturnConsumedCapacity: 'TOTAL',
});
const client = await this.#getDocumentClient();
return client.send(command);
return sendRepairingValues(
item,
(itemToWrite) =>
client.send(
new PutCommand({
TableName: table,
Item: itemToWrite,
ReturnConsumedCapacity: 'TOTAL',
}),
),
() => `a put to ${table}`,
);
}
@Span('ddb.batchGet', (params: unknown[]) => ({
@@ -222,6 +275,37 @@ export class DDBClient extends PuterClient {
'db.batch_size': params.length,
}))
async batchPut(params: { table: string; item: Record<string, unknown> }[]) {
return this.#batchWrite(
params.map(({ table, item }) => ({
table,
request: { PutRequest: { Item: item } },
})),
);
}
@Span('ddb.batchDel', (params: unknown[]) => ({
'db.batch_size': params.length,
}))
async batchDel(params: { table: string; key: Record<string, unknown> }[]) {
return this.#batchWrite(
params.map(({ table, key }) => ({
table,
request: { DeleteRequest: { Key: key } },
})),
);
}
// Shared BatchWriteItem plumbing for batchPut/batchDel: 25-item chunks,
// UnprocessedItems retried with capped exponential backoff, consumed
// capacity accumulated per table across every request.
async #batchWrite(
params: {
table: string;
request: NonNullable<
BatchWriteCommandInput['RequestItems']
>[string][number];
}[],
) {
const consumedCapacityByTable = new Map<string, number>();
if (params.length === 0) {
return { ConsumedCapacity: [] };
@@ -258,11 +342,7 @@ export class DDBClient extends PuterClient {
let requestItems = chunk.reduce(
(acc, curr) => {
const tableRequests = acc[curr.table] ?? [];
tableRequests.push({
PutRequest: {
Item: curr.item,
},
});
tableRequests.push(curr.request);
acc[curr.table] = tableRequests;
return acc;
},
@@ -278,11 +358,17 @@ export class DDBClient extends PuterClient {
break;
}
const response = await client.send(
new BatchWriteCommand({
RequestItems: requestItems,
ReturnConsumedCapacity: 'TOTAL',
}),
const response = await sendRepairingValues(
requestItems,
(itemsToWrite) =>
client.send(
new BatchWriteCommand({
RequestItems: itemsToWrite,
ReturnConsumedCapacity: 'TOTAL',
}),
),
() =>
`a batch write to ${Object.keys(requestItems).join(', ')}`,
);
accumulateConsumedCapacity(
response.ConsumedCapacity as
@@ -328,11 +414,18 @@ export class DDBClient extends PuterClient {
}
@Span('ddb.del', (table: string) => ({ 'db.table': table }))
async del<T extends Record<string, unknown>>(table: string, key: T) {
async del<T extends Record<string, unknown>>(
table: string,
key: T,
opts?: { returnOld?: boolean },
) {
const command = new DeleteCommand({
TableName: table,
Key: key,
ReturnConsumedCapacity: 'TOTAL',
// ALL_OLD makes the delete an atomic claim: exactly one caller
// gets the attributes back.
...(opts?.returnOld ? { ReturnValues: 'ALL_OLD' as const } : {}),
});
const client = await this.#getDocumentClient();
@@ -348,6 +441,7 @@ export class DDBClient extends PuterClient {
index = '',
consistentRead = false,
options?: {
scanIndexForward?: boolean;
beginsWith?: { key: string; value: string };
select?: 'COUNT';
filter?: {
@@ -404,6 +498,9 @@ export class DDBClient extends PuterClient {
? { FilterExpression: options.filter.expression }
: {}),
...(options?.select ? { Select: options.select } : {}),
...(options?.scanIndexForward !== undefined
? { ScanIndexForward: options.scanIndexForward }
: {}),
ReturnConsumedCapacity: 'TOTAL',
});
@@ -418,25 +515,36 @@ export class DDBClient extends PuterClient {
expression: string,
expressionValues?: Record<string, unknown>,
expressionNames?: Record<string, string>,
options?: { condition?: string },
) {
const hasValues =
!!expressionValues && Object.keys(expressionValues).length > 0;
const hasNames =
!!expressionNames && Object.keys(expressionNames).length > 0;
const command = new UpdateCommand({
TableName: table,
Key: key,
UpdateExpression: expression,
...(hasValues
? { ExpressionAttributeValues: expressionValues }
: {}),
...(hasNames ? { ExpressionAttributeNames: expressionNames } : {}),
ReturnValues: 'ALL_NEW',
ReturnConsumedCapacity: 'TOTAL',
});
const client = await this.#getDocumentClient();
return client.send(command);
return sendRepairingValues(
expressionValues,
(valuesToWrite) =>
client.send(
new UpdateCommand({
TableName: table,
Key: key,
UpdateExpression: expression,
...(hasValues
? { ExpressionAttributeValues: valuesToWrite }
: {}),
...(hasNames
? { ExpressionAttributeNames: expressionNames }
: {}),
...(options?.condition
? { ConditionExpression: options.condition }
: {}),
ReturnValues: 'ALL_NEW',
ReturnConsumedCapacity: 'TOTAL',
}),
),
() => `an update to ${table}`,
);
}
async createTableIfNotExists(
@@ -538,6 +646,9 @@ export class DDBClient extends PuterClient {
marshallOptions: {
removeUndefinedValues: true,
},
unmarshallOptions: {
wrapNumbers: clampStoredNumber,
},
});
}
@@ -565,6 +676,9 @@ export class DDBClient extends PuterClient {
marshallOptions: {
removeUndefinedValues: true,
},
unmarshallOptions: {
wrapNumbers: clampStoredNumber,
},
});
}
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import {
clampStoredNumber,
isRepairableMarshallError,
MAX_STORED_NUMBER,
MIN_STORED_NUMBER,
repairForMarshall,
} from './marshallRepair.ts';
describe('isRepairableMarshallError', () => {
it('recognizes the out-of-range and special-value failures', () => {
for (const message of [
'Number 1.6515584833071455e+55 is greater than Number.MAX_SAFE_INTEGER. Use NumberValue from @aws-sdk/lib-dynamodb.',
'Number -1e+55 is lesser than Number.MIN_SAFE_INTEGER. Use NumberValue from @aws-sdk/lib-dynamodb.',
'Special numeric value NaN is not allowed',
'Special numeric value Infinity is not allowed',
]) {
expect(isRepairableMarshallError(new Error(message))).toBe(true);
}
});
it('leaves every other failure alone', () => {
for (const error of [
new Error('Unsupported type passed: [object Symbol].'),
new Error('ValidationException'),
new Error(''),
undefined,
'not an error',
]) {
expect(isRepairableMarshallError(error)).toBe(false);
}
});
});
describe('repairForMarshall', () => {
it('clamps a number past the safe range in both directions', () => {
expect(repairForMarshall(1.6515584833071455e55)).toEqual({
value: MAX_STORED_NUMBER,
changed: true,
});
expect(repairForMarshall(-1e55)).toEqual({
value: MIN_STORED_NUMBER,
changed: true,
});
});
it('clamps infinities and nulls NaN', () => {
expect(repairForMarshall(Infinity).value).toBe(MAX_STORED_NUMBER);
expect(repairForMarshall(-Infinity).value).toBe(MIN_STORED_NUMBER);
expect(repairForMarshall(NaN)).toEqual({ value: null, changed: true });
});
it('repairs numbers nested in objects and arrays', () => {
const repaired = repairForMarshall({
profile: { netWorth: 1e55, level: 29 },
scores: [1, [2, -1e55]],
});
expect(repaired.changed).toBe(true);
expect(repaired.value).toEqual({
profile: { netWorth: MAX_STORED_NUMBER, level: 29 },
scores: [1, [2, MIN_STORED_NUMBER]],
});
});
it('returns the original payload untouched when nothing is out of range', () => {
const payload = {
a: MAX_STORED_NUMBER,
b: [1, 2, { c: 'three' }],
d: null,
};
const repaired = repairForMarshall(payload);
expect(repaired.changed).toBe(false);
expect(repaired.value).toBe(payload);
});
it('does not mutate the payload it repairs', () => {
const nested = { netWorth: 1e55 };
const payload = { nested };
repairForMarshall(payload);
expect(nested.netWorth).toBe(1e55);
});
it('keeps a `__proto__` key as data', () => {
const payload = JSON.parse('{"__proto__":{"big":1e55}}');
const repaired = repairForMarshall(payload) as {
value: Record<string, unknown>;
changed: boolean;
};
expect(repaired.changed).toBe(true);
expect(Object.getPrototypeOf(repaired.value)).toBe(Object.prototype);
expect(
Object.getOwnPropertyDescriptor(repaired.value, '__proto__')?.value,
).toEqual({ big: MAX_STORED_NUMBER });
});
it('leaves values it does not own alone', () => {
const date = new Date(0);
expect(repairForMarshall(date).value).toBe(date);
expect(repairForMarshall('str')).toEqual({
value: 'str',
changed: false,
});
});
});
describe('clampStoredNumber', () => {
it('decodes a number inside the range unchanged', () => {
expect(clampStoredNumber('42')).toBe(42);
expect(clampStoredNumber('-0.5')).toBe(-0.5);
expect(clampStoredNumber(String(MAX_STORED_NUMBER))).toBe(
MAX_STORED_NUMBER,
);
});
it('clamps a stored number past the range instead of decoding a BigInt', () => {
expect(clampStoredNumber('18014398509481982')).toBe(MAX_STORED_NUMBER);
expect(clampStoredNumber('-18014398509481982')).toBe(MIN_STORED_NUMBER);
expect(clampStoredNumber('1e400')).toBe(MAX_STORED_NUMBER);
});
});
@@ -0,0 +1,129 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* A stored number keeps its exact value only within the IEEE-754 safe integer
* range; a magnitude past this cannot round-trip, and the document client
* refuses to marshall one rather than write a value that reads back wrong.
*/
export const MAX_STORED_NUMBER = Number.MAX_SAFE_INTEGER;
export const MIN_STORED_NUMBER = Number.MIN_SAFE_INTEGER;
/**
* Marshalling failures a retry can fix by clamping: an out-of-range number, or
* a special numeric value (`NaN`, `±Infinity`) that has no representation at
* all. Matched on the message because they are plain `Error`s thrown while
* encoding the payload — no error name or code separates them from anything
* else, and they never reach the wire, so there is no response to inspect.
*/
const REPAIRABLE_MARSHALL_ERRORS = [
/is greater than Number\.MAX_SAFE_INTEGER/,
/is lesser than Number\.MIN_SAFE_INTEGER/,
/^Special numeric value /,
];
export const isRepairableMarshallError = (error: unknown): boolean => {
const message = (error as Error | undefined)?.message;
if (typeof message !== 'string') return false;
return REPAIRABLE_MARSHALL_ERRORS.some((pattern) => pattern.test(message));
};
/**
* The same bound, applied to what comes back. A stored number past the safe
* range — left by an older write, or reached by a counter incremented up to it
* — is otherwise decoded as a `BigInt`, which no JSON response can carry. This
* hands back the same kind of value a write accepts, and does less work per
* number than the default decoding it replaces.
*/
export const clampStoredNumber = (stored: string): number => {
const num = Number(stored);
if (num > MAX_STORED_NUMBER) return MAX_STORED_NUMBER;
if (num < MIN_STORED_NUMBER) return MIN_STORED_NUMBER;
return num;
};
export interface MarshallRepair {
value: unknown;
/** False when nothing in the payload was out of range — nothing to retry. */
changed: boolean;
}
const unchanged = (value: unknown): MarshallRepair => ({
value,
changed: false,
});
// Matches the shapes the marshaller walks into as a map: object literals and
// null-prototype objects, but not class instances (Date, Buffer, Set, …),
// which it encodes by their own rules.
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' &&
value !== null &&
(value.constructor === Object || value.constructor === undefined);
const repairNumber = (num: number): MarshallRepair => {
// No representation for these; JSON already turns them into null, so a
// caller that got one this far did not send it as JSON.
if (Number.isNaN(num)) return { value: null, changed: true };
if (num > MAX_STORED_NUMBER)
return { value: MAX_STORED_NUMBER, changed: true };
if (num < MIN_STORED_NUMBER)
return { value: MIN_STORED_NUMBER, changed: true };
return unchanged(num);
};
/**
* Clamp every number a payload holds into the range the store accepts,
* returning the original object untouched when nothing needed it — callers hold
* on to the values they pass (caches, retries), so a repair copies rather than
* mutating in place.
*
* Only run after a marshalling failure. Walking a payload this way costs as
* much as marshalling it, which is exactly why writes are not checked up
* front.
*/
export const repairForMarshall = (value: unknown): MarshallRepair => {
if (typeof value === 'number') return repairNumber(value);
if (Array.isArray(value)) {
let changed = false;
const repairedEntries = value.map((entry) => {
const repaired = repairForMarshall(entry);
changed = changed || repaired.changed;
return repaired.value;
});
return changed ? { value: repairedEntries, changed } : unchanged(value);
}
if (isPlainObject(value)) {
let changed = false;
const repairedEntries = Object.entries(value).map(([key, entry]) => {
const repaired = repairForMarshall(entry);
changed = changed || repaired.changed;
return [key, repaired.value] as const;
});
// `fromEntries` defines each property rather than assigning it, so a
// `__proto__` key in the payload stays data here.
return changed
? { value: Object.fromEntries(repairedEntries), changed }
: unchanged(value);
}
return unchanged(value);
};
+193 -5
View File
@@ -19,6 +19,7 @@
import { describe, expect, it, vi } from 'vitest';
import type { IConfig } from '../../types';
import { digestLines } from '../../services/share/shareNotifyTitle';
import { EmailClient } from './EmailClient';
const FROM = '"Puter" <[email protected]>';
@@ -54,14 +55,22 @@ const sentMessage = (info: unknown) =>
};
describe('EmailClient — transport lifecycle', () => {
it('reports as configured once a transport is wired up', () => {
it('stops sending once the transport is shut down', async () => {
const client = startClient();
expect(client.isConfigured).toBe(true);
expect(
await client.sendRaw({ to: '[email protected]', subject: 'up', text: 'x' }),
).not.toBeNull();
client.onServerShutdown();
expect(client.isConfigured).toBe(false);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(
await client.sendRaw({ to: '[email protected]', subject: 'down', text: 'x' }),
).toBeNull();
warn.mockRestore();
});
it('warns and stays unconfigured when no transport is set', () => {
it('warns at boot when no transport is set', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const client = new EmailClient({
port: 0,
@@ -69,7 +78,6 @@ describe('EmailClient — transport lifecycle', () => {
} as unknown as IConfig);
client.onServerStart();
expect(client.isConfigured).toBe(false);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('no email transport configured'),
);
@@ -238,6 +246,186 @@ describe('EmailClient — template rendering', () => {
});
});
describe('EmailClient — share notification templates', () => {
/** Captures both parts of a send without touching the transport. */
const renderShare = async (
template: 'file_shared_with_you' | 'file_shared_invite',
values: Record<string, unknown>,
) => {
const client = startClient();
const captured: { html: string; text: string } = { html: '', text: '' };
vi.spyOn(client, 'sendRaw').mockImplementation(async (options) => {
captured.html = options.html ?? '';
captured.text = options.text ?? '';
return null;
});
await client.send('[email protected]', template, values);
return captured;
};
// Built by the producer rather than hand-shaped, so a change to the digest
// wording can't leave these fixtures describing a shape it no longer emits.
const HOLDER = {
recipient: 'alice',
subject_line: 'bob shared notes.md with you',
shares: digestLines([
{
username: 'bob',
count: 1,
items: [{ name: 'notes.md', link: 'https://puter.test/?shared=%2Fbob%2Fu1%2Fnotes.md' }],
},
{
username: 'carol',
count: 3,
items: [{ name: 'a.txt' }, { name: 'b.txt' }],
},
]),
link: 'https://puter.test/?shared=%2Fbob%2Fu1%2Fnotes.md',
origin: 'https://puter.test',
unsubscribe_uuid: null,
};
it('sends a plain-text alternative beside the html', async () => {
const { html, text } = await renderShare(
'file_shared_with_you',
HOLDER,
);
expect(html).toContain('<!DOCTYPE html>');
// Every sender reaches both parts, so a text-only client loses nothing.
for (const part of [html, text]) {
expect(part).toContain('bob');
expect(part).toContain('notes.md');
expect(part).toContain('carol');
expect(part).toContain('+1 more');
}
expect(text).not.toContain('<');
});
it('escapes item names in the html and leaves them raw in the text', async () => {
const { html, text } = await renderShare('file_shared_with_you', {
...HOLDER,
shares: digestLines([
{
username: 'bob',
count: 1,
items: [{ name: 'r&d "notes".md' }],
},
]),
});
expect(html).toContain('r&amp;d &quot;notes&quot;.md');
expect(text).toContain('r&d "notes".md');
});
// The name links to the item. The URL is ours, built from the origin and one
// encoded path, so it stays literal — an entity-escaped `=` would still
// resolve, but the text part has no parser to undo it.
it('links a named item to itself in both parts', async () => {
const link = 'https://puter.test/?shared=%2Fbob%2Fu1%2Fnotes.md';
const { html, text } = await renderShare('file_shared_with_you', HOLDER);
expect(html).toContain(`<a href="${link}"`);
expect(html).toContain(`>notes.md</a>`);
expect(text).toContain(link);
expect(html).not.toContain('&#x3D;');
});
// An item with no link — an invite, with no account to route to yet —
// renders as the plain name it always did.
it('leaves an unlinked item as plain text', async () => {
const { html } = await renderShare('file_shared_with_you', HOLDER);
expect(html).toContain('a.txt');
expect(html).not.toContain('>a.txt</a>');
});
it('renders as a responsive single column with no remote assets', async () => {
const { html } = await renderShare('file_shared_with_you', HOLDER);
expect(html).toContain(
'<meta name="viewport" content="width=device-width, initial-scale=1" />',
);
expect(html).toContain('@media only screen and (max-width: 600px)');
expect(html).toContain('@media (prefers-color-scheme: dark)');
expect(html).toContain('max-width: 600px');
// Images are the one thing a client can refuse to load, so the design
// does without them — the layout can't depend on a blocked asset.
expect(html).not.toContain('<img');
// The colors have to survive a client that drops the stylesheet.
expect(html).toContain('style="background-color: #ffffff;');
});
it('opens with a preview line that is hidden in the body', async () => {
const { html } = await renderShare('file_shared_with_you', HOLDER);
const preheader = html.indexOf('Waiting for you under Shared with me.');
expect(preheader).toBeGreaterThan(-1);
expect(html.slice(0, preheader)).toContain('mso-hide: all');
expect(preheader).toBeLessThan(html.indexOf('Hi alice,'));
});
// The button carries every item in the mail, so it is a query string with
// `&` and `=` in it — which must reach the client as written, in both parts.
it('points the call to action at Shared with every item picked out', async () => {
const link =
'https://puter.test/?shared=%2Fbob%2Fu1%2Fa.txt&shared=%2Fbob%2Fu2%2Fb.txt';
const { html, text } = await renderShare('file_shared_with_you', {
...HOLDER,
link,
});
expect(html).toContain(`href="${link}"`);
expect(html).toContain('>Open Puter</a>');
expect(text).toContain(`Open Puter: ${link}`);
expect(html).not.toContain('&#x3D;');
expect(html).not.toContain('&amp;shared');
});
it('separates senders without a rule above the first', async () => {
const { html } = await renderShare('file_shared_with_you', HOLDER);
// One rule between two senders, none leading the list.
expect(html.split('border-top: 1px solid').length - 1).toBe(1);
});
it('offers the unsubscribe link only to a recipient who has an account', async () => {
const withAccount = await renderShare('file_shared_with_you', {
...HOLDER,
unsubscribe_uuid: 'a-uuid',
});
expect(withAccount.html).toContain(
'href="https://puter.test/unsubscribe?user_uuid=a-uuid"',
);
expect(withAccount.text).toContain(
'https://puter.test/unsubscribe?user_uuid=a-uuid',
);
const anonymous = await renderShare('file_shared_with_you', HOLDER);
expect(anonymous.html).not.toContain('/unsubscribe');
expect(anonymous.text).not.toContain('/unsubscribe');
});
it('tells an invited address what to do with it', async () => {
const { html, text } = await renderShare('file_shared_invite', {
email: '[email protected]',
subject_line: 'bob shared notes.md with you on Puter',
shares: digestLines([
{ username: 'bob', count: 1, items: [{ name: 'notes.md' }] },
]),
link: 'https://puter.test',
});
for (const part of [html, text]) {
expect(part).toContain('[email protected]');
expect(part).toContain('Create your free account');
// An invite has no account to unsubscribe from.
expect(part).not.toContain('/unsubscribe');
}
expect(html).toContain('href="https://puter.test"');
});
});
describe('EmailClient.clean', () => {
const clean = (email: string) => startClient().clean(email);
+36 -12
View File
@@ -22,12 +22,21 @@ import handlebars, { template } from 'handlebars';
import nodemailer from 'nodemailer';
import type { IConfig } from '../../types';
import { PuterClient } from '../types';
import { EMAIL_TEMPLATES, type EmailTemplateName } from './templates';
import {
EMAIL_TEMPLATES,
type EmailTemplate,
type EmailTemplateName,
} from './templates';
/** Attachment shape passed through to the underlying transport. */
/**
* Attachment shape passed through to the underlying transport. Give either
* `content` (held in memory) or `path` (a local file the transport streams on
* its own, re-read for every message that carries it).
*/
export interface EmailAttachment {
filename: string;
content: Buffer | string;
content?: Buffer | string;
path?: string;
contentType?: string;
encoding?: string;
}
@@ -64,6 +73,7 @@ export type EmailValidator = (email: string) => Promise<boolean> | boolean;
interface CompiledTemplate {
subject: ReturnType<typeof template>;
html: ReturnType<typeof template>;
text?: ReturnType<typeof template>;
}
// -- Clean-email rules ------------------------------------------------
@@ -156,23 +166,28 @@ 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).
*
* Returns the transport's send result, or `null` when none is configured —
* {@link sendRaw} drops the message instead of throwing. A caller that
* treats undelivered mail as a failure must check for `null` too.
*/
async send<T extends EmailTemplateName>(
to: string,
template: T,
values: Record<string, unknown> = {},
options: { replyTo?: string } = {},
): Promise<void> {
) {
const compiled = this.compiledTemplates[template];
if (!compiled) {
throw new Error(`Unknown email template: ${template}`);
}
await this.sendRaw({
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 } : {}),
});
}
@@ -183,7 +198,8 @@ 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 `isConfigured` first).
* silently drop mail should check `config.email` before composing, or the
* `null` return after).
*/
async sendRaw(options: SendMailOptions) {
if (!this.transport) {
@@ -199,11 +215,6 @@ export class EmailClient extends PuterClient {
});
}
/** Whether an SMTP transport is configured (sends are no-ops otherwise). */
get isConfigured(): boolean {
return this.transport !== null;
}
// -- Public API: clean / validate ---------------------------------
/**
@@ -289,7 +300,11 @@ export class EmailClient extends PuterClient {
}
private compileTemplates(): void {
for (const [name, template] of Object.entries(EMAIL_TEMPLATES)) {
// Widened to the interface: the literal map keeps a distinct type per
// template, and only some of them carry a `text` part.
const templates: Record<EmailTemplateName, EmailTemplate> =
EMAIL_TEMPLATES;
for (const [name, template] of Object.entries(templates)) {
this.compiledTemplates[name as EmailTemplateName] = {
// Subjects are plain-text headers: HTML-escaping would put
// literal entities in front of the recipient (&amp; etc.).
@@ -300,6 +315,15 @@ export class EmailClient extends PuterClient {
noEscape: true,
}),
html: handlebars.compile(dedent(template.html)),
// Same reasoning as the subject: a text part is not HTML, so
// escaping would show entities to the reader.
...(template.text
? {
text: handlebars.compile(dedent(template.text), {
noEscape: true,
}),
}
: {}),
};
}
}
+325 -24
View File
@@ -29,8 +29,195 @@
export interface EmailTemplate {
subject: string;
html: string;
/**
* Optional plain-text alternative. Where a template has one it goes out
* beside the HTML as multipart/alternative, which is what plain-text
* clients, screen readers and spam scoring all prefer to a machine
* down-conversion of the markup.
*/
text?: string;
}
// -- Shared layout ----------------------------------------------------
/**
* Colors, in one place so the two share templates can't drift apart. Every one
* of them is also written inline further down: clients that drop `<style>`
* (Gmail reading a non-Gmail account, Outlook.com) would otherwise render an
* unstyled page, so the stylesheet only carries what inline CSS can't express —
* the small-screen and dark-mode overrides.
*
* `ACCENT` is the GUI's primary blue darkened until white text on it clears
* 4.5:1, since a button label is small text however bold it is.
*/
const INK = '#101828';
const INK_SOFT = '#5b6472';
const INK_FAINT = '#667085';
const PAGE_BG = '#f2f4f7';
const CARD_BG = '#ffffff';
const CARD_BORDER = '#e4e7ec';
const RULE = '#e6e9ee';
const PANEL_BG = '#f8f9fb';
const PANEL_BORDER = '#eceff3';
const ACCENT = '#0a76cc';
const FONT =
"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif";
/** Pads the preview line so body text can't leak into it. */
const PREHEADER_FILL = '&#847;&zwnj;&nbsp;'.repeat(60);
/**
* The document both share notifications render into: one 600px column that goes
* full width on a phone, laid out with tables and `bgcolor` because Outlook
* renders through Word and ignores anything more modern.
*
* `preheader` is the line the inbox shows beside the subject — it should add to
* the subject, not repeat it. `content` is the card's rows; `footer` sits below
* the card, where the reason-for-receiving and unsubscribe belong.
*/
const shareEmailLayout = (parts: {
preheader: string;
content: string;
footer: string;
}): string => `
<!DOCTYPE html>
<html lang="en" dir="ltr" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="x-apple-disable-message-reformatting" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title>{{subject_line}}</title>
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<![endif]-->
<style>
body { margin: 0; padding: 0; width: 100% !important; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
table { border-spacing: 0; mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
td { mso-line-height-rule: exactly; }
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; }
@media only screen and (max-width: 600px) {
.col { width: 100% !important; }
.pad { padding-left: 24px !important; padding-right: 24px !important; }
.pad-y { padding-top: 28px !important; padding-bottom: 28px !important; }
.h1 { font-size: 22px !important; line-height: 30px !important; }
.btn { width: 100% !important; }
}
@media (prefers-color-scheme: dark) {
.page { background-color: #0e1116 !important; }
.card { background-color: #171b22 !important; border-color: #272d38 !important; }
.ink { color: #e9ebef !important; }
.ink-soft { color: #9aa3b2 !important; }
.ink-soft a { color: #9aa3b2 !important; }
.rule { border-color: #272d38 !important; }
.panel { background-color: #1d222a !important; border-color: #2b323d !important; }
.mark { color: #4da3f2 !important; }
}
</style>
</head>
<body class="page" style="margin: 0; padding: 0; width: 100%; background-color: ${PAGE_BG};">
<div style="display: none; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all;">${parts.preheader}${PREHEADER_FILL}</div>
<div role="article" aria-roledescription="email" lang="en">
<table role="presentation" class="page" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%; background-color: ${PAGE_BG};">
<tr>
<td align="center" style="padding: 32px 12px;">
<!--[if mso]><table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0"><tr><td><![endif]-->
<table role="presentation" class="col" width="600" cellpadding="0" cellspacing="0" border="0" style="width: 100%; max-width: 600px; text-align: left;">
<tr>
<td class="pad mark" style="padding: 0 4px 14px; font-family: ${FONT}; font-size: 15px; line-height: 20px; font-weight: 700; letter-spacing: 0.01em; color: ${ACCENT};">Puter</td>
</tr>
<tr>
<td class="card" style="background-color: ${CARD_BG}; border: 1px solid ${CARD_BORDER}; border-radius: 14px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%;">
<tr>
<td class="pad pad-y" style="padding: 36px 36px 34px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%;">${parts.content}
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="pad ink-soft" style="padding: 20px 4px 0; font-family: ${FONT}; font-size: 13px; line-height: 20px; color: ${INK_FAINT};">${parts.footer}
</td>
</tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td>
</tr>
</table>
</div>
</body>
</html>
`;
/** The greeting line above the heading. */
const greetingRow = (text: string): string => `
<tr>
<td class="ink-soft" style="padding: 0 0 6px; font-family: ${FONT}; font-size: 15px; line-height: 22px; color: ${INK_SOFT};">${text}</td>
</tr>`;
const headingRow = (text: string): string => `
<tr>
<td class="ink h1" style="padding: 0 0 18px; font-family: ${FONT}; font-size: 24px; line-height: 32px; font-weight: 700; letter-spacing: -0.01em; color: ${INK};">${text}</td>
</tr>`;
/**
* One row per sender, hairline-separated. The wording comes pre-composed from
* the digest (`digestLines`), so the list stays a list however many senders and
* items fold into it.
*
* Each named item links to itself where it has one; `lead`/`trail` are the
* wording around the names. An unlinked item renders as the plain name.
*
* `link` is triple-braced because we build it, so `?` and `=` stay literal;
* `name` is the owner's text and stays escaped.
*/
const SHARE_ITEM_LIST = `{{this.lead}}{{#each this.items}}{{#unless @first}}, {{/unless}}{{#if this.link}}<a href="{{{this.link}}}" style="color: ${ACCENT}; text-decoration: underline;">{{this.name}}</a>{{else}}{{this.name}}{{/if}}{{/each}}{{this.trail}}`;
const SHARE_LIST_ROW = `
<tr>
<td class="panel" style="padding: 6px 18px; background-color: ${PANEL_BG}; border: 1px solid ${PANEL_BORDER}; border-radius: 10px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%;">
{{#each shares}}
<tr>
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}</td>
</tr>
{{/each}}
</table>
</td>
</tr>`;
/** A body paragraph. `padding` lets a caller tune the rhythm around it. */
const textRow = (text: string, padding = '18px 0 0'): string => `
<tr>
<td class="ink-soft" style="padding: ${padding}; font-family: ${FONT}; font-size: 15px; line-height: 23px; color: ${INK_SOFT};">${text}</td>
</tr>`;
/**
* The call to action. Padding sits on the cell and the color on `bgcolor` so
* Outlook still draws a real button (square-cornered, which is fine); the table
* goes full width under 600px so the tap target spans the card.
*
* `link` is triple-braced for the same reason as an item's: we build it, and a
* deep link's `=` and `&` should read the same in the html as in the text.
*/
const buttonRow = (label: string): string => `
<tr>
<td style="padding: 26px 0 0;">
<table role="presentation" class="btn" cellpadding="0" cellspacing="0" border="0" style="border-collapse: separate;">
<tr>
<td align="center" bgcolor="${ACCENT}" style="background-color: ${ACCENT}; border-radius: 10px; padding: 14px 26px;">
<a href="{{{link}}}" style="display: inline-block; font-family: ${FONT}; font-size: 16px; line-height: 20px; font-weight: 600; color: #ffffff; text-decoration: none;">${label}</a>
</td>
</tr>
</table>
</td>
</tr>`;
export const EMAIL_TEMPLATES = {
'approved-for-listing': {
subject: '🎉 Your app has been approved for listing!',
@@ -157,6 +344,64 @@ [email protected] immediately.
<p>A password recovery request was issued for your account, please follow the link below to reset your password:</p>
<p><a href="{{link}}">{{link}}</a></p>
<p>Sincerely,</p>
<p>Puter</p>
`,
},
team_account_created: {
subject: 'Your {{team_name}} account on Puter',
html: `
<p>Hi there,</p>
<p>{{team_name}} has created a Puter account for you: <b>{{username}}</b>.
They will send you a temporary password separately; you will be asked to
choose your own the first time you sign in.</p>
<p>What this means:</p>
<ul>
<li>This account belongs to {{team_name}}. They pay for it and can close it.</li>
<li>{{team_name}} <b>cannot</b> see your files, apps or data.</li>
<li>{{team_name}} <b>can</b> reset your password, which would let them sign in
as you. Choose your own password promptly.</li>
</ul>
<p>Sincerely,</p>
<p>Puter</p>
`,
},
team_account_disabled: {
subject: 'Your {{team_name}} account has been disabled',
html: `
<p>Hi there,</p>
<p>{{team_name}} has disabled your Puter account <b>{{username}}</b>. You can no
longer sign in to it.</p>
<p>Nothing has been deleted. Your files, apps and data are as you left them, and
{{team_name}} can re-enable the account at any time. If you believe this is a
mistake, ask them.</p>
<p>Sincerely,</p>
<p>Puter</p>
`,
},
team_closed: {
subject: '{{team_name}} has been closed',
html: `
<p>Hi there,</p>
<p>{{team_name}} has closed its Puter team, and your account
<b>{{username}}</b> has been disabled along with it. You can no longer sign in to
it.</p>
<p>Nothing has been deleted. Your files and data are still there; closing the
team does not destroy the accounts it created, and each one would have to be
deleted on its own request.</p>
<p>Sincerely,</p>
<p>Puter</p>
`,
},
team_password_reset: {
subject: 'Your {{team_name}} account password was reset',
html: `
<p>Hi there,</p>
<p>An administrator of {{team_name}} reset the password on your Puter account
<b>{{username}}</b>. They will send you a temporary password separately, and you
will be asked to choose your own the next time you sign in.</p>
<p>If you did not expect this, the record of who reset it and when is in your
team activity, along with every sign-in to your account.</p>
<p>Sincerely,</p>
<p>Puter</p>
`,
},
@@ -183,32 +428,88 @@ immediately</p>
<p>Puter</p>
`,
},
share_by_username: {
subject: 'Puter share from {{susername}}',
html: `
<p>Hi there {{rusername}},</p>
<p>You've received a share from {{susername}} on Puter.</p>
<p>Go to puter.com to check it out.</p>
{{#if message}}
<p>The following message was included:</p>
<blockquote>{{message}}</blockquote>
{{/if}}
<p>Sincerely,</p>
<p>Puter</p>
/**
* A digest: shares to one recipient are held briefly and merged, so
* `shares` may carry several senders. The subject is composed by the
* service (see `digestSubject`), which owns the grouped wording. `link`
* opens Shared with every item highlighted; `origin` is the bare site, for
* the unsubscribe link.
*/
file_shared_with_you: {
subject: '{{subject_line}}',
html: shareEmailLayout({
preheader: 'Waiting for you under Shared with me.',
content:
greetingRow('Hi{{#if recipient}} {{recipient}}{{/if}},') +
headingRow('Shared with you') +
SHARE_LIST_ROW +
buttonRow('Open Puter') +
textRow(
'Shared items live under <span style="font-weight: 600;">Shared</span> in your files.',
'24px 0 0',
),
footer: `You're receiving this because someone shared with your Puter account.{{#if unsubscribe_uuid}}
<br /><a href="{{origin}}/unsubscribe?user_uuid={{unsubscribe_uuid}}" style="color: inherit; text-decoration: underline;">Unsubscribe from notification emails</a>{{/if}}`,
}),
text: `
Hi{{#if recipient}} {{recipient}}{{/if}},
Shared with you on Puter:
{{#each shares}}
- {{this.sender}} shared {{this.what}}
{{#each this.items}}{{#if this.link}} {{this.name}}: {{this.link}}
{{/if}}{{/each}}{{/each}}
Open Puter: {{link}}
Shared items live under "Shared" in your files.
--
You're receiving this because someone shared with your Puter account.
{{#if unsubscribe_uuid}}Unsubscribe from notification emails:
{{origin}}/unsubscribe?user_uuid={{unsubscribe_uuid}}{{/if}}
`,
},
share_by_email: {
subject: 'share by email',
html: `
<p>Hi there,</p>
<p>You've received a share from {{sender_name}} on Puter:</p>
<p><a href="{{link}}">{{link}}</a></p>
{{#if message}}
<p>The following message was included:</p>
<blockquote>{{message}}</blockquote>
{{/if}}
<p>Sincerely,</p>
<p>Puter</p>
// The only way to reach someone with no account. Same digest shape.
file_shared_invite: {
subject: '{{subject_line}}',
html: shareEmailLayout({
preheader: 'Claim it with a free Puter account.',
content:
greetingRow('Hi there,') +
headingRow('Shared with you on Puter') +
SHARE_LIST_ROW +
textRow(
'There\'s no Puter account for <span style="font-weight: 600;">{{email}}</span> yet. Create one with this address, confirm it, and everything above will be waiting for you. It\'s free and takes about a minute.',
) +
buttonRow('Create your free account') +
textRow(
'Already on Puter? Add {{email}} to your account and confirm it to get the same access.',
'24px 0 0',
),
footer: `You're receiving this because someone shared with {{email}}. Nothing is shared until the address is confirmed, so you can ignore this email and nothing happens.`,
}),
text: `
Hi there,
Shared with you on Puter:
{{#each shares}}
- {{this.sender}} shared {{this.what}}
{{/each}}
There's no Puter account for {{email}} yet. Create one with this
address, confirm it, and everything above will be waiting for you.
It's free and takes about a minute.
Create your free account: {{link}}
Already on Puter? Add {{email}} to your account and confirm it to get
the same access.
--
You're receiving this because someone shared with {{email}}. Nothing is
shared until the address is confirmed, so you can ignore this email
and nothing happens.
`,
},
} satisfies Record<string, EmailTemplate>;
+258 -5
View File
@@ -32,6 +32,54 @@ type GuiEvent<R = Record<string, unknown>> = {
response: R;
};
// The entry arrives under several aliases, for handlers of any vintage.
type FsCreateEvent = { node: FSEntry; entry: FSEntry; uid: string };
/**
* Who pays, and for which team. Deliberately no payment identity: the payer's
* row is alive at emit time, so a consumer resolves it when it acts -- a
* snapshot taken here would be stale, and two events racing on it would each
* create their own customer.
*/
export type TeamBillingContext = {
team_uid: string;
owner_user_id: number;
};
/** A team event about one seat. */
export type TeamBillingEvent = TeamBillingContext & {
user_id: number;
user_uuid: string;
username: string;
};
/**
* Extension-augmentable half of {@link EventMap}. Extensions that emit their own
* events declare the payload here by declaration merging, so both the emitter
* and every listener are typed against the same shape:
*
* declare module '@heyputer/backend/clients/event/types' {
* interface IExtensionEventMap {
* 'my.thing.happened': { thingId: string };
* }
* }
*
* Deliberately member-less and index-signature-free: an index signature here
* would widen `keyof EventMap` to `string` and silently disable key checking on
* every `emit` in the tree.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface IExtensionEventMap {}
/**
* What a KV mutation did to a key. `expire` is a lifetime change rather than a
* value change, which is why it is not folded into `set`.
*/
export type KvOp = 'set' | 'del' | 'expire';
/** A flush empties a whole namespace, so it is never reported per key. */
export type KvMutation = KvOp | 'flush';
export type EventMap = {
// ---- Server lifecycle ----
serverStart: Record<string, never>;
@@ -89,6 +137,8 @@ export type EventMap = {
};
// ---- Apps ----
/** Awaited before lookup; listeners may mutate or replace appNames. */
'app.recommended': { appNames: string[] };
'app.changed': {
app_uid: string;
action: string;
@@ -142,6 +192,19 @@ export type EventMap = {
'puter.signup.validate': {
allow: boolean;
email?: string;
/**
* The address in the same canonical form the `email.validate` hook was
* given (`cleanEmail`), so a handler can correlate the two hooks on one
* key. Aliases (`[email protected]`, `[email protected]`) differ from
* `email` here.
*/
clean_email?: string;
/**
* True for a temp (frictionless, no-password) signup. Those carry a
* synthetic `<username>@gmail.com` and never reach email validation, so
* a handler must not draw conclusions from `email`.
*/
is_temp?: boolean;
ip?: string | null;
source?: 'oidc';
req?: unknown;
@@ -246,6 +309,14 @@ export type EventMap = {
publishable_key: string | null;
[key: string]: unknown;
};
// Side-effect-free "is the card gate usable?" probe. The extension stamps
// `enabled` from its own config and does nothing else — no provider call,
// no user state, nothing billed. It exists so the SMS-to-card fallback can
// default on only where a card gate actually works, which means it gets
// asked often and must stay cheap.
'puter.card-verification.status': {
enabled: boolean | null;
};
'puter.card-verification.confirm': {
user_id: number;
user_uid: string;
@@ -283,6 +354,23 @@ export type EventMap = {
stripe_customer_id?: string | null;
};
// ---- Team billing ---- the trigger, never the charge ----
'team.account.created': TeamBillingEvent;
/** `held_bytes` is what to bill storage on while the seat is off. */
'team.account.disabled': TeamBillingEvent & { held_bytes: number };
'team.account.enabled': TeamBillingEvent & { held_bytes: number };
/** Emitted pre-delete: the membership row cascades away with the user. */
'team.account.deleted': TeamBillingEvent;
/** Per-seat charges stop; byte charges do not, the accounts remain. */
'team.deleted': TeamBillingContext & { account_count: number };
/** A budget line was crossed. Emitted on transition only, never per request. */
'metering.credit-state': {
user_uuid: string;
state: 'near-limit' | 'exhausted';
allowance_used: number;
month_usage_allowance: number;
};
// ---- Filesystem ----
'fs.copy.node': {
source: unknown;
@@ -290,9 +378,31 @@ export type EventMap = {
sourceObjectKey: string;
copyObjectKey: string;
};
'fs.move.node': { node: FSEntry; fromPath: string; toPath: string };
/**
* `fromUserId` is the owner the node had before the move. A move into
* someone else's tree re-owns the row, and `node` already carries the new
* owner — listeners that care about the hand-over need the old one.
*/
'fs.move.node': {
node: FSEntry;
fromPath: string;
toPath: string;
fromUserId?: number;
};
'fs.remove.node': { node: FSEntry; entry: FSEntry; target: FSEntry };
'fs.write.file': { node: FSEntry; entry: FSEntry; target: FSEntry };
/** A new entry, one key per flavor; `fs.write.file` is the overwrite. */
'fs.create.file': FsCreateEvent;
'fs.create.directory': FsCreateEvent;
'fs.create.shortcut': FsCreateEvent;
'fs.create.symlink': FsCreateEvent;
/** In-place name change. A move emits `fs.move.node` instead. */
'fs.rename': FsCreateEvent & {
old_name: string;
new_name: string;
old_path: string;
new_path: string;
};
'fs.storage.upload-progress': {
upload_tracker: unknown;
context: unknown;
@@ -306,6 +416,57 @@ export type EventMap = {
};
'storage.quota.bonus': { userId: number; extra: number };
// ---- Key-value ----
/**
* Keys of one namespace that changed together, announced after the write
* commits. `namespace` is `v1:<userUuid>:<appUid>` and `userId` is the same
* user by its row id — the only part a listener cannot derive from the
* namespace, and the one dispatch keys on.
*
* Distinct from `outer.kv.cacheInvalidated`, which carries encoded cache
* keys and is suppressed entirely when the read cache is off.
*/
'kv.mutated': {
namespace: string;
userId: number;
keys: string[];
op: KvOp;
};
/**
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
* key enumeration is truncated for a large namespace, so the keys it saw
* are not the keys it removed.
*/
'kv.flushed': { namespace: string; userId: number };
// ---- Notifications ----
/**
* A notification row exists. Emitted after the insert commits — nothing
* pushes a uid before there is a row to name — and carries the scope tuple
* so a listener never has to read the row back.
*/
'notif.created': {
userId: number;
userUuid: string;
uid: string;
type: string;
audience: string;
appUid: string | null;
value: Record<string, unknown>;
createdAt: number;
};
/**
* One notification, addressed at whatever sockets each region holds for the
* recipient. `outer.*` reaches peer regions and never sibling nodes, so
* every region applies it exactly once — which is what keeps a socket from
* receiving the same notification twice.
*/
'outer.notif.delivery': {
userId: number;
wire: 'notif.message' | 'notif.unreads' | 'notif.ack';
response: unknown;
};
// ---- Metering ----
// Recurring charges are pure mechanism here: the metering service knows
// when to ask (once per user per month, the first time that month's usage
@@ -338,12 +499,78 @@ export type EventMap = {
// is pricing workers that came into existence, not deploys.
'worker.create': { actor: Actor; workerName: string };
// The same announcement for worker code that lives inside a hosted site
// and is deployed on demand rather than by an explicit call. Such a worker
// has no row of ours to key on, so what stands in for "genuinely new" is
// the absence of any prior record that it ran — which is why it is
// announced separately, and identified by where its source lives rather
// than by a worker name.
'worker.dynamic.create': {
actor: Actor;
subdomain: string;
worker: string;
};
// An app's events worker coming into being (its handler count going 0→1)
// and going away (1→0, whether by removing the last handler or by the
// destroy route). `actor.user` is the app's owner, not the caller — a
// developer session publishing for an app it owns is the common case, but
// billing follows ownership.
'events.worker.create': { actor: Actor; appUid: string };
'events.worker.destroy': { actor: Actor; appUid: string };
// ---- Outer / GUI broadcast ----
'outer.cacheUpdate': {
cacheKey: string[];
data?: unknown;
ttlSeconds?: number;
};
/**
* Permission cache generations were bumped, so peer regions must bump their
* own — the counter is per-cluster, so a local bump says nothing to them.
* Carries the actors, not the values: the numbers only have to change.
*/
'outer.permission.generationBumped': { actorUids: string[] };
/**
* Flat permission entries were deleted. Grant-path flat entries carry no
* expiry, so without this a revoke never lands in a peer region whose KV
* table isn't replicated. Revoke-only: a grant that fails to replicate just
* denies there, which is the safe direction.
*/
'outer.permission.flatInvalidated': {
entries: Array<{ holderUserId: number; permission: 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
* bump produced so a listener can order two bumps it sees out of order.
*
* The dispatch hot path never reads the counter — it is the broadcast that
* invalidates, which is what keeps an unsubscribed write at zero Redis
* commands. Hence `outer.pubsub.*`: the cache is a per-process map, the
* webhook reaches one node per peer region, and only the Redis fan reaches
* that node's siblings — a node the bump never reached has nothing that
* expires.
*/
'outer.pubsub.events.generationBumped': {
userId: number;
generation: number;
/** Whether the table changed; only then does a peer need to re-read it. */
durable: boolean;
};
/**
* A user's sockets moved between regions, so every node must drop what it
* cached about where they are. Bumped on a first connect, a last
* disconnect, and every repair — never on a timer, which is what keeps
* presence reads proportional to session churn rather than to event volume.
* `outer.pubsub.*` for the same reason as the generation bump: a
* per-process cache that only the Redis fan reaches on a region's other
* nodes.
*/
'outer.pubsub.events.presenceBumped': {
userId: number;
generation: number;
};
'outer.fs.write-hash': { hash: string; uuid: string };
/**
* Cache keys the KV read cache must stop serving, because the entries
@@ -359,16 +586,18 @@ export type EventMap = {
'outer.gui.item.moved': GuiEvent;
'outer.gui.item.pending': GuiEvent;
'outer.gui.item.removed': GuiEvent;
'outer.gui.item.renamed': GuiEvent;
'outer.gui.notif.ack': GuiEvent<{ uid: string }>;
'outer.gui.notif.persisted': GuiEvent<{ uid: string }>;
'outer.gui.notif.message': GuiEvent<{ uid: string; notification: unknown }>;
'outer.gui.notif.unreads': GuiEvent<{
unreads: { uid: string; notification: unknown }[];
}>;
// ---- Subdomains ----
'subdomain.delete': { subdomain: string };
'subdomain.update': { subdomain: string };
// `uid` is the row uuid — for `delete` it is the only surviving handle a
// listener can use to find state keyed to the row after it is gone.
'subdomain.delete': { subdomain: string; uid?: string };
'subdomain.update': { subdomain: string; uid?: string };
'site.htmlServed': {
subdomain: string;
entry: unknown;
@@ -400,6 +629,30 @@ export type EventMap = {
'web.socket.connected': { socket: unknown; user: unknown };
'web.socket.user-connected': { socket: unknown; user: unknown };
/**
* Sessions were revoked for a user. Anything holding one of them open — a
* live socket, say — should drop it: `revoked_at` is otherwise only read on
* the next handshake.
*/
'auth.sessions.revoked': { user_id: number; session_uids: string[] };
/**
* A grant was withdrawn, so whatever was standing on it has to be settled
* rather than left to fail its next check.
*
* Local rather than `outer.*` on purpose: a listener settles shared state,
* and one region doing it covers every other. Emitted once per revoke call,
* not once per row it affects.
*/
'permission.revoked': {
/** Whose access went. */
holderUserId: number;
/** The app the grant was to, or `null` for a user-to-user grant. */
appUid: string | null;
/** The grant string, or `null` when every grant to the app went. */
permission: string | null;
};
// ---- Extension hooks / misc ----
'puter.gui.addons': {
prependHeadContent?: string[];
@@ -460,7 +713,7 @@ export type EventMap = {
* have all dropped that answer.
*/
'outer.pubsub.metering.credits-changed': { userUuid: string };
};
} & IExtensionEventMap;
/**
* Phase of a request/method lifecycle. `reject` is emitted when a `before`
@@ -0,0 +1,410 @@
/*
* 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/>.
*/
/**
* `DispatcherInvokeTransport` against an injected fetch, so the handled/error
* header contract is pinned without a real dispatcher on the other end.
*/
import type { fetch as undiciFetch } from 'undici';
import { describe, expect, it } from 'vitest';
import type { IConfig } from '../../types.js';
import {
DispatcherInvokeTransport,
EVENTS_DEPLOYED_HEADER,
EVENTS_DISPATCH_ERROR_HEADER,
EVENTS_DISPATCH_PATH,
EVENTS_ERROR_HEADER,
EVENTS_HANDLED_HEADER,
EventsWorkerInvokerClient,
type WorkerInvokeRequest,
} from './EventsWorkerInvokerClient.js';
type FetchImpl = typeof undiciFetch;
const CALL = {
script: 'evw-test',
appUid: 'app-test',
key: 'k1:test',
body: '{}',
timeoutMs: 5_000,
};
/** A stub fetch that answers the same way on every call. */
const stubFetch = (
status: number,
headers: Record<string, string> = {},
): FetchImpl =>
(async () =>
new Response(null, { status, headers })) as unknown as FetchImpl;
/** A stub fetch that never answers until the abort signal fires. */
const hangingFetch: FetchImpl = ((_url: string, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () =>
reject(new Error('This operation was aborted')),
);
})) as unknown as FetchImpl;
/** A stub fetch that records the URL it was called with. */
const capturingFetch = (
calls: string[],
status = 200,
): FetchImpl =>
(async (url: string) => {
calls.push(url);
return new Response(null, { status });
}) as unknown as FetchImpl;
describe('DispatcherInvokeTransport', () => {
it('reports a handled 400 with no error — the handler`s own refusal', async () => {
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: stubFetch(400, { [EVENTS_HANDLED_HEADER]: '1' }),
});
expect(await transport.send(CALL)).toEqual({
status: 400,
handled: true,
});
});
it('reports an unmarked 404 with no dispatch-error header as unhandled', async () => {
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: stubFetch(404),
});
expect(await transport.send(CALL)).toEqual({
status: 404,
handled: false,
});
});
it('turns a dispatch-error header into a null status with the reason', async () => {
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: stubFetch(502, { [EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-failed' }),
});
expect(await transport.send(CALL)).toEqual({
status: null,
error: 'dispatcher: deploy-failed (502)',
dispatchReason: 'deploy-failed',
});
});
it('adds the deployed header when the call says so', async () => {
const headers: Array<Record<string, string>> = [];
const fetchImpl = (async (_url: string, init?: RequestInit) => {
headers.push(init?.headers as Record<string, string>);
return new Response(null, { status: 200 });
}) as unknown as FetchImpl;
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl,
});
await transport.send(CALL);
await transport.send({ ...CALL, deployed: true });
expect(headers[0][EVENTS_DEPLOYED_HEADER]).toBeUndefined();
expect(headers[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
});
it('reports a handled 200 as settled with no error', async () => {
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: stubFetch(200, { [EVENTS_HANDLED_HEADER]: '1' }),
});
expect(await transport.send(CALL)).toEqual({
status: 200,
handled: true,
});
});
it('passes a 429 through as-is', async () => {
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: stubFetch(429),
});
expect(await transport.send(CALL)).toEqual({
status: 429,
handled: false,
});
});
it('answers null with an error when the request times out', async () => {
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: hangingFetch,
});
const result = await transport.send({ ...CALL, timeoutMs: 10 });
expect(result.status).toBeNull();
expect(result.error).toMatch(/abort/i);
});
it('surfaces the runtime`s own error header alongside its status', async () => {
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: stubFetch(500, {
[EVENTS_HANDLED_HEADER]: '1',
[EVENTS_ERROR_HEADER]: 'handler-threw',
}),
});
expect(await transport.send(CALL)).toEqual({
status: 500,
handled: true,
error: 'handler-threw',
});
});
it('preserves a path prefix on the dispatcher URL', async () => {
const calls: string[] = [];
const transport = new DispatcherInvokeTransport(
'http://dispatcher/prefix/',
's',
{ fetchImpl: capturingFetch(calls) },
);
await transport.send(CALL);
expect(calls).toEqual([`http://dispatcher/prefix${EVENTS_DISPATCH_PATH}`]);
});
});
const REQUEST: WorkerInvokeRequest = {
script: 'evw-test',
appUid: 'app-test',
handler: 'h',
token: 't',
key: 'k1:test',
event: {},
ctx: {},
};
const makeClient = () => new EventsWorkerInvokerClient({} as unknown as IConfig);
/** Answers `missing` until it sees the deployed header, then settles. */
const missUntilDeployedFetch = (
headersSeen: Array<Record<string, string>>,
): FetchImpl =>
(async (_url: string, init?: RequestInit) => {
const headers = (init?.headers ?? {}) as Record<string, string>;
headersSeen.push(headers);
if (headers[EVENTS_DEPLOYED_HEADER] === '1')
return new Response(null, {
status: 200,
headers: { [EVENTS_HANDLED_HEADER]: '1' },
});
return new Response(null, {
status: 404,
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'missing' },
});
}) as unknown as FetchImpl;
/** Answers `missing` no matter what, deployed header included. */
const alwaysMissingFetch = (calls: unknown[]): FetchImpl =>
(async () => {
calls.push(null);
return new Response(null, {
status: 404,
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'missing' },
});
}) as unknown as FetchImpl;
/**
* Answers `deploy-timeout` once, then 200 once it sees the deployed header —
* the shape of a script that finished propagating between the two calls.
*/
const deployTimeoutThenOkFetch = (
headersSeen: Array<Record<string, string>>,
): FetchImpl =>
(async (_url: string, init?: RequestInit) => {
const headers = (init?.headers ?? {}) as Record<string, string>;
headersSeen.push(headers);
if (headers[EVENTS_DEPLOYED_HEADER] === '1')
return new Response(null, {
status: 200,
headers: { [EVENTS_HANDLED_HEADER]: '1' },
});
return new Response(null, {
status: 503,
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-timeout' },
});
}) as unknown as FetchImpl;
describe('EventsWorkerInvokerClient deploy-on-miss', () => {
it('deploys the script and retries once with the deployed header', async () => {
const headersSeen: Array<Record<string, string>> = [];
const client = makeClient();
client.setTransport(
new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: missUntilDeployedFetch(headersSeen),
}),
);
const missCalls: Array<[string, string]> = [];
client.setMissHandler(async (appUid, script) => {
missCalls.push([appUid, script]);
return 'deployed';
});
const result = await client.invoke(REQUEST);
expect(missCalls).toEqual([[REQUEST.appUid, REQUEST.script]]);
expect(headersSeen).toHaveLength(2);
expect(headersSeen[0][EVENTS_DEPLOYED_HEADER]).toBeUndefined();
expect(headersSeen[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
expect(result).toEqual({ outcome: 'settled', status: 200 });
});
it('does not retry when the miss handler reports the script is stale', async () => {
const calls: unknown[] = [];
const client = makeClient();
client.setTransport(
new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: alwaysMissingFetch(calls),
}),
);
client.setMissHandler(async () => 'stale');
const result = await client.invoke(REQUEST);
expect(calls).toHaveLength(1);
expect(result).toEqual({
outcome: 'retriable',
status: null,
error: 'deploy: stale',
});
});
it('never asks the miss handler twice for one invocation', async () => {
const calls: unknown[] = [];
const client = makeClient();
client.setTransport(
new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: alwaysMissingFetch(calls),
}),
);
let handlerCalls = 0;
client.setMissHandler(async () => {
handlerCalls++;
return 'deployed';
});
const result = await client.invoke(REQUEST);
expect(handlerCalls).toBe(1);
// The first send plus the one retry — never a third.
expect(calls).toHaveLength(2);
expect(result.outcome).toBe('retriable');
expect(result.status).toBeNull();
});
it('leaves behavior unchanged with no miss handler set', async () => {
const calls: unknown[] = [];
const client = makeClient();
client.setTransport(
new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: alwaysMissingFetch(calls),
}),
);
const result = await client.invoke(REQUEST);
expect(calls).toHaveLength(1);
expect(result).toEqual({
outcome: 'retriable',
status: null,
error: 'dispatcher: missing (404)',
});
});
it('does not trigger the miss handler for a forbidden dispatch reason', async () => {
const client = makeClient();
client.setTransport(
new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: stubFetch(403, {
[EVENTS_DISPATCH_ERROR_HEADER]: 'forbidden',
}),
}),
);
let handlerCalled = false;
client.setMissHandler(async () => {
handlerCalled = true;
return 'deployed';
});
const result = await client.invoke(REQUEST);
expect(handlerCalled).toBe(false);
expect(result).toEqual({
outcome: 'retriable',
status: null,
error: 'dispatcher: forbidden (403)',
});
});
it('retries a deploy-timeout with the deployed header instead of redeploying', async () => {
const headersSeen: Array<Record<string, string>> = [];
const client = makeClient();
client.setTransport(
new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: deployTimeoutThenOkFetch(headersSeen),
}),
);
const deployCalls: unknown[] = [];
client.setMissHandler(async () => {
deployCalls.push(null);
return 'deployed';
});
const result = await client.invoke(REQUEST);
// The script was already deployed once — the one that led to this
// deploy-timeout answer — so this client must not add a second.
expect(deployCalls).toHaveLength(0);
expect(headersSeen).toHaveLength(2);
expect(headersSeen[0][EVENTS_DEPLOYED_HEADER]).toBeUndefined();
expect(headersSeen[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
expect(result).toEqual({ outcome: 'settled', status: 200 });
});
it('gives up after one retry when propagation is still not done', async () => {
const calls: unknown[] = [];
const client = makeClient();
client.setTransport(
new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: (async () => {
calls.push(null);
return new Response(null, {
status: 503,
headers: {
[EVENTS_DISPATCH_ERROR_HEADER]: 'deploy-timeout',
},
});
}) as unknown as FetchImpl,
}),
);
const deployCalls: unknown[] = [];
client.setMissHandler(async () => {
deployCalls.push(null);
return 'deployed';
});
const result = await client.invoke(REQUEST);
// The first send plus the one retry — never a third, and never a
// deploy. The delivery comes back on its own backoff instead.
expect(calls).toHaveLength(2);
expect(deployCalls).toHaveLength(0);
expect(result).toEqual({
outcome: 'retriable',
status: null,
error: 'dispatcher: deploy-timeout (503)',
});
});
});
@@ -0,0 +1,399 @@
/*
* 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/>.
*/
// Claims the global dispatcher slot for Node's own fetch before npm `undici`
// can — see the guard's own comment.
import '../../util/nodeFetchDispatcherGuard.js';
import { Agent, fetch as undiciFetch } from 'undici';
import { PuterClient } from '../types.js';
/**
* The call that leaves the platform and runs an app's own code.
*
* Events workers are unreachable from the internet: deployed into their own
* dispatch namespace and reached only through the events dispatcher, which
* answers on its own hostname behind the internal secret.
*
* POST <events dispatcher>/invoke
* x-puter-internal-auth: <internal secret>
* x-puter-events-script: <script the handler set deploys as>
* x-puter-events-app: <app uid, for the deploy-on-miss callback>
* x-puter-events-key: <key derived for that script>
* { handler, event, ctx, token }
*
* The delivery token rides in the body, not a header, so nothing between here
* and the isolate can treat it as its own authorization.
*
* On a miss, a handler set here may deploy the script itself and retry once
* with `x-puter-events-deployed: 1`, skipping the dispatcher's own callback.
*/
/** The one route an events worker answers. */
export const EVENTS_INVOKE_PATH = '/__events/invoke';
/** The route the events dispatcher takes an invocation on. */
export const EVENTS_DISPATCH_PATH = '/invoke';
/**
* Header the dispatcher marks its own failures with. Anything carrying it never
* reached a handler, so its status says nothing about the delivery.
*/
export const EVENTS_DISPATCH_ERROR_HEADER = 'x-puter-events-dispatch';
/**
* Header a script's own answer carries, whatever its status — set by the
* runtime itself and re-stamped by the dispatcher, so a 4xx with neither this
* nor the dispatch-error header came from neither (an edge 404, a WAF) and is
* retriable rather than a handler's refusal.
*/
export const EVENTS_HANDLED_HEADER = 'x-puter-events-handled';
/** Machine-readable reason on the runtime's own failure answers. */
export const EVENTS_ERROR_HEADER = 'x-puter-events-error';
/**
* Set on the retry after this backend deploys a missing script itself, so the
* dispatcher skips its own deploy-on-miss callback and negative cache and goes
* straight to retrying the namespace.
*/
export const EVENTS_DEPLOYED_HEADER = 'x-puter-events-deployed';
/**
* Dispatch-error reasons this backend can resolve itself by deploying, rather
* than wait on the rehydrate callback finding a backend behind the dispatcher's
* own hostname. `deploy-timeout` is not one of them: it is only ever answered
* after a deploy that succeeded, so the script exists and another upload of it
* cannot make the namespace see it any sooner.
*/
const SELF_DEPLOYABLE_MISS_REASONS = new Set([
'missing',
'deploy-failed',
'deploy-error',
]);
/**
* Dispatch-error reasons where the deploy already happened and only propagation
* is still catching up — one prompt retry with the deployed header is worth it,
* without spending another upload on it.
*/
const ALREADY_DEPLOYED_MISS_REASONS = new Set(['deploy-timeout']);
/** What a self-deploy attempt did, and to which app/script it applies. */
export type WorkerMissHandler = (
appUid: string,
script: string,
) => Promise<'deployed' | string>;
/** How long a handler has to answer before the attempt is abandoned. */
export const EVENTS_INVOKE_TIMEOUT_MS = 30_000;
/**
* Connections held open to the dispatcher, so a busy fleet is not
* re-handshaking. The 30s invoke abort starts when a request is queued, not
* when it gets a connection, so a saturated pool shows up as timeouts.
*/
const INVOKE_CONNECTIONS = 256;
const INVOKE_KEEP_ALIVE_MS = 30_000;
/** One invocation, as the events service hands it over. */
export interface WorkerInvokeRequest {
/** Script the app's current handler set deploys as. */
script: string;
appUid: string;
handler: string;
/** The subscriber's app session token. */
token: string;
/** Value the script compares against its own invoke-key binding. */
key: string;
event: unknown;
ctx: unknown;
}
/** One invocation, as a transport delivers it. */
export interface EventsInvokeCall {
script: string;
appUid: string;
/** Value the script compares against its own invoke-key binding. */
key: string;
/** Serialized `{ handler, event, ctx, token }`. */
body: string;
timeoutMs: number;
/** Set on the retry after this backend deployed the script itself. */
deployed?: boolean;
}
/**
* Where an invocation is delivered.
*
* `status` is what the handler answered with. A `null` status means nothing
* ran: no transport, no script, or a dispatcher that refused us — all of which
* are the platform's fault and so retriable. `handled` is set only when the
* answer is provably the script's own (the handled header); a 4xx without it
* came from something between here and the script, not a handler's refusal.
*/
export interface EventsInvokeTransport {
send(call: EventsInvokeCall): Promise<{
status: number | null;
handled?: boolean;
error?: string;
/** Set only by the dispatcher transport, naming its own failure. */
dispatchReason?: string;
}>;
}
/**
* What the call did.
*
* - `settled` — the handler took the delivery (2xx).
* - `terminal` — it refused it (a marked 4xx). Retrying sends the same body to
* the same code, so it is not retried.
* - `retriable` — it could not answer: 5xx, 429, a timeout, a transport failure,
* an unmarked 4xx (nothing said it was a handler's answer), or no way to
* reach the worker at all.
*/
export type WorkerInvokeOutcome = 'settled' | 'terminal' | 'retriable';
export interface WorkerInvokeResult {
outcome: WorkerInvokeOutcome;
/** The status the handler answered with, or `null` when it never did. */
status: number | null;
/** Why it could not answer, when nothing did. */
error?: string;
}
const NO_TRANSPORT =
'no events dispatcher configured (url and internal secret)';
export class EventsWorkerInvokerClient extends PuterClient {
/** Set only where invocations do not leave the process — local development. */
#transport: EventsInvokeTransport | null = null;
#dispatcher: DispatcherInvokeTransport | null = null;
/** Lets this backend resolve a dispatcher miss instead of waiting on it. */
#missHandler: WorkerMissHandler | null = null;
/** Replaces the dispatcher with an in-process runtime. */
setTransport(transport: EventsInvokeTransport): void {
this.#transport = transport;
}
/**
* Deploys a script the dispatcher reports missing, in place of the
* rehydrate callback — for the case where that callback lands on a backend
* other than this one. Set once; `invoke()` calls it at most once per
* delivery and never loops.
*/
setMissHandler(handler: WorkerMissHandler): void {
this.#missHandler = handler;
}
override onServerShutdown(): void {
this.#dispatcher?.close();
this.#dispatcher = null;
}
async invoke(request: WorkerInvokeRequest): Promise<WorkerInvokeResult> {
const transport = this.#pickTransport();
if (!transport)
return { outcome: 'retriable', status: null, error: NO_TRANSPORT };
const call: EventsInvokeCall = {
script: request.script,
appUid: request.appUid,
key: request.key,
body: JSON.stringify({
handler: request.handler,
event: request.event,
ctx: request.ctx,
token: request.token,
}),
timeoutMs: this.#timeoutMs(),
};
let result = await transport.send(call);
if (result.status === null && result.dispatchReason) {
if (
this.#missHandler &&
SELF_DEPLOYABLE_MISS_REASONS.has(result.dispatchReason)
) {
const outcome = await this.#missHandler(
request.appUid,
request.script,
);
if (outcome !== 'deployed')
return {
outcome: 'retriable',
status: null,
error: `deploy: ${outcome}`,
};
result = await transport.send({ ...call, deployed: true });
} else if (
ALREADY_DEPLOYED_MISS_REASONS.has(result.dispatchReason)
) {
// No deploy to trigger — just give propagation the one retry
// it usually needs, without spending an upload on it.
result = await transport.send({ ...call, deployed: true });
}
}
const { status, handled, error } = result;
if (status === null)
return { outcome: 'retriable', status: null, error };
const outcome = outcomeForStatus(status, handled === true);
// An unmarked 4xx (429 aside — that one is always retriable) is not
// the handler's own answer; say so even when the transport did not,
// so the reason a delivery kept retrying shows up in the log rather
// than reading as an unexplained retry.
const unmarkedFourxx =
status !== 429 && status >= 400 && status < 500 && !handled;
const reportedError =
error ?? (unmarkedFourxx ? 'unmarked 4xx' : undefined);
return {
outcome,
status,
...(reportedError ? { error: reportedError } : {}),
};
}
#pickTransport(): EventsInvokeTransport | null {
if (this.#transport) return this.#transport;
const { dispatcherUrl, internalSecret } = this.config.events ?? {};
// Without the secret the dispatcher would refuse every call, which is
// a misconfiguration worth reporting once per attempt rather than a
// round trip.
if (!dispatcherUrl || !internalSecret) return null;
this.#dispatcher ??= new DispatcherInvokeTransport(
dispatcherUrl,
internalSecret,
);
return this.#dispatcher;
}
#timeoutMs(): number {
const configured = this.config.events?.invokeTimeoutMs;
return typeof configured === 'number' && configured > 0
? configured
: EVENTS_INVOKE_TIMEOUT_MS;
}
}
/** Reaches an events worker the only way production can: through the dispatcher. */
export class DispatcherInvokeTransport implements EventsInvokeTransport {
readonly #url: string;
readonly #secret: string;
readonly #fetchImpl: typeof undiciFetch;
#agent: Agent | null = null;
constructor(
dispatcherUrl: string,
secret: string,
options: { fetchImpl?: typeof undiciFetch } = {},
) {
this.#url = dispatcherUrl;
this.#secret = secret;
this.#fetchImpl = options.fetchImpl ?? undiciFetch;
}
close(): void {
void this.#agent?.close().catch(() => {});
this.#agent = null;
}
async send(call: EventsInvokeCall): Promise<{
status: number | null;
handled?: boolean;
error?: string;
dispatchReason?: string;
}> {
try {
// `new URL(path, base)` resolves an absolute path against the
// base's origin, dropping any path prefix `base` already carries.
const url = `${this.#url.replace(/\/$/, '')}${EVENTS_DISPATCH_PATH}`;
const response = await this.#fetchImpl(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-puter-internal-auth': this.#secret,
'x-puter-events-script': call.script,
'x-puter-events-app': call.appUid,
'x-puter-events-key': call.key,
...(call.deployed ? { [EVENTS_DEPLOYED_HEADER]: '1' } : {}),
},
body: call.body,
signal: AbortSignal.timeout(call.timeoutMs),
dispatcher: this.#dispatcherFor(),
});
// The body is not read: a handler answers with a status, and
// whatever else it writes is its own business.
void response.body?.cancel().catch(() => {});
// The dispatcher never reached a handler, so its status is not the
// delivery's answer.
const dispatchError = response.headers.get(
EVENTS_DISPATCH_ERROR_HEADER,
);
if (dispatchError !== null)
return {
status: null,
error: `dispatcher: ${dispatchError} (${response.status})`,
dispatchReason: dispatchError,
};
const handled = response.headers.get(EVENTS_HANDLED_HEADER) === '1';
const errorHeader = response.headers.get(EVENTS_ERROR_HEADER);
return {
status: response.status,
handled,
...(errorHeader ? { error: errorHeader } : {}),
};
} catch (err) {
return {
status: null,
error: err instanceof Error ? err.message : String(err),
};
}
}
/** One pool per process, built on first use and closed with the server. */
#dispatcherFor(): Agent {
this.#agent ??= new Agent({
connections: INVOKE_CONNECTIONS,
keepAliveTimeout: INVOKE_KEEP_ALIVE_MS,
keepAliveMaxTimeout: INVOKE_KEEP_ALIVE_MS,
});
return this.#agent;
}
}
/**
* 2xx took it regardless. 429 is always "not now". Any other 4xx is a refusal
* only when `handled` says the answer is provably the script's own — an
* unmarked 4xx came from something between here and the handler.
*/
export const outcomeForStatus = (
status: number,
handled: boolean,
): WorkerInvokeOutcome => {
if (status >= 200 && status < 300) return 'settled';
if (status === 429) return 'retriable';
if (status >= 400 && status < 500)
return handled ? 'terminal' : 'retriable';
return 'retriable';
};
+2
View File
@@ -21,6 +21,7 @@ import { AlarmClient } from './alarm/AlarmClient';
import { DatabaseClientFactory } from './database';
import { EmailClient } from './email/EmailClient';
import { EventClient } from './event/EventClient';
import { EventsWorkerInvokerClient } from './events/EventsWorkerInvokerClient';
import { DDBClient } from './dynamodb/DDBClient';
import { RedisClient } from './redis/RedisClient';
import { S3Client } from './s3/S3Client';
@@ -32,6 +33,7 @@ export const puterClients = {
db: DatabaseClientFactory,
email: EmailClient,
event: EventClient,
eventsWorkerInvoker: EventsWorkerInvokerClient,
dynamo: DDBClient,
redis: RedisClient,
s3: S3Client,
@@ -30,7 +30,7 @@ describe('PreludeClient', () => {
expect(makeClient().isConfigured()).toBe(false);
});
describe('isCountrySupported (€0.07 cap)', () => {
describe('isCountrySupported (€0.20 cap)', () => {
const client = makeClient('sk_test');
it('allows revenue markets up to the cap (incl. the priciest)', () => {
@@ -40,6 +40,17 @@ describe('PreludeClient', () => {
expect(client.isCountrySupported('us')).toBe(true); // case-insensitive
});
it('allows the markets the old €0.07 cap excluded', () => {
// Raising the cap to €0.20 brought ~89 countries into the phone
// gate. These pin the new range so a future change to the default
// has to say so out loud.
expect(client.isCountrySupported('UA')).toBe(true); // €0.0940
expect(client.isCountrySupported('PH')).toBe(true); // €0.1237
expect(client.isCountrySupported('EG')).toBe(true); // €0.1561
expect(client.isCountrySupported('NG')).toBe(true); // €0.1980
expect(client.isCountrySupported('WS')).toBe(true); // €0.2000, at the cap
});
it('rejects countries above the cap, with no SMS, or unknown', () => {
expect(client.isCountrySupported('PK')).toBe(false); // €0.3548
expect(client.isCountrySupported('ID')).toBe(false); // €0.2430
@@ -63,9 +74,7 @@ describe('PreludeClient', () => {
});
it('createVerification POSTs the phone target + ip signal with bearer auth', async () => {
fetchMock.mockResolvedValue(
okJson({ id: 'vrf_1', status: 'success' }),
);
fetchMock.mockResolvedValue(okJson({ id: 'vrf_1', status: 'success' }));
const client = makeClient('sk_test');
const res = await client.createVerification('+14155550123', {
@@ -81,7 +90,11 @@ describe('PreludeClient', () => {
target: { type: 'phone_number', value: '+14155550123' },
// Defaults to RCS (cheaper); Prelude falls back to SMS. locale is
// hardcoded to en-US so the message text is always English.
options: { code_size: 6, preferred_channel: 'rcs', locale: 'en-US' },
options: {
code_size: 6,
preferred_channel: 'rcs',
locale: 'en-US',
},
signals: { ip: '203.0.113.7' },
});
});
@@ -196,9 +209,9 @@ describe('PreludeClient', () => {
it('throws (does not call fetch) when not configured', async () => {
const client = makeClient();
await expect(
client.createVerification('+14155550123'),
).rejects.toThrow(/not configured/i);
await expect(client.createVerification('+14155550123')).rejects.toThrow(
/not configured/i,
);
expect(fetchMock).not.toHaveBeenCalled();
});
Loaded 100 of 1020 files, more files were not shown because too many files have changed in this diff. Show more