From the adversarial review of this PR.
The forced password-change gate could deadlock: it POSTed to the
cookie-only route with a bare fetch, and both initgui call sites open it
before update_auth_data mints the session cookie — a fresh browser with a
token URL 401'd every submit inside a non-dismissible loop. It uses the
session-cookie retry wrapper now, taking the caller's token because
window.auth_token does not exist yet on that path. It also gains the
logout footer its sibling gates have; a lost temporary password was a
hard lock with devtools as the only exit.
Password recovery refused for seats: the address is admin-supplied and
never verified, so whoever holds that inbox could take the seat over at
any later time. A seat's recovery channel is its admin's reset.
change-email gets the same seat guard as change-username and deletion —
the address is where admin-issued credentials go.
The login response now carries `team` alongside requires_password_change:
no-reload logins store that payload as window.user verbatim, and every
seat restriction keys on it.
Smaller: the team-badge tooltip no longer double-encodes; the create-token
hint for an emailless account stops pointing at a verification it can
never perform; the quotas doc records the halved org_seat_free allowance;
the config template tells upgrading operators how to keep the old flat
cap; the SDK suite covers emailless provisioning and the owner-only uuid.
The Usage card's Upgrade link relabels to "Manage →" for anyone on a
plan, including a seat whose plan is its team's. Following it only
reaches a dialog saying so. Hidden for a seat, unchanged for everyone
else.
`i18n()` echoes a key it has no translation for, and a team tier has
none — so a seat's dashboard read "team-basic" where a personal plan
reads "Basic". The offering already travels with the subscription, so
its display name is right there; `i18n` stays the fallback for the
personal tiers that do have keys.
`config.default.json` shipped `max_seats_per_team: 50`, and that flat
override wins over the plan, so the free/paid caps were dead code on
every deployment that did not delete the key. A free owner got 50 seats.
The defaults now carry the two per-plan numbers and leave the flat
override unset; a deployment that wants one number for everyone still
sets it and still wins.
`#ownerPays` asked metering about an `{id, uuid}` stub, so a resolver
keyed on any other field missed and the answer came down to whether
something else had cached that user's plan first — the same team, at the
same seat count, was refused or allowed depending on nothing.
Running out of seats now says what raises the limit and offers the plan
picker, rather than reporting the number and stopping there. The record
table pages at ten rows instead of running off the bottom, and the team
card no longer labels the absence of a handle nothing here can set.
A provisioned account could rename itself, delete itself, and see a
Billing tab for a subscription it does not hold — all of it the team's,
not the account's. Each is now refused server-side and dropped from the
UI, keyed on one predicate: whoami reports a team only for an org-owned
seat, and the owner joined their own team.
The Teams tab showed a seat nothing but its own audit rows, so a member
could not see who else was on the team they were told they shared it
with. It now lists them; listMembers was already membership-gated and
already withholds from a member what is not theirs.
The dashboard share modal had no way to reach a team, though the desktop
dialog has had one since team sharing shipped and the SDK has always
taken `{ team }`. Same control, same copy, same helper. The access list
needed a team bucket to go with it: a team share names no holder, so the
aggregate dropped it and a team you had just shared with vanished.
The per-account subscription button did nothing. `listMembers` never
returned a member's uuid, the SDK's `toMember` dropped it, and the Plan
column read `seatTiers[undefined]`, so every seat rendered as Free and
the action dispatched with no seat.
The uuid is owner-only: it is what billing keys a seat's plan on, and one
member has no business identifying another.
The Teams tab was asking which tier with a UIAlert, which looked nothing like
the personal plan modal. It now dispatches the seat and lets the extension
render the grid, so the two match and OSS stays free of prices.
Carries `username` and `currentTier` so the picker can title itself and mark
the plan the account is already on.
It described a team-wide tier, which stopped existing when plans moved to the
account. What it still showed -- what each tier costs -- is in the Change plan
picker, next to the account it applies to, so nothing is lost by removing it.
`teamPlan.js` and its tests go with it, along with twelve i18n keys nothing
reads any more. `state.plan` stays: the Plan column and the picker both need
the catalogue and the seat assignments.
A row now carries up to five actions -- change plan, reissue, suspend or enable,
delete -- and five labelled buttons do not fit the cell. Each becomes a 30px
icon button.
The label is not dropped, only hidden: it stays as `title`, as `aria-label`, and
as visually-hidden text, so a screen reader and a hover both still get it. The
glyphs are `aria-hidden`, since the button already carries the name.
Inline SVG rather than files under `icons/`: these are one-place 24px line
glyphs, and the dashboard already inlines its sidebar chevron the same way. An
`edit` glyph is defined but unused, ready for the account-edit action.
Falsified: removing the visually-hidden label fails "keeps the label reachable
without showing it".
Reverses PUT-1788 D1 at the UI. A team keeps one subscription; each account sits
on its own tier within it.
The plan card stops being the chooser and becomes a summary: what each tier
costs per account, and how many accounts are on it. Choosing happens on the
account, with a Change plan action per row, because that is the thing the tier
now belongs to.
`memberPlanLabel` reads the seat's own assignment rather than the team's single
tier, so two accounts can honestly show different plans. The other three states
are unchanged and still matter: the owner is the payer, a suspended seat is not
billed whatever it was on, and a seat nobody bought a tier for is free.
The action only appears where a billing extension is present and a catalogue
came back, so a deployment that sells nothing shows prices and no dead buttons.
Per-seat billing context without per-seat subscriptions. PUT-1788 D1 stands: one
tier per team, one Stripe subscription, quantity = seat count. The tier is still
changed once, in the plan card.
Four states, because "on Team Basic" is not true of every row. The owner is the
payer and keeps their own personal plan, so they show as such rather than
inheriting the team's. A suspended seat reads "not billed" -- it stops costing a
per-account charge, which is the same rule the billing summary and the plan
card's seat count already use. A seat of a team that bought nothing reads free,
which is the reduced org-seat allowance. Everyone else shows the tier.
The rule is a helper in teamsConsole so it is testable, like the rest of that
file.
Falsified: dropping the payer branch fails "says the owner is the payer, not a
seat"; dropping the suspended branch fails "says a suspended seat is not
billed".
It filtered on `org_owned`, but the GUI annotates members to `orgOwned` --
so the count was always zero and the card read "billed for 0 accounts" beside
an accounts table saying two.
Uses `membersBillingSummary` now, the same count the table shows, which also
excludes suspended seats: they stop costing a per-account charge, which the
naive filter would have billed for.
`initgui` runs the verification gates in two places: the token-in-URL branch and
the session-restore/login branch. The password gate went into the first only, so
it never fired for the case it exists for -- a seat signing in through the login
form with the credential its administrator issued. The account reached the
desktop and then failed at every gated call with no prompt, which is the state
the gate was written to prevent.
Caught by manual testing, not by any test: both chains looked right in
isolation. Added an invariant test over initgui's source asserting each gate
appears on both paths and each loops until cleared, which is the shape of the
mistake rather than the instance of it.
Falsified: removing the login-path gate fails "runs the forced password change
on both paths" with `expected 1 to be 2`.
PUT-1796's UI half. The owner already manages accounts here, so the plan they
are billed for belongs on the same page rather than a tab away.
Pricing stays out of OSS. The card is drawn from whatever catalogue the server
returns, so this code knows no tier, no price and no payment provider -- a
deployment that sells nothing serves no catalogue and the card does not render.
`TabHome` already reads `/marketplace/subscriptions/current` the same way, so
OSS reading a prod-served endpoint is not a new idea here.
Two things are deliberately not offered rather than offered and broken. A tier
the server marks unavailable gets no button, because no Stripe price is
configured for it and buying would 422. And no button appears at all unless a
billing extension has set `window.team_billing_ui` -- the prices still render,
which is useful on its own, but nothing invites a click nobody can handle.
Checkout is Stripe's, so the button only dispatches `team-plan-purchase` with
the team and item id and lets the extension take it from there.
The markup is a helper rather than another branch in TabTeams, matching
teamBadge/credits/usageBudget, so it can be tested without the window stack.
Falsified: offering an unavailable tier fails "offers no button for a tier with
no configured price"; rendering buttons regardless of the extension fails
"offers no button without a billing extension to act on it".
342 GUI/SDK tests, 136 team backend tests, typecheck clean.
A provisioned account had no way to know it was one. That matters: the team can
reset its password and close it, which is exactly what the account-created email
already warns about, and nothing in the product repeated it afterwards.
`whoami` now carries `team: { uid, name }`. Two gates on it. Only user actors --
a seat's employer is no more an app's business than its phone number, which the
same handler already withholds. And only where `teams_enabled` is on, so a
deployment without teams is byte-identical.
It rides whoami rather than a route of its own because the sidebar needs it at
first paint. A `/teams/whoami` would add a request to every page load for every
user, and almost none of them are seats. The lookup costs nothing either way:
`getOrgSeat` is already cached, negative results included, precisely because
almost nothing is a seat. `team_name` comes off a join the query already made.
In the sidebar it sits under the Puter wordmark -- the conventional slot for
workspace context -- as a muted second line, hidden when the sidebar collapses.
Owners see nothing: they already know, and one may own several teams, so there
would be no single name to show.
The markup is a helper rather than another branch inside UIDashboard, matching
how appGroups/credits/usageBudget were pulled out, so it can be tested without
mocking the window stack.
Falsified: dropping the `isUser` gate fails "withholds it from an app actor" and
nothing else.
181 backend tests, 331 GUI/SDK tests, typecheck clean.
Follows the previous commit. Dropping the email field entirely went one step too
far: without an address the temporary password shown once in the panel is the
only copy, and an admin who closes that panel has to issue a new one. The field
is back, marked optional, and now it buys something concrete.
`team_account_created` carried no credential -- it said the team "will send you
a temporary password separately". It now carries the username and the temporary
password, so an admin who supplies an address hands nothing over by side channel.
`#notifyUser` takes extra template variables, and both credential-issuing paths
pass the one they just minted: provisioning and re-issue. Re-issue passing the
fresh credential rather than the stale one is the case worth checking, and there
is a test that asserts the old password is absent from that mail.
With no address nothing is sent, which was already true -- `#notifyUser` returns
early without one -- and is now covered.
The docs said the notice carries no credential in two places. Both corrected.
Falsified: dropping the credential from the re-issue call fails "emails the fresh
credential on re-issue, not the old one" and nothing else.
178 backend tests, 326 GUI/SDK tests, typecheck clean.
The backend already refused every route with `password_change_required` until a
provisioned account replaced the password its administrator chose, and
`/user-protected/change-password` was already exempt so the account could act.
The client half was missing entirely: nothing in the GUI referenced that code,
so a seat signed in and then failed at everything with no prompt and no way out.
Two gaps, both closed here.
The flag never reached the client. Neither the login response nor `/whoami`
carried `requires_password_change`, so the GUI could not have known even if it
wanted to. It ships from both now, alongside the other three verification flags
the whoami extension already describes as "the flags the GUI acts on".
There was no window to show. `UIWindowChangePassword` is a settings dialog --
closable, and it never resolves on success -- so it cannot act as a gate.
`UIWindowPasswordChangeRequired` mirrors the existing `*Required` windows: it
resolves true only once the change lands, and `initgui` loops on it. It runs
last in the boot chain, matching the server order in assertVerifiedAccount.
It refuses a new password equal to the current one. Without that the account
stays on the credential its administrator still holds, which is the entire thing
the gate exists to end.
Falsified twice: dropping the same-password guard fails "refuses to reuse the
password the admin handed over", and resolving on a rejected response fails
"stays open on a rejected change, so the gate cannot be escaped" -- each
breaking only its own test.
175 backend tests, 326 GUI/SDK tests, typecheck clean.
PUT-1792. Two separate problems, both from treating a provisioned account like
a self-registered one.
`email` was required, so an admin creating ten seats had to invent ten addresses
and then keep track of ten uniqueness constraints -- for accounts that sign in
by username and never use the address. It is now optional at every layer, and
the add-account form does not ask for it at all: username is the only thing a
seat needs.
`requires_email_confirmation` was set to true, with the reasoning that an
admin-supplied address is unverified. True, but `requireVerifiedAccount` turns
away on exactly `requires_email_confirmation && !email_confirmed`, so a
freshly created seat was asked to confirm an address it may not hold and could
not use the product until it did. The team creating the account is the trust
anchor, not the mailbox, so this is now false either way.
An address is still accepted and still stored when given, because the notices
are worth delivering. `#notifyUser` already returned early on a missing
address, so `team_account_created`, `team_account_disabled`,
`team_password_reset` and `team_closed` degrade quietly with no new branching --
the temporary password is in the API response, which is the documented delivery.
`idx_user_owned_email` is partial and skips password-null rows, so omitting the
address sidesteps it rather than creating a collision surface. Two seats with no
address do not conflict, and there is a test for it.
Docs now say an emailless seat is recoverable only through its team's owner.
That falls out of the design rather than being a limitation of this change, but
it should be written down rather than discovered.
Falsified: putting `requires_email_confirmation: true` back fails
"never demands confirmation, with or without an address" with
`expected true to be false`, and nothing else.
164 team tests, 40 SDK tests, typecheck clean.
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.
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.
`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) <noreply@anthropic.com>
* 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.
* 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.
* 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.
* 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
"'" / "&" 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.
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.
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.
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.
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.
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.
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.
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.
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.
`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.