Review call from the ticket's author: a team share is the team's, not
one colleague's to withhold from another, so a personal block should not
hide it. This drops the enforcement added earlier on the branch — the
listing, its total and the fs-event fan-out no longer filter team rows
by the recipient's block list, and the SQL fragment that did it goes
with them.
What a block still does for a team share is suppress the notification,
which was already true before this branch: it stops the interruption
without pretending to stop the access. Leaving the team is what ends
that. Said so in the block API docs, the settings copy and the code,
since the mismatch between the two was the original complaint.
The unshare sweep is untouched — that half of the ticket stands.
Emits ai.cost.multiplier.<driver>.<provider>:<model> before recording AI
usage, so what a model costs to charge is policy an extension owns rather
than a number in core. Nothing listening records the provider cost.
MeteringService.withAiCostMultiplier(driver) returns a view of the service
whose recording paths scale costOverride by the hook's answer; every AI
driver hands that view to its providers, so all of them are covered without
touching provider code.
* fix: list a link share only while the owner's plan covers it
* fix: fs limits for signed urls
* feat: a paid plan counts as a verified card for the sharing gate
* feat(email): inline cid attachments and Puter mailbox delivery for sendTransactional
EmailAttachment gains cid/contentDisposition so the transactional driver can send inline images. The SDK's EmailAttachment typedef now comes from types.js, which already carried cid. Docs describe delivery to <username>@puter.email recipients and the not_found code.
* feat: share with anyone with the link (PUT-1580); gate sharing on a verified phone or card
Review follow-ups on #3872: deleteActiveGroup ran even when every
canManagePermission check was denied, deleting the index row while the
team's grant survived — unlisted live access, the class this PR exists
to remove. And memberIdsAmong's join left deleted_at unqualified, which
only works while jct_user_group has no such column; #live now takes an
alias.
feat: the team seat experience — no email required, forced password change, team label, and plan-based limits (PUT-1792)
Note: Bypassing the code owners rule, since there are couple approvals in place for this.
Code review on the previous commit confirmed three ways the block filter
inside readUserGroupPerms turned a reversible block into permanent loss:
canManagePermission reads the same rows, so a block-suspended grant
looked revoked to #revokeDownstream's cascade, to unshare's authority
gate, and to invite claiming — deleting re-shares and invites that were
supposed to come back on unblock. The scan is now deliberately blind to
blocks; a block suspends delivery only (listing, count, fan-out,
telling), which the share store filters itself through one shared
notBlockedSql fragment owned by UserBlockStore.
The team-unshare sweep also now covers what it claimed to: a member's
re-share *to another team* is revoked with them (group rows swept in
#revokeDownstream, user path included), and the sweep is skipped
entirely while another issuer's grant still backs the team — members'
re-shares rest on that authority and revoking them would orphan live
access. The blanket block-all note in settings now says team shares
still arrive, matching what the code deliberately does.
A sender a member blocked could still land items in that member's
shared-with-me — and grant them real access — by sharing with a common
team. The group grant is one row and cannot exclude a member, so the
block is now enforced where delivery is derived per member: the
permission scan, the inbound listing and its count, and the fs-event
fan-out all skip team rows whose issuer the member blocked. Nothing is
revoked, so unblocking restores everything. Blocking now also bumps the
blocker's permission cache so it bites immediately. Direct shares keep
their contract: existing ones stand.
#unshareTeam swept re-shares by listing members with a 200 cap and
never following the cursor, so members past the cap kept their orphaned
rows. It now walks the share rows in the subtree — the set that can
actually need sweeping — and filters those issuers to members, which
has no cap by construction.
Deleting a team suspends every provisioned seat, but every member route
resolved live teams only — so the suspended seats could never be
deleted afterwards, stranding the accounts and, on the billing side,
their paused subscriptions. deleteMember now resolves the team
soft-deleted or not, matching the audit reader.
It also gains the guard enableMember always had: only the team's own
suspension qualifies. A platform-suspended seat could previously be
cascade-deleted by the owner, destroying an account Puter had frozen.
- PUT-1800: gate `createWorkerSessionToken` on actor type, so an app or an
access token can no longer mint an app-less, root-shaped worker session;
`WorkerDriver` binds on `effectiveApp` instead of `app`.
- PUT-1799: add `isAccountContext` and read it where "no app" was being read
as "the account" — handler publish, events-worker listing, kv handle
mint/revoke/list. A scoped API token is no longer an account session.
- PUT-1802: re-authorize a durable row before its backlog drains, settling it
permanently when the grant is gone. Covers an ancestor-level unshare, which
the revoke settle deliberately leaves to the delivery re-check.
- PUT-1803: mask the owner's absolute path out of deliveries and subscription
anchors on a foreign node, the way every FS surface already does.
- PUT-1804: let a revoke reach rows already suspended for a resumable reason,
re-stamping them so a resume cannot hand over the held backlog.
- PUT-1805: apply the subscribe path's audience gate to `/events/fetch` before
the query, so a cursor can no longer count and name invisible notifications.
- PUT-1807: refuse `mode: 'manage'` from any actor holding an app — inside its
own AppData the ACL short-circuit would otherwise supply the reach.
- PUT-1808: take the sending peer from the verified signature header rather
than the request body.
- PUT-1810: re-base a kv share-handle row's stored match filter on the handle,
so the owner's absolute key prefix stays hidden.
- PUT-1814: escape LIKE wildcards and anchor the issuer-prefix queries on a
segment; anchor `manage:` stripping; reject a backslash in a share prefix;
assert a resolved actor in `subscribeDurable`.
- PUT-1815: bound the char/varchar columns behind `event_subscriptions` and
`kv_share_handles` at the store layer.
Per-tier quantities need to know *which* seats are active, not just how many:
the count has to be split by the tier each one is on. `countActiveSeats` answers
the old question and stays for the cap and the console copy.
Keyed by `uid` rather than the numeric id, because the billing side holds uids
and would otherwise have to look the team up twice. Same suspension filter, and
a test asserts the two agree so they cannot drift apart.
Falsified: dropping the suspension clause fails "leaves out a suspended seat,
matching countActiveSeats".
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.
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.
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 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.
`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.
`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.
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.
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.
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.
`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.
* 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
* 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
* 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.
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.
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.
* 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.
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.
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.
`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.
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.
\`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.
* 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.