mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-05 21:46:51 +00:00
eeedeb817fd14a3c7b13b2d5bfb7bd7fe647be3e
162
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9fb66137a3 | Merge juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes | ||
|
|
c8f7b1ceda | fix(auth): support recovery codes when disabling two-factor auth (#4374) | ||
|
|
47a6122c37 |
refactor(permission): keep the notifications:push constant with the permission code
The consent check that reads it lives with the delivery code, outside core. The grant flow test checks the grant through the permission service directly. |
||
|
|
c79b3edf4f | Merge juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes | ||
|
|
f850155411 |
KV share handles: hardening, higher limits, batch share, uninstall data deletion, account-deletion cleanup (#4344)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
* fix(kv): sign share handle ids and drop retired handles from every node's cache
New handle ids carry a short HMAC (kvh-<uuid>.<mac>); an id whose mac
does not verify is refused before any lookup. Unsigned ids keep working
but no longer fall back to the primary on a replica miss.
Retiring or changing a handle now broadcasts outer.pubsub.kv.share.forget
so every node drops its cached row. Event subscribes and delivery
rechecks refuse a handle whose owner is suspended, as the driver does.
* fix(kv): an app its user removed stops reaching shares
An app acting for a user exercises a share grant only while the user
still has the app authorized: at least one user-to-app permission row,
which every app token path leaves and revoke-user-app '*' removes. The
share itself stays; authorizing the app again restores access. The
answer rides the permission cache, keyed by the app-under-user
generation the revoke bumps, so live subscriptions go quiet too.
* feat(kv): list keys through a watch-level share handle
list with as: 'keys' through a handle needs only watch, which already
learns every key written in the region. Values and entries still need
read.
* feat(kv): filter share listings by app and by state
GET /kv/shares and GET /kv/shares/with-me take an optional appUid: an
app uid, or null for the app-less store. An account session may name
any app; an app is refused any namespace but its own. GET /kv/shares
also takes state=live|revoked|all, all by default.
* feat(puter.js): appUid and state options on the KV share listings
listShared() and listSharedByMe() pass appUid (null for the app-less
store), and listSharedByMe() passes state. Docs cover the new options
and that a watch share can list keys without their values.
* feat(kv): indexes and store counts for share-handle quotas and retention
Adds covering indexes for the owner's live count, the owner-holder pair,
app-filtered listings and retired rows, plus store methods to count a
pair's live handles and delete rows retired before a cutoff. The live
handle cache holds up to 50,000 entries; the miss cache stays at 10,000.
* feat(kv): higher share-handle caps, a per-holder cap, and sharing with several recipients
Live share handles per account go to 25,000 paid / 5,000 free, and per
app to 10,000 / 2,000. One owner may hold out 256 live handles to one
holder on every plan, which bounds the rows a revoke or unshare walks.
Re-sharing an existing region takes no slot and is never refused.
POST /kv/shares takes recipients (1 to 50) as an alternative to
recipient: one region, level and label for all. Caps and a new
per-recipient grant budget (300/min, 2,000/hour) are checked for the
whole batch up front; failures about one recipient are reported on its
item. The single-recipient request and response are unchanged and run
through the same code path.
* feat(kv): delete share handles 30 days after they stop
A daily job deletes retired share-handle rows older than 30 days, in
batches of 500. One node runs it per day; it stops on shutdown.
* feat(puter.js): share a KV prefix with several users in one call
puter.kv.share() takes an array of recipients, positionally or as
recipients in the options form, and resolves to one item per recipient:
the share, or an error with its message and code. The single-recipient
forms are unchanged.
* feat(kv): delete an app's data on uninstall, and close account-deletion gaps
Uninstall:
- POST /auth/revoke-user-app takes an optional `deleteData` boolean, only
with `permission: '*'` (400 otherwise). AuthService.uninstallApp revokes
every grant, deletes the app's worker sessions, then awaits
`app.uninstalled` { userId, userUuid, appUid, deleteData }.
- With deleteData, KvShareService retires the handles the user shares out
of that app's namespace (as the platform, in batches) and then empties
the namespace in the background, 5,000 items per step, stopping on
shutdown. Handles shared with the user are not touched.
- SystemKVStore.purgeNamespace(namespace, { maxItems }) deletes up to
maxItems items of one `v1:<uuid>:<appUid>` namespace, reading keys only,
with no mutation events or usage, and reports `done` once it is empty.
It refuses the system namespace and anything else. flush shares its
page-delete path.
- puter.perms.revokeApp(appUid, '*', { deleteData: true }); the desktop's
uninstall dialog has an unchecked "Also delete this app's data" option.
Account deletion:
- EventsService settles each of a deleted account's apps on `user.delete`
(they cascade without `app.changed`) and drops the owner's subscription
keys.
- The flat permission entries the account held are purged in pages.
Suspension:
- `user.suspended` / `user.unsuspended` events; the share-handle store
drops cached rows owned by that user here and on other nodes.
* fix(permission): await a peer's flat-permission deletes so emitAndWait sees them applied
* fix(kv): strongly consistent purge pages, and an uninstall purge stops once the app is authorized again
A stale empty page could end a purge as done while items remained. The
background purge after an uninstall now checks before each step that the
user hasn't authorized the app again, so data written after that is kept.
* fix(kv): flush keeps eventually consistent page reads; only a purge reads strongly consistent
* fix(user): emit user.suspended and user.unsuspended when the user store changes an account's suspension
|
||
|
|
c8998075ea |
fix(push): let users grant apps notifications:push
The grant route now only accepts permissions from a fixed set of app-grantable families, which refused notifications:push, so an app could never be given push consent. Register it next to events:background. |
||
|
|
589a98b12c |
feat(kv): share KV prefixes with other users at watch/read/write, and bound what apps can be granted (#4331)
* feat(kv): share key-value prefixes with other users, and bound what apps can be granted - KV share handles move out of Events into KvShareService, served at /kv/shares (the /events/kv-handles routes stay as an alias). - Shares have watch, read and write levels, encoded in the permission root (kv-share, kv-share-read, kv-share-write) with write implying read and watch. - Every puter.kv method takes optConfig.handle; keys are relative to the shared prefix, cursors are sealed, and private entries stay hidden. - New puter.kv.share, unshare, listShared and listSharedByMe; optional label; kv.share.received notice in the app's mailbox only; recipient blocks apply. - puter.kv.set gains onlyIfAbsent. - fs and kv events gain viaWorker, and kv events report self from the writer. - /auth/grant-user-app only accepts canonical permissions from a fixed set of families; the consent dialog, puter.perms and docs follow. - Migrations: kv_share_handles gains revoked_by, label, and grantee and app indexes (mysql_mig_49, postgres_mig_38, sqlite 0095). * fix(kv): never name a private key in a cross-app or handle cursor, and bound flat-permission scans - Listings of another namespace (cross-app or through a handle) resume after the last entry they returned, and their cursors are sealed, bound to the namespace or handle, and padded to one length. Old plain cross-app cursors are still read when they name the target namespace; other namespaces get 400. - PermissionStore scans flat permissions a few roots at a time and skips roots nested under another root in the same call. |
||
|
|
8b8dd19eae |
fix(teams): refuse to boot when teams_enabled has nothing registering Teams (#4300)
* fix(teams): refuse to boot when teams_enabled has nothing registering Teams Core's seat checks (team 2FA, seat password reset, email and username changes, self-delete, the signup placeholder claim) read the team store and skip when it is absent. With `teams_enabled` on and no extension registering the store and service, the server now fails at boot instead of serving without those checks. - whoami no longer string-matches the extension proxy's "missing property" error: with the flag on, the store is always there. - Tests that turned the flag on without Teams drop it; the puter.js harness sets it only under the `teams` capability. - Drop a stale services/index.ts comment. * refactor(gui): drop the Teams console styles, which ship with the console now |
||
|
|
1627650abc |
refactor: make teams an optional feature (#4248)
release-please / release-please (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
* refactor: let core run without a teams registration
Teams becomes optional: `services.team` and `stores.team` may be absent, and
core stops assuming either is there.
Seventeen call sites across share, signup, account deletion and the homepage
now degrade instead of throwing: a team recipient that cannot be resolved is
"no such recipient" rather than a fault, a group share folds in nothing, and
account deletion stops asking what a seat was charged for. This generalises
the pattern `gates.ts` and `SocketService` already used.
Teams' own files take a private handle to their store and service, so the
feature's code stays free of optionality that only core needs.
No behaviour changes where Teams is registered.
* refactor: put a contract between core and teams
`core/teams.ts` holds the shapes core reads and the calls it makes, and the
registries declare the pair as provided rather than built in. The service,
store and controller are registered by whatever runs the feature.
Typing them properly turned up eight AuthController call sites the previous
commit could not see: the controller reads its stores from the built-in
registry, so an absent `team` fell to an index signature and typed as
`unknown` instead of erroring. They now go through the same optional path as
the rest.
The feature's own tests move with it. The cases in core suites that build a
team to exercise a core rule still need relocating, so those suites are red
here.
* test: stub core's seat rules, rehome the rest with the feature
A core suite can no longer build a team, so the rules that refuse a seat get
`testFixtures/seatStub.ts` instead: it answers the contract, which is all
those rules read, and puts the registry back when the test ends.
Seventeen cases move that way. Four could not: group shares and a seat's 2FA
enrolment over HTTP need the real feature and the `/teams` route, so they go
with it, as does the whole of `GroupGrants.test.ts`.
Two whoami cases asserted a seat's team through `extension.import`, which
resolves the registry rather than the stores object. They now assert the
absent case, and the behaviour they covered is asserted against
`buildUserDetails`, which takes its stores as an argument.
Suite: 12252 passed, 38 skipped.
* refactor: take the teams console out of the built-in tabs
`TabTeams`, `teamsConsole` and `teamActionIcons` are no longer built in. The
dashboard already invites a tab through its `dashboard-will-open` event, so
whatever provides Teams contributes the console the same way.
Its 113 strings go with it and register into the shipped English dictionary
at load, so `i18n('teams_*')` resolves as it did. Only `en.js` ever carried
them, so no translation is lost.
Three modules stay, because things other than the console use them:
`teamBadge` (the dashboard shell), `orgSeat` (Home, Account, Security) and
`shareTeams` (the share picker). They already render nothing when the
account has no team. `teams_account_of` stays with `teamBadge` for the same
reason, and the `share_*` and `plan_team_*` strings stay with their callers.
The GUI builds clean with no console at all, and builds it in when the tab
is contributed.
* docs: seats past the owner's current limit get no allowance
|
||
|
|
3bce3b056e | misc: hardening (#4290) | ||
|
|
18b96d529c | fix: misc scan fixes (#4270) | ||
|
|
9ff224e22f |
perf(backend): cut sequential round trips on auth, fs, driver, event and site paths (#4259)
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Pipeline - CORS preflights send Access-Control-Max-Age: browsers stop re-preflighting authed calls every 5 s. - `req.ua` is parsed on first read instead of on every request. - JSON `rawBody` reuses the parsed buffer instead of copying it. - `emitAndWait` returns early when nothing listens: no span or closure per unlistened driver call. - SQLBatcher sends a lone query on the next tick while fewer than 8 flushes are in flight, and waits `maxTimeInQueue` as before otherwise. Lone reads skip the 10/30 ms wait; batching under load is unchanged. Auth and permissions - Token-to-actor auth starts the user, session and app lookups together and checks them in the same order: fewer sequential Redis round trips per authed request. - get-user-app-token and check-app reuse the resolved app row. getOrCreateApp skips the re-select after its own insert and doesn't wait on the cache write for replica rows. - getOrCreateApp reads the race winner from the primary when its insert loses, so it no longer hands out a session that was never written. - whoami runs the email check and the OIDC/taskbar/team lookups in parallel; key order unchanged. - OIDC providers are memoized once every discovery succeeds, with one discovery fetch in flight per URL, and the Apple key is parsed once. - gui-token and sync-cookie use `req.actor.user` instead of re-reading the user. - listSessions batches app lookups. App icon probes run together, and the original-icon probe skips the lineage fallback. - Default-granted permissions are answered from config before the scan cache, and exploded options are deduped: one less Redis read per /drivers/call. - ACL resolves ancestors lazily (owners and full-access tokens skip them) and scans the node first, then the ancestors concurrently on a miss. Filesystem - Missing-path lineage lookups run 10 at a time: batch-upload path resolution drops from about 1.3 s to about 40 ms per 100 new files. - MySQL single-file inserts return the row built from the written values, falling back to the primary read: one less primary round trip per new file. - Batch thumbnails are stored with one UPDATE per chunk (200 rows or 4M chars), with a per-row fallback. - storage.quota.bonus listeners run concurrently next to the usage SUM, and the payload carries `userUuid`: quota checks and /df no longer serialize the listener reads. - readdir cache backfill is one pipeline instead of 3xN SETEX. - Profile reads skip the lineage fallback; the own profile is read alongside the user lookup. - Legacy /stat runs is_empty (new `hasChildrenByParentUid`), owner and thumbnail concurrently. - Downloads and the video proxy pipe through `pipeToResponse`: aborts release the storage stream at once with no extra 'close' listeners, and the video proxy no longer crashes on a mid-stream upstream error. - mkdir looks up target and parent together. - Upload completion reuses the session from the access check (status is still re-read before completing), and multipart signing reads it once. - Moving a shared file to Trash skips two lookups that always returned the entry itself. Drivers and events - Subscription resolvers run concurrently and are folded in registration order. - SubdomainDriver and WorkerDriver overlap independent lookups. - The events "anyone subscribed?" check is one Redis pipeline when the region is warm. - Handler publish overlaps the count with the size check. - Broadcast flushes peers concurrently over a keep-alive agent, so a slow peer no longer delays cross-region invalidations to the others. Sites, share and AI - Hosted sites resolve the owner first, then the app and root together. Site config loads only on 404s. The hosted-app query selects only the columns it uses and moves into AppStore. - shareFlags asks about the plan only when an item is shared by link only. - Share listings run the count, teams and plan reads alongside their page query. - Claude attachment cleanup no longer blocks the response. - New util `awaitLater` for promises started early and awaited later. |
||
|
|
62270ff9aa | Delete worker sessions when workers are deleted, don't treat sessions from deleted workers as still valid. (#4163) | ||
|
|
b52fc01712 |
perf: cut godmode app launch latency (cached token renewal, launch reuse) (#4221)
* perf(auth): renew godmode app tokens from cache instead of the primary Every godmode launch read its token row from the primary and rewrote its expiry, a few cross-region round trips per launch far from the primary. - A cache pointer names the row per (user, web session, app). The row is read through the uuid cache token auth uses and re-checked (owner, session, app, revoked, expired, max age); anything else falls back to the primary read. Revoking the row drops the pointer. - The expiry write is skipped when it would move by under an hour. A renewal that hits a revoked or lapsed row mints a new one instead. - The response carries the row's actual expires_at. * perf(gui): reuse a just-minted godmode token for repeat launches A launch of a godmode app reuses the token this page minted for it within the last ten minutes under the same desktop session, instead of asking again. The cache is dropped when an app reports its token dead, after a session revoke from the session manager or an uninstall, and across tabs via a BroadcastChannel that carries token row ids, never tokens. A mint in flight during a drop is not remembered. |
||
|
|
b88aa55b24 |
fix: drop moved descendants from the entry cache; read cursor=null as the first page (#4204)
Renaming or moving a directory rewrote its descendants' paths but left their
cached copies (by id, uuid and old path) live for up to 60 s. A lookup of an
old path kept resolving, and a write beneath one landed at a path its parent
no longer had. updatePathPrefixForUser and renameUserHome now drop those keys,
here and, within the broadcast budget, in peer regions.
puter.js sent `cursor=null` for `listShared`/`listSharedByMe({ cursor: null })`,
which decoded to null and failed every keyset listing with a 500. decodeCursor
reads it as no cursor, and the SDK no longer sends it.
|
||
|
|
d162549c1d |
refactor(controllers): one signup, login, confirm and window.user path; userProtected/internalAuth route options (#4189)
* refactor(auth): one signup path for form, temp, OIDC and team accounts Signup steps (validate hook, phone/card forcing, bonus verdict, provisioning, signup events) move from AuthController and OIDCService into SignupService. Username rules, availability and generation move to util/username.ts, and user-row + group + default-folder creation to provisionUser, which TeamService and DefaultUserService now share. Behaviour fixes: a provisioned team seat joins default_user_group like every other account, and generated temp usernames skip names whose home path is occupied. * refactor(auth): one web-session start for password and OIDC sign-in startWebSession creates the session with the request's IP, user agent, origin and host and sets the session cookie. OIDC sign-ins used to create their session without that metadata, so they showed up blank in the session list. * refactor(auth): one confirmEmail for the code and link confirmation routes Both routes now re-run the email checks, refuse an address another account confirmed, strip it from other rows, promote the group and push user.email_confirmed to the account's sockets (with the originating socket id on the code route). The link route's full-table UPDATE clearing other accounts' pending change-email requests is dropped: /change_email/confirm already refuses an address a confirmed account owns. * refactor(auth): build window.user once for whoami and sign-in buildUserDetails and scrubSensitive move from the whoami extension into util/userDetails.ts. /whoami calls them; sign-in responses now return the same user-actor payload, scrubbed, so the login copy no longer ships the phone number and a no-reload login gets oidc_only/otp/directories up front. Taskbar resolution is best-effort on both. * refactor(http): userProtected route option replaces AuthController's route walker copy The user-protected gate is materialized from a userProtected RouteOption (implies requireUserActor), so AuthController no longer re-implements the decorator walker to wire it per instance; those routes and the session routes are plain decorators now. AuthRequired also counts requireVerified and userProtected. * refactor(http): internalAuth route option and a shared bodyRecord EventsController and PeerController each checked x-puter-internal-auth by hand with different compares; an internalAuth RouteOption now picks the secret from config and one constant-time gate checks it. ShareController and EventsController share bodyRecord instead of private copies. * fix(fs): cap legacy /delete at 500 paths per request The bulk form resolved, ACL-checked and removed an unbounded paths[] list one by one. It now refuses more than FS_BATCH_WRITE_MAX_ITEMS (500), the cap the batch write routes already use, before touching any entry. * refactor(controllers): user and subdomain writes and reads through their stores AuthController's 2FA setup/enable/disable and recovery-code writes go through UserStore.update instead of raw UPDATEs plus a manual invalidate, and /readdir-subdomains pages SubdomainStore.listByUserId instead of an unbounded SELECT; the response shape is unchanged. * refactor(auth): note SignupService's call-time peers * fix(auth): type userDetails' taskbar deps independently of TaskbarDeps.clients |
||
|
|
ecef154630 |
refactor(services): page fs subtree walks, gate share fan-out, one fs ACL helper (#4190)
* refactor(acl): read ancestor chains from FSEntryStore and share one fs ACL helper getAncestorChain (split a path, batch-read the entries) moves to FSEntryStore, with getAncestors for callers that want the rows. Services read it from the store instead of calling FSService sideways; FSService.getAncestorChain stays for drivers and extensions. ACLService gains fsDescriptor (memoized chain), assertFsAccess, assertFsCreate and refusal, plus the pure aclRefusal mapping. The eight hand-rolled descriptor and safe-error copies in FSService, ShareService, events/authorization, FSController, legacyFsHelpers, LegacyFSController, AuthController and WebDAVController use them. Each route keeps its status, legacyCode and message: fs routes answer access_denied on a 403, the legacy routes still hide refusals from apps as 404, share and events keep forbidden and their own 404 text. * perf(fs): page recursive deletes and batch their event dispatch A recursive delete read every descendant into memory, and an empty-check for a non-recursive one did the same. It now walks the subtree a page at a time in descending path order (children before the directory holding them, so the parent_id cascade never takes a row before it is handled), deleting objects and rows and publishing each page as it goes. A non-recursive delete probes with one LIMIT 1 query. Removals publish once per entry, after the row is gone (the same change as the open fix for the double emit), with one surviving-ancestor chain read at most once for the whole subtree. Events-API dispatch for a batch runs as one background task at bounded concurrency instead of a fire-and-forget promise per entry. FSEntryStore gains hasDescendants and listDescendantsInOrder (keyset paged by path, then id). * perf(fs): copy directories a level at a time A directory copy walked every descendant in memory and copied them one by one: an insert per directory and, per file, an S3 CopyObject, a batchCreateEntries of one and its read-back. It now pages the source in ascending path order (parents before children) and takes each page a depth at a time: rows for directories, links and empty files at bounded concurrency, then the depth's S3 copies at bounded concurrency and one batchCreateEntries for all of them. A single-file copy goes through the same path. A failed S3 copy still fails the call (a ghost file still answers 404), but only after the objects that did copy have their rows, so nothing is left in the bucket unreferenced. Copied entries publish per depth in one dispatch. * refactor(share): build masked share paths in one place sharePathMask exports maskedRoot(owner, uuid, name) and maskUnder(root, realPath). ShareService's maskedPathVia goes, its maskedSelfPath and shareDeepLink's maskedSharePath build on them, and the masker, maskUnderAnchor and learnShareRoots use maskedRoot. Output is byte-for-byte the same; tests pin it. * fix(share): send old_path on a recipient's item.moved The desktop's item.moved handler reads old_path; ShareService sent the former masked path only as from_path. Both are sent now. * perf(share): skip the audience lookup for owners with nothing shared Every fs write, create, rename, move and delete looked for share recipients: an ancestor walk plus two share queries, for an owner who in nearly every case has shared nothing. #sharesReaching now reads the chain through FSEntryStore.getAncestors (no third copy of the walk) and asks a per-owner "has anything shared" flag before the share queries. The flag is the per-user cache events already keeps for subscriptions (SubscriptionCache): one ShareStore.ownerHasShares read per owner per TTL, coalesced across a burst. Creating a share (user, team, claimed invite) or moving a subtree to a new owner bumps it locally and over outer.pubsub.share.ownerShared; the TTL covers a process that misses the bump. A failed lookup reads as shared, so the full lookup decides. * perf(share): batch the per-row reads behind share listings Share listings looked things up one row at a time: - teams by id: TeamStore.getByIdsIncludingDeleted (the single-id read calls it) for listSharedByMe, listSharesOf and #revokeGroupSharesBy - team shares per node and ancestor: ShareStore.listGroupOnFsentries, one query for the chain, in the same node-then-ancestor order - #liveGroupGrants: PermissionStore.queryIssuerGroupPermsByPrefixes reads every issuer/group pair under every prefix in one query - #revokeGroupSharesBy: the five canManagePermission checks per row run together instead of one after another - ShareNotificationService #unblocked: users via getByIds and blocks via UserBlockStore.blockersAmong, two queries instead of two per member - digest flush: entries are taken at bounded concurrency rather than one at a time * perf(events): ask background consent once per holder and app on a revoke settle Withdrawing events:background re-checked consent row by row, resolving the holder's actor and asking the permission service for every one of their background rows. Rows of one holder and app stand on one consent, so it is asked once and shared. * refactor(events): drop the registry's unused push fields notify, defaultDelivery, PushMessage, NotifyProjection and pushProjection were read only by registry.test. Every subject was unpushable and broadcast by default; the subscription's delivery class stays in DeliveryClass, which the stores use. * refactor(share): retire trashed shares through onEntryOwnerChanged onEntryTrashed was a copy of onEntryOwnerChanged: the same subtree share sweep, grant retire and row delete. * fix(fs): page subtree walks by depth, not path MySQL sorts a PAD SPACE string on its first max_sort_length (1024) bytes, so under utf8mb4_unicode_ci paths past about 512 characters tie and fall back to id. A file moved under a newer directory then sorted after it: the delete walk reached the directory first and the parent_id cascade took the file without its object delete or event, and the keyset skipped or repeated rows. Pages now order by segment count, then id, which no collation can reorder. * fix(share): cache an owner as shared when the bump arrives A bump only dropped the cached answer, so the next lookup could read a replica that hadn't applied the new share yet and cache "nothing shared" for another TTL. The bump now records "shared" outright. * fix(fs): probe a directory for children by parent_id, from the primary * fix(fs): keep the copied-size check in the batched directory copy A copied file records the size CopyObject actually duplicated; growth past the source row is charged against the allowance, and a copy that no longer fits is deleted and refused, as a single-file copy already does. |
||
|
|
0e0cedb02b |
fix(auth): no email password recovery for built-in admin accounts (#4153)
- /send-pass-recovery-email for `admin`/`system` (by username or email) returns the usual generic message, writes no token, sends no mail, and raises a critical alarm with the requester's IP, user agent and, when signed in, their user. - /verify-pass-recovery-token and /set-pass-using-token reject recovery tokens for those accounts as no longer valid. |
||
|
|
8a6929847d |
Auth limits, reborn-app tokens, seat addresses, drop-area encode (#4148)
* auth: per-account 2FA code limit, single-use otp-login tokens, recovery mail limits - /login/otp and /login/recovery-code share a per-account bucket charged on wrong codes; an otp-login token completes one sign-in. - /send-pass-recovery-email adds an IP bucket, a per-account cap and captcha, and reuses the outstanding recovery token instead of rotating it. - Reauth tokens carry the rejection reason; /signup re-attaches a temp account only from an expired session. * auth: app-issued access tokens older than their app stop authenticating An access token whose row or parent session predates the app now holding its uid is refused, the same check app and worker sessions already get. * teams: signup can take an address a seat never confirmed Password signup, save-account and a provider-verified OIDC sign-up take an address a team seat holds unconfirmed; the seat's copy is cleared at the write. A seat address that was confirmed still blocks. * dev-center: encode dropped directory child names |
||
|
|
c55437245b |
fix: settle the url file-access gate from a grant held on a folder above (#4132)
* fix: settle the url file-access gate from a grant held on a folder above The gate asks `/auth/check-permissions` with `app_uid`, which resolved through the permission scan. `fs:` access inherits down a tree and only ACL walks that, so a grant on a folder never answered for a file inside it and the gate asked again, writing another per-file row on each Allow. The `app_uid` branch now routes `fs:<uid>:<mode>` through `ACLService.check` as the app-under-user actor, which is the same authority the operation itself will face. Everything else keeps the scan, including `puter.perms.holds`, which sends no `app_uid`. * fix: the review's findings on the consent gate A permission that is not a string reached `startsWith` and faulted the whole request, where it used to answer false inside the catch; both parsers now refuse a non-string, which also covers the other callers that read a request's list. The path spelling of a permission took the scan while the uid spelling took ACL, so one file could answer two ways. Both spellings resolve to their entry and take the same route. The checks run together rather than one after another: the caller gives up after five seconds and prompts, and a deep path is several reads. |
||
|
|
d1d55a6bc3 |
SDK auth plumbing: cache scope, dedupe keys and the token listener (#4112)
* fix: pin the token sender, scope the grant flag, and stop two stragglers
Five of the SDK auth-plumbing items.
**The global `puter.token` handler pinned origin but not source.** Its three
siblings all pin both, and each says why: the GUI origin is shared by every
frame on that domain, so origin alone identifies a domain, not a sender.
`signIn` pins its popup and binds `msg_id`, the consent dialog pins its popup,
and the reauth wait pins the embedder. This one pinned the embedder only when
`env === 'app'`. Since `authenticateWithPuter` now hands off to `signIn`, which
adopts the token itself, the embedder is the only sender this handler is left
serving, so it pins in every environment.
**`is_grant_user_app_permission` was a request-wide flag held across an await.**
It tells the `app-root-dir` rewriter that a row is being written, and while it
is set that rewriter resolves to a real `fs:` permission instead of
`PERMISSION_FOR_NOTHING_IN_PARTICULAR`. Anything evaluating an `app-root-dir:`
permission beside the grant -- the grant route runs one under `Promise.all`
with fs work -- read the same flag. It now lives in a scope derived for that
call, so what the write needs to say about itself is not also said to its
neighbours. `runInDerivedContext` is new: `runWithContext` starts an empty
store, which would drop the `actor` the rewriter reads.
**`fs.stat` and `fs.readdir` deduped on parameters alone**, so two identities
in one realm could share a result. They now key on origin and token as
`os/user.js` does, which documents the reason.
**`signIn`'s relay poll never stopped.** Closing the popup settles the promise;
the loop kept asking `/login/wait` every second for the life of the page.
**`triggerReauth`'s five-minute timeout was never cleared**, so a successful
reauth left a timer to fire into a settled promise.
The concurrency test parks the rewrite and reads the flag while it is held --
my first attempt read before the flag was even set and passed either way.
* fix: scope the fs cache per identity, and retarget two paths that never ran
The remaining SDK auth-plumbing items.
**`puter._cache` outlived the identity that filled it.** `_clearAuthToken`
dropped the token and the stored session but left the cache and `whoami` as
they were, and its keys were raw paths -- `item:/alice/Documents` and a
`~`-relative key alike, identical whoever asked. A GUI user switch without a
reload could be served the previous user's stat or readdir. Keys now carry the
origin and token they were read as, built in one place so the writers, the
readers, the invalidations and the warm-up probes cannot drift apart, and
signing out drops the cache and `whoami` with the token.
**The driver permission prompt could not fire.** Both paths waited for a 200
carrying `{success:false, error:{code:'permission_denied'}}`. The driver route
answers `success: true` or throws, so a refusal arrives as a 403 -- the shape
the retry now reads. Its two tests synthesised the old envelope, so they passed
against a response the API does not send; they use the real one now, and the
prompt goes from never called to called once.
This is a behaviour change: a driver call refused for permissions will now
prompt, which is what the path was written to do. Happy to delete it instead if
the prompt is not wanted.
The half-written copy of the same idea in `utils.js` is gone -- it prompted for
one hard-coded interface and its retry was a `// todo`.
**The verification-gate single-flight was keyed on nothing**, so a phone-gated
request in flight beside an email-gated one took the email answer, replayed,
failed again and surfaced raw without ever asking. Keyed per gate: same gate
still shares one dialog, a different gate gets its own.
* test: ask the cache the same way the code does
The API suites built `item:<path>` keys by hand, so they stopped finding
anything once the real keys carried the identity they were read as. They go
through `fsCacheKey` now, which is the point: a test that spells the key itself
passes until the moment the spelling changes, and then fails for a reason that
has nothing to do with the behaviour it is guarding.
These run against a live server, so a plain `vitest run` does not reach them --
they were green locally and red in CI.
* fix: the review's findings on this branch
Eight, and three of them were regressions this branch introduced.
**Pinning the token sender to `globalThis.parent` in every environment broke
the case it was meant to protect.** On a top-level page `parent` is the page
itself, and the token arrives from a popup, so the handler stopped accepting
anything. The GUI posts `puter.token` to `window.opener` for the file pickers
too, not only for sign-in, and this handler is their only consumer -- a site
calling `showOpenFilePicker()` would have signed the user in, dropped the token
and run the following read with none. It now pins the embedder when framed and
a window this SDK opened when not; `UI.js` already tracked its picker popups
for the same reason, so that set is shared rather than duplicated.
**The GUI still spelled a cache key by hand.** `updateSubdomainsForItems` built
`item:<path>`, which no longer matches what the SDK writes, so its `get` could
not hit and its `set` wrote an orphan nothing reads and nothing evicts -- a
published website's badge quietly stopped appearing on a cached render. It goes
through `fsCacheKey` now, and that is the last hand-spelled key in the tree.
**Retargeting the driver permission prompt to 403 was wrong and is reverted to
the deletion the ticket asked for.** The route refuses with `forbidden`, not
`permission_denied`, so the retarget still missed it; meanwhile a driver that
raises `permission_denied` for its own reasons would have opened a prompt that
had nothing to do with the refusal. And the prompt asks for
`driver:<iface>:<method>` while the route checks `service:<driver>:ii:<iface>`,
so granting it changes nothing. The path cannot work as written.
The rest were pre-existing or latent:
`cacheWhoami_` wrote its answer without checking the token it was issued under
was still current, so a reauth landing mid-flight could repopulate `whoami`
with the previous identity. `_dropIdentityCaches` also left `whoamiCache_`,
which other call sites await.
`setAuthToken` now drops the identity caches when the token actually changes:
keys carry the token and the store is persisted with no TTL, so a rotation that
did not go through `_clearAuthToken` orphaned a whole key space with nothing to
reclaim it.
The verification-gate entry was registered after the body could already have
deleted it -- a synchronous throw stranded a resolved "not verified" in the map
for the life of the page. It registers first and clears on a microtask.
`signIn`'s poll is bounded as well as settle-checked: on a cross-origin
isolated page opened from a gesture, nothing watches the popup, so closing it
never settled the promise and the loop ran on exactly as before.
`runInDerivedContext` says in its docblock that callee writes do not survive;
only the one flag needs the scope, and a future caller should not expect to
hand a value back through it.
* fix: the review's findings on the SDK auth plumbing
**The relay bound stopped asking without saying so.** The deadline left the
`signIn` promise pending and its listener attached. On an isolated page opened
from a gesture the relay is the only thing that can settle it -- nothing watches
the popup there -- so a user who took longer than the deadline was left with a
promise that never resolved, and `authenticateWithPuter` queues every later
implicit-auth call behind it. It rejects now.
**Pinning the token source left `signIn`'s own popup untracked.** Only the
picker popups registered, so on a top-level page the global handler bailed on a
sign-in it had opened itself: `puter.onAuth` never fired, and
`puterAuthState.authGranted` was never set. `openAuthPopup` is the one place
auth popups are opened, so they register there.
**A closed popup is ours too.** Rejecting one that had already closed loses a
message from a popup that posts and then closes itself, which is why `UI.js`
tolerates the same case. Kept, with the set bounded instead.
**The gate key carried the factors**, so one route naming them and another not
opened two dialogs for the same gate. It keys on the gate, as `ctx.done` does.
**`fsCacheKey` left `~` unresolved**, so a writer and an invalidation spelling
the same place differently missed each other -- the drift the one key builder
was meant to end.
The dead `opts.permission` went with the path that used to read it.
Four workerd-runner failures are left: `ai > chat stream adapter supports
cancellation`, `kv > a GUI boot key written by another client reads fresh` and
two cache-warmer ones. All four fail the same way on `origin/main`, and the node
and browser runners pass.
* chore: one-line the comments added here
* chore: reflow the comments the removed retry cause left ragged
|
||
|
|
f234bd477c |
fix(auth): answer a username lost to a concurrent write with the taken-name 400 (#4115)
The duplicate-username check in the signup placeholder claim, change-username and save-account runs before the user-row write, so a concurrent write can take the name in between and the unique index rejects ours. Each path now re-reads the holder from the primary on a unique violation and returns the 400 its pre-check returns for a taken name, instead of a 500. In all three, the home-directory rename, rename budget charge and username-changed event run after the user-row write, so nothing has happened yet when it fails. |
||
|
|
2e275a8f6b |
fix: godmode installation (#4116)
* fix: godmode installation * fix: godmode classifier * fix: vitest |
||
|
|
f674e51b4d |
fix: answer lost write races with 4xx, and time health-check timeouts against loop stalls (#4107)
- signup: a username taken by a concurrent signup between the duplicate check and the insert now returns the pre-check's 400 instead of surfacing the unique-key error as a 500. The holder is read from the primary. - fs move: when the UPDATE trips the (parent_id, name) unique key after the collision probe missed the occupant, re-read it from the primary and resolve it the same way the probe would have (overwrite, dedupe, or the 409 item_with_same_name_exists), then retry the move once. - server health: a check that hits the per-check timeout reports how long the event loop was blocked during it, like the database-liveness latency failure already does. |
||
|
|
6efb96d560 | Merge branch 'juancastro/flat-permission-view-scope' into juancastro/permissions-access-control-cleanup | ||
|
|
11e48c68a9 | fix: verification for sms (#4101) | ||
|
|
464f758a97 | fix: verification for sms (#4100) | ||
|
|
d2cdde5147 | fix: verification for sms (#4098) | ||
|
|
738a8058ef |
fix: close the path oracle where it actually was
Local testing against a running instance found the service-side fix was not enough. Both answers were 403, but they did not read the same: exists -> permission_denied: fs:/other/rd:read missing -> Cannot create outside your home directory (forbidden) The second comes from `#planFsPathCreate` in the controller, which probes the path itself and only calls the create guard once it knows the path is absent. Its comment said so outright -- checked "only once the path is confirmed missing, so an existing shared path outside the caller's home is not a new rejection" -- which is the oracle stated as an intention. A foreign path now refuses word for word whichever it is. Responses are byte-identical apart from the caller's own input. `rewritePermissionForActor` collapses the create guard's `forbidden` the same way it already collapsed the 404, for the paths that reach it through the service rather than the controller. The existing test pinned `forbidden` here; its subject is that nothing is created in someone else's tree, which still holds. Alongside it is the invariant itself: refuse a present and an absent foreign path, mask the path, compare the whole error. Without the controller check that fails. |
||
|
|
b8fcbffe3f |
fix: the five remaining review findings
**The path oracle was not closed.** The rewrite only remapped the *error*, so success-vs-403 still answered whether a foreign path exists. Narrower than reported: every other grant path already gates on `canManagePermission`, and only `grantUserAppPermission` has no authority check -- deliberately, since granting your own app something you don't hold is inert. It now refuses a foreign path the actor cannot see, with the same error a missing one gives. My first test for this passed without the fix: `makeUserActor` creates no fsentries, so both paths 404'd and nothing distinguished them. It inserts a real entry now, and the control fails with "expected null to match 403" -- null being the grant that succeeded, which is the oracle. **`#validateEmail` still failed open** on the main signup / change-email / confirm path while its OIDC twin was hardened. Same shape as OIDC now: a gate that did not run is not a pass. **The 16-permission cap broke `puter.perms.check()`** for longer lists. The SDK's own docblock promised "one round trip however many are asked about", which the cap made untrue, so it batches instead. The reported second half -- the UI grant poller burning its timeout -- is not reachable: `MAX_REQUESTED_PERMISSIONS` already caps that path at the same 16. **`listener_failed` was set by wildcard listeners.** An unrelated observer on `email.*` throwing would refuse every signup. Only an exact-key listener implements the event, so only that one speaks for the gate. **`whoami()` labelled everything `Unauthorized`.** A 502 with an HTML body is not a 401; the server's `code` now rides along and the fallback names the status. |
||
|
|
66774ff90c | Merge branch 'main' into juancastro/permissions-access-control-cleanup | ||
|
|
0495ea0f9d |
fix: close the holes the review found in the storage gate
Four, all real.
The gate threw on a non-string. `validator.isEmail` asserts its argument, and
two call sites hand it an unvalidated body value -- `send-pass-recovery-email`
is public, so `{"email": 1}` was a 500 rather than a 400. The check belongs in
the gate, not in each caller, which is the whole premise of having one.
OIDC signup never shape-checked the claim at all. `claims.email` went from a
truthiness check straight into `user.create`, so a provider we do not run could
store exactly the string this branch exists to keep out. `email_verified`
attests the mailbox, not the characters.
The change-email confirm handler promoted the staged column without re-reading
it through anything. A row staged before this deploys still lands on `email`;
one check at promotion closes that window.
The invite path kept its own regex, and it was never the same rule. It admitted
`a..b@x.com`, `.a@x.com`, `a@-x.com`, `a;b@x.com` and `a,b@x.com` -- the last of
which nodemailer reads as two recipients -- each one a permanent pending row
that spent daily quota, which is the failure the comment above the constant
said it existed to prevent. It uses the gate now, and the second regex is gone.
A regression test each, all four confirmed to fail without their fix.
|
||
|
|
73698be964 |
fix: one gate for an address we are about to store
`validator.isEmail` accepts a quoted local part, so an address whose local part is an arbitrary string passes it. Five call sites relied on that check alone before writing the address to `user.email`, and the invite path's own shape regex was equally permissive. `isStorableEmail` is now the single gate: the library check, plus a refusal of `<`, `>` and `"`, which are legal in an address only inside the quoted form. Nothing deliverable uses that form, so the rule costs no real address -- apostrophes and ampersands in a local part still pass, and the suite covers them. One function rather than a check each caller has to remember, because the next place that stores an address will not remember. |
||
|
|
95809c7dd5 |
fix: bound the permission check, and let whoami fail
`/auth/check-permissions` took an array of any length. `checkMany` batches the
scan so it is not the fan-out the report describes, but nothing stopped a caller
sending thousands of strings; it now takes the same 16 the grant and revoke
routes take.
`whoami()` returned `resp.json()` without looking at the status, so an expired
token resolved to an error body typed as a `User`. Callers read `username` off
it as undefined — which is how a consent prompt came to ask for
`/undefined/Documents`. It throws the documented `{ status, message }` shape
now, the same one the no-token path already threw.
|
||
|
|
3389d3f8a1 |
fix: treat an abuse hook that could not run as a refusal
The signup paths read `allow` off a payload they initialise to `true`, so a harness listener that throws leaves the verdict untouched and the signup goes through. The outer try/catch the report points at is not where it happens: `emitAndWait` already swallows each listener's error and carries on, so that catch almost never fires and the throw never reaches it. `emitAndWait` now records the failure on the metadata it was handed, and both signup paths — OIDC and the ordinary one — refuse when it is set. Other emitters are unaffected: the flag is only read where somebody is asking for a verdict. The report named OIDC alone; the ordinary signup had the same hole, and it is the path that matters more. Test boots a server, registers a listener that throws, and posts a signup: 200 before, 403 now. |
||
|
|
8c7372adc6 |
fix: stop the path rewrite confirming entries the caller cannot see
`fs:/path:mode` resolves through `getEntryByPath` with no ACL check, and a missing entry answers 404 while an existing one carries on to the authorization check and answers 403. The pair is a membership test for any path on the deployment: grant routes take the permission as given, so anyone could ask whether /someone-else/taxes.pdf is there. The resolver keeps its 404 for a path inside the caller's own home, where knowing it is missing is the useful answer and reveals nothing. Everywhere else it answers with the same `permission_denied` the authorization check gives, so the two cases are one case. ACLService already collapses them; this is the same rule reaching the grant and revoke paths, which take an actor and so can tell whose tree is being named. Internal resolution still goes through `rewritePermission`: only the paths that take a caller-supplied permission moved over. The test grants on an existing foreign path and a missing one and asserts the outcomes are identical; without the fix they are 403 and 404. |
||
|
|
a46eb15d5e |
Let the account's own API token reach the routes a privileged app needs (#4032)
* feat: let the account's own API token reach the routes it should
A privileged app is launched with the GUI session token today. Running one on a
full-access personal access token instead costs much less when it leaks, but
several routes those apps depend on refuse every access token, so the swap would
break them.
Admitted, each a read or a write the token's own HTTP reach already covers:
- the realtime socket handshake, which refused access tokens outright; FS
live updates and notifications stop without it
- GET /get-dev-profile and POST /profile, both puter.js surface
- GET /auth/list-permissions — a read of what was granted, not a grant
- GET /share/shared-by-me/apps and GET /share/audit, over grants
/share/shared-by-me already lists for the same credential
- POST /rao, which puter.js calls on every setAuthToken
Gated on `isAccountContext`, not the `full_access` claim alone, so a token with
an app anywhere in its chain is still refused and a socket for one never joins
the user room.
Left session-only: mint, session, 2FA, grant, team and billing, plus two the
list named. /share/blocks is a personal safety control whose only caller is the
desktop's Blocked Senders window. /app-feedback is reachable only from our own
GUI pages, and being unsubmittable programmatically on a user's behalf is the
property it was built with.
The new HTTP suite drives a real server with a minted token and fails on all six
admissions without the change; the block routes' existing metadata check already
pins their refusal.
* test: sort the two token shapes at the events handshake
* fix: carry the account-context invariant into the HTTP gate
`requireUserActorGate` admitted on the raw `full_access` claim while the socket
and /rao use `isAccountContext`, so one shape — a token carrying the claim with
an app in its chain, or an actor built without `makeActor` — would have been
refused in two places and admitted in the third. Unreachable today, since the
mint and the auth-time read both drop the claim when `app_uid` is present, but
it is the invariant this change states and it should hold at every enforcement
point.
The payout address goes back behind a plain session: `/get-dev-profile` is open
to the account's own token now, and the rest of what it answers is a developer's
own name and incentive-program status.
Also drops a cross-reference in EventsService to the socket posture this change
relaxed, and rewords the block-routes comment, which read as if access tokens
were admitted there rather than refused.
* fix: drop a revoked token's sockets, and read the dev-profile columns
Two gaps the review found, both reachable only because this branch lets the
account's own token hold a socket and call /get-dev-profile.
`#revokeAccessTokenTail` soft-revoked the session row and told no one, so a
revoked token kept its connection — and the account's `outer.gui.*` fan with it
— until the five-minute reauth sweep. Sockets now join a per-token room and a
new `auth.access-token.revoked` event drops exactly that room, so the account's
other tabs stay up. Session revoke keeps its account-wide eviction.
`/get-dev-profile` read `first_name`, `last_name`, `paypal` and the two
incentive flags off the user row, which carries none of them: the columns have
always been `dev_`-prefixed, so the endpoint answered nulls to everyone and
`puter.apps.getDeveloperProfile()` has never returned anything. Reads the real
columns, keeping the unprefixed name as a fallback for a deployment carrying
both. The payout address stays behind a plain session, so opening the route to
the account's token hands over a name and incentive status and nothing else.
Both fail without the fix: the socket test waits five seconds on a connection
that should already be gone, and the profile test reads back a seeded row.
* fix: close /open_item to tokens, and stop the node runner leaking sockets
`/open_item` carried `allowFullAccessToken`, but it grants an app a permission
on the user's behalf and mints the app token to go with it — a grant and a mint,
which stay session-only. Pre-existing, and missed by the first sweep here, which
only asked whether routes lacking the flag should gain it and never re-read the
ones that already had it. Refused at the top of the handler too, so a credential
that gets past the gate cannot leave an ACL row behind.
The node runner loads each SDK into a vm context and never closed it, so every
test leaked a socket, and every socket holds a per-(user, origin) connection
slot. Admitting the account's own token to the handshake pushed that past the
cap, and the whole events block failed with `events_connection_failed` while the
browser and workerd runners — which do not leak — passed. Closing the FS socket
and the events channel after each test gives the slots back.
* fix: announce only the revokes that can have a socket to drop
Every access-token revoke broadcast an eviction cluster-wide, including the
read-URL tokens `revokeReadUrl` retires constantly. A scoped token is refused at
the handshake, so none of those broadcasts could ever reach a connection.
The announcement now goes out only for a token the handshake would have
admitted: full access, no app in the chain. `revokeOwnAccessToken` refuses
full-access tokens outright, so that whole path is silent; a revoke by raw uuid
says nothing about the token and still announces, which costs a no-op broadcast
rather than leaving a revoked socket up.
Fails without it: the test revokes a scoped token and a full-access one through
the same entry point and counts the announcements.
* test: pin the mint route against the account's own token
`/auth/create-access-token` carries only `requireAuth`, so it never showed up
in the sweep over `requireUserActor` routes — the refusal lives in
`AuthService.createAccessToken`, which turns away any access-token actor
outright. That holds today, and a leaked full-access token minting itself a
sibling that survives revoking the original is exactly what the token swap is
meant to rule out, so it is worth a test rather than a reading.
* fix: let the recommended-apps test follow the list it tests
Two commits on main rewrote `RECOMMENDED_APP_NAMES` without touching the test,
which seeds `editor` and `camera` and asserts the result contains them. Neither
is on the list any more, so the resolved set came back empty and `test (base)`
has failed on every PR since.
The test now seeds the first two names off the list itself and asserts on those,
so the next edit to it cannot strand the test: prepending two unknown apps keeps
all four cases green.
* feat: let an extension waive a plan gate for one route or driver method
A plan gate about to refuse a caller now emits
`subscription.gate.<surface>` through `emitAndWait` first:
`route.<method>.<path>` for a route, `driver.<iface>.<method>` for a driver
method, `share.anyone` for link shares. The payload carries the actor, the
request and the requirement, and a listener that sets `allow` waives the plan
for that call. Callers the plan already covers never reach the hook, and a
listener that throws leaves the refusal in place.
* feat: mint godmode apps their own full-access token
`/auth/get-user-app-token` answers a desktop launch of a godmode app (the
`godmode` column) with a full-access token instead of an app token. Origin
lookups, the sign-in popups for pages outside the desktop, keep getting
ordinary app tokens, and only a web session may mint one.
The token carries the app in `godmode_app_uid`, read into
`accessToken.godmodeApp` for attribution only: `effectiveApp` stays null, so
it keeps the account's reach and passes the same gates as a personal access
token, including the vendor-compatible AI routes. It is refused once the app
is gone or no longer godmode.
Its row is an `access_token` session parented to the web session that asked,
with `app_uid` set, so signing out, revoking that session or revoking all
sessions takes it along, and the sessions list shows it under the app. Each
mint re-signs onto the same row with a 12h `exp` and moves the row's expiry
out, so the desktop keeps an open app alive while a copy taken earlier still
expires on schedule.
A full-access token may now mint scoped tokens (read URLs), parented to its
own row; it still may not mint full access. Revoking a full-access token
cascades to the tokens it minted.
An access token whose session row has a parent now authenticates only while
that parent is live, so a read URL a godmode app minted lapses with the app's
token, and through it with the desktop session.
* feat: launch godmode apps on their own token and renew it
The desktop asks for a godmode app's token at launch and blocks the launch if
it can't get one, as it does for other apps; a godmode app never runs on the
desktop's session token. While the window is open the desktop re-mints the
token once less than 6h of its 12h remain, checking on a timer and when the
tab becomes visible, and answers the app's `reauth_required` with a fresh one,
posted to the app's origin.
puter.js keeps a godmode token in sessionStorage like a session token, never
in localStorage. In app mode a 401 on a godmode token, background requests
included, asks the desktop for a new token and replays, and a `puter.token`
message is only taken from the embedding frame.
The sessions list shows a godmode token row with its app's title and icon.
* fix: keep a godmode token the same across renewals
Apps hand their launch token to code that never sees a replacement (the
terminal's Drive mount, a CLI's API key), so a token re-signed every renewal
broke those copies once the first one expired. The JWT now carries no `exp`
and a renewal moves only its row's expiry, so every copy keeps working while
the desktop keeps the app alive.
What still bounds it:
- only the desktop's web session can extend it; using the token never does,
and signing out or revoking that session revokes it;
- it lapses 12h after the desktop last asked, and a lapsed row is never
revived: the next launch gets a new token;
- however often it is renewed, a token is replaced after 7 days, and the old
one is revoked on the spot along with the tokens it minted and its sockets.
The desktop no longer re-posts a renewal that kept the same token, so the
app's sockets aren't rebuilt every few hours; an app that reports its token
stopped working is always answered.
* fix: reserve the feedback and no-reply mailbox names again
Signup checks names against `util/reservedUsernames`, but `fbl`, `noreply` and
`no-reply` were added to a second copy of the list in AuthController that
nothing reads any more, so they became claimable. Move them into the live list
and drop the copy.
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
|
||
|
|
970092f0a0 | PUT-1995 (#4020) | ||
|
|
83d64ac685 |
fix: follow-ups from the reviews of #4047, #4049, #4051, #4053, #4055 and #4057 (#4068)
* fix(auth): issue reauth tokens only for browser sessions
A rejected token's 401 carried a signed reauth_token, which /signup turns
into a session on a temp account. Any revoked app or access token, a stale
token from a deleted app whose uid was reused, or a worker credential could
get one. Only a GUI/browser session's rejection now carries a reauth_token;
everything else still gets reauth_required without it. The stale-app check
also runs before the session is touched.
* fix(events): refuse worker creation from an events handler
workers.create minted a worker token with no handler depth, so a handler
could deploy a worker and chain writes through it at depth 0. A handler
actor can no longer create a worker; delivery, deploys and hot reload mint
from plain owner actors and are unaffected.
* fix(ai-ocr): bound the page count's parsing and close its under-counts
- Object-stream headers are capped (/N, /First) and read incrementally, and
the inflate budget scales with the file, so a crafted PDF can't spend
seconds and gigabytes before the credit check.
- An escaped /ObjStm type, an indirect /Count, untyped page leaves and an
object redefined to a smaller tree no longer lower the count.
- An account with no balance is refused before the file is parsed.
* fix(fs): add a switch to stop accepting pre-binding signed URLs
Signed URLs issued before owner binding are still accepted, and owner-signed
ones never expire, so the move-to-another-tree attack stays open for them
indefinitely. legacy_file_signatures (default on) can now turn them off, a
rate-limited warning counts their use so it's visible when they've died
out, and a non-UUID uid is refused before the entry lookup.
* fix(fs): hide the issuer's home uid from a scoped token's entries
A token scoped below the home could still learn the home's uid as the
parent_uid of a direct child (e.g. stat on ~/Documents). For access-token
actors that can't list the home, a direct child's parent uid is now null in
the v2 and legacy entry shapes, using the same rule as the root listing.
* fix(gui): ask for the 2FA code on the last step, right before enabling
The setup flow verified a code before the recovery-codes screen and sent
that same code to enable afterwards; by then it had usually expired, and
the user was sent back to the start. The code is now entered last and goes
straight to enable; a wrong code keeps the user on that step, and other
failures show the server's message. Enter submits the password step.
* test(fs): expect the legacy-signature flag in the signing config
* fix: apply the review's follow-ups to the merged-PR fixes
- Reauth tokens also require the session row to be a browser ('web')
session, not just the absence of the worker claim.
- The OCR page count only treats an untyped object as a page when it sits in
a page tree's /Kids, so outline items, widgets and name-tree leaves can't
inflate an honest PDF's count.
- The signed-URL uid guard checks the uuid's shape only, so a legacy row
whose uuid has nonstandard version/variant bits still resolves.
|
||
|
|
9ca77be5a9 |
fix(auth): require a step-up to set up 2FA and a valid code to enable it (#4051)
Setup returned a fresh secret and recovery codes to any session, and enable flipped otp_enabled without proof of the secret, so a stolen session could enroll its own authenticator and lock the owner out. Setup now lives at /user-protected/setup-2fa behind the same password / OIDC revalidation gate as disable-2fa, and enable requires a live code for the stored secret. The GUI setup window asks for the password (or opens the revalidation popup) first, and sends the code it already collected to enable. |
||
|
|
04406373f4 | event based FBL implementation (#3969) | ||
|
|
37c1678897 |
fix(fs): keep a vacated username off-limits to other accounts for an hour (#4039)
* fix(fs): keep a vacated username off-limits to other accounts for an hour Renaming a home rewrites descendant paths in the database, but their cached entries keep the old path until they expire. ACL grants a home by path prefix, so whoever takes the old name next must not be able to before those entries are gone. renameUserHome now records the vacated name, and the claim check every username-claim site already runs treats it as taken for anyone but the account that left it. * test: seed recommended apps that are still in the default list The default list no longer includes editor or camera, so the ordering test found neither. |
||
|
|
42b204f46e | fix: don't count failed username change towards rate limit (#4021) | ||
|
|
a67cdac380 |
fix: bound the inputs on the grant and token routes
Three permission routes sized their work by the request body rather than by the route, so one call could cost far more than its rate-limit slot implies. - `extra` and `meta` were only type-checked. They ride every row a grant writes (up to 16) and are re-serialised into the per-(user, app) cache on each miss, so they are now capped at 4 KiB. - `/auth/create-access-token` took a `permissions` array of any length: one permission check and one sequential INSERT each. It now uses the same 16-per-request cap the grant routes already had, and runs every entry through the validator those routes use. - Withdrawing an app's cross-app data grants looks them up by permission text, and no index on `user_to_app_permissions` led with `permission`. Indexed per engine, mirroring what mysql_mig_22 / postgres_mig_11 / sqlite 0067 did for `user_to_user_permissions`. `grant-dev-app` validated none of its input — not even the type of `extra` — so it now runs the same validator as its user-app sibling. Two paths could also carry a permission wider than the `varchar(255)` column it lands in. `grantUserAppPermission` already rejected that after rewriting; `grantDevAppPermission` now does the same, and `createAccessToken` checks it before the session row a failing INSERT would otherwise orphan. Caps are published in rate-limits-and-quotas.md. Every in-tree caller sends one permission and a small `extra`, so none of them change behaviour. |
||
|
|
abd27c0192 |
fix: replicate tombstones, and attribute foreign-key failures correctly
Review of the first pass turned up two holes that made it ineffective. CacheReplicationService always deleted the keys a peer broadcast and never adopted a payload, so tombstones stopped at the region boundary: peers dropped the marker, kept caching deleted rows off their own replicas, and the multi-region case the fix was for stayed broken. Tombstones are now the one payload a peer adopts — they carry no row, only the fact of a delete. user_to_app_permissions keys both the app and the user, and the drivers don't all name the constraint, so a deleted *account* was being read as a deleted app: a live app got tombstoned, the request 404'd for the wrong reason, and the dead account was never retired, so the client retried and re-tombstoned indefinitely. The insert site now asks the primary which parent actually went away and answers 404 or 401 to match. Also: re-check the tombstone after a cache write lands, since a row cached under a live tombstone is never looked at again; extend the guard to #refreshCache, which update() could otherwise use to undo a delete; stop markDeleted tombstoning an address the deleted row never owned, which was blocking the account that did own it; let the primary rather than the tombstone decide in batched lookups; tombstone the id key when no cached row names the others; and correct the leaf-store comment in stores/index.ts now that SessionStore depends on UserStore. |
||
|
|
785304e807 |
fix: stop deleted apps and users from being resurrected by a lagging replica
A delete lands on the primary and invalidates the cache, but a concurrent request that misses the cache reads a replica that hasn't applied it yet, finds the row, and writes it back under a fresh TTL — 24h for apps, 15m for users. Every write referencing that resurrected row then fails its foreign key and escapes as a raw driver error, so the route 500s until the TTL expires. Origin-bootstrapped app uids are deterministic, so the same origin keeps hitting the stale row and never re-bootstraps. Deletes now tombstone their cache keys before dropping them. While a tombstone is live the read path goes to the primary and the write path refuses to cache; re-creating a row clears it. As a backstop, a write rejected for a missing parent is answered as one: 404 for a vanished app, 401 for a vanished account, with the stale entry retired either way. Also fixes cascadeDelete's identity lookup, which selected a column some schemas lack and so threw into its catch, skipping the invalidation entirely; and drops SessionStore's private copy of isUniqueViolation in favour of the shared helper. |
||
|
|
09887747e0 | fix: app uuid fixes (#3980) | ||
|
|
176c8848f5 |
fix: let a seat enrol in 2FA from the console, and hold it once required
The Security tab only rendered the 2FA card for accounts with a confirmed email, so a seat — provisioned without one by design — had no way to enrol from the UI at all. It was the GUI twin of the enable-route check fixed earlier: the rule could be required of accounts that could not satisfy it. The card now shows for a seat too, giving it the ordinary authenticator flow: QR, manual secret, six-digit code, recovery codes. Disabling is refused while the seat's team requires 2FA. Without that a member could switch off the factor their team mandates and lock themselves out of everything until they enrolled again. |
||
|
|
3105378fdc |
fix: let a team seat enrol in 2FA, and fit the rule's note on its card
Found by driving the flow against a live instance: a provisioned seat could reach `configure-2fa/setup` but never `enable`, which demands a confirmed email. Seats are provisioned without one on purpose (PUT-1792, the team being the trust anchor), so requiring 2FA of a team would have locked out precisely the accounts the rule is for — permanently, since a seat cannot enrol and the rule never lifts. The check now passes for an account a team provisioned; every other account still needs its address confirmed, with tests either way. The card's note was also a line too long and rendered clipped, so it says the same thing in the space the layout gives it. |
||
|
|
db1a95260a |
feat: let a team require 2FA of the accounts it provisioned (PUT-1988)
`group.require_2fa`, toggled by the owner through `PUT /teams/:uid` and recorded in the audit log like `directory_enabled` — it changes what every seat must hold to sign in, so it is not something a team should be able to alter silently. Enforced by deriving, not by stamping a flag on the user: a stamp left behind by a member leaving, or by the rule being turned off, would lock someone out of their own account with no team left to clear it. The seat row already joins `group` and is already read per request, so it carries the flag and the gate costs nothing extra. Toggling busts those rows, or turning the rule off would leave members locked out until the TTL lapsed. The gate rides the same `allowUnconfirmed` opt-out as the other pending- requirement gates, so the routes a gated member must still reach stay reachable — `configure-2fa` among them, which would otherwise be walled off behind the very requirement it clears. Scoped to `org_owned = 1`: the owner is not a seat of their own team, and a joined member is an ordinary self-paying account whose personal login is not a team's to dictate. The owner is held to the rule at the point it matters — they cannot turn it on without 2FA of their own. |