Compare commits

..
Author SHA1 Message Date
Nariman Jelveh 6b276a4823 fix(gui): stop disabling 2FA when the provider sign-in doesn't happen
For an account that signs in through a linked provider, turning 2FA off
opens the provider's sign-in popup. When the popup was closed or
blocked, the dialog showed why for a moment and then sent the request
anyway, whose refusal ("OIDC revalidation required") replaced the
reason. It now stops at the popup's error.
2026-10-24 14:44:26 -07:00
Nariman Jelveh 906ea0f7ee fix(gui): don't leave the 2FA dialogs stuck when a request can't be sent
Turning 2FA on or off from the dashboard's Security tab while offline,
or on a dropped connection, left the dialog busy for good: enabling kept
its spinner up with the code boxes disabled, and disabling kept its
button and password field disabled, with nothing said either way. A
request that gets no answer is now reported like a refused one, and the
dialog can be tried again.
2026-10-24 14:43:58 -07:00
Nariman Jelveh 54c0d48d69 fix(gui): show the 2FA setup's confirmations without entities
The 2FA setup window, which the dashboard's Security tab opens, encoded
already-encoded translations for its labels, so in French the two
checkboxes before enabling read "J'ai enregistré mes codes…" and
"Je suis prêt à activer l'A2F". The labels, and the window's other
translated text set the same way, are now encoded once.
2026-10-24 14:42:51 -07:00
Nariman Jelveh a3bf0bd150 fix(dashboard): undo a drag out of an Apps folder when the drag is cancelled
Dragging an app past the edge of an open folder takes it out, closes the
folder and carries the drag on over the grid, and the removal was saved
right then. Pressing Escape, or the window losing focus, cancels the
drag but left the app out; out of a two-app folder that dissolved the
folder for good, its name with it (dragging the app back made a new
"Folder"). A cancelled drag now puts the folder and the order back.
2026-10-24 14:42:21 -07:00
Nariman Jelveh 2f3b9ec581 fix(dashboard): re-read the Apps layout on returning to its browser tab
The Apps tab read its order and folders only when it was opened, and
saves write its whole copy of them. With the dashboard open in two
browser tabs, a folder made in one was wiped as soon as the other,
showing Apps since before, changed anything (a reorder, a new folder, a
rename), as was the order arranged in the first. The tab now reads the
layout again when its browser tab comes back into view.
2026-10-24 14:41:06 -07:00
Nariman Jelveh 55c0225c67 fix(dashboard): leave IME keystrokes to the IME in the share recipient field
With a Japanese or Chinese input method on, even a Latin username is
composed, and the share dialog's recipient field took the keys that
drive it: the arrows moved through its suggestions instead of the IME's
candidates, and Enter to confirm the text picked a suggestion instead.
Keys during composition are now left to the IME.
2026-10-24 14:40:10 -07:00
Nariman Jelveh 01b0014c87 fix(dashboard): leave IME keystrokes to the IME in Apps
Typing Japanese or Chinese in the Apps search or a folder's name, the
keys that drive the input method reached the tab's own handlers:
ArrowDown to pick a candidate moved focus to the first tile and cut the
conversion short, Enter to confirm one named the folder half-written or
launched the first match, and Escape put the old name back. The search
box's Enter meant to skip composition, but read isComposing off jQuery's
event, which never carries it. Keys during composition are now left to
the IME.
2026-10-24 14:39:46 -07:00
Nariman Jelveh 2590bfcd32 fix(dashboard): refresh Home's recently used apps on returning to the tab
Recent apps open in a new browser tab, and coming back to the dashboard
refreshed the usage cards but not "Recently used", whose comment said
it followed the same triggers: the app just opened only moved up the
list after switching away from Home and back. Returning to the tab now
re-reads the list too.
2026-10-24 14:39:01 -07:00
Nariman Jelveh 345f25d337 fix(dashboard): measure Home's storage meter by the amount it shows
On a self-hosted install the storage card names the disk's use (Puter's
files and everything else on it) but filled its meter, and gave its
percentage, from Puter's files alone, so it could read "93 GB used ·
0% of 466 GB". The meter and percentage now come from the same amount
the card shows.
2026-10-24 14:38:37 -07:00
Nariman Jelveh 329e02347e fix(dashboard): prefill the username when saving a session from Home
Home's "Save session" button opened Save Account with an empty username,
so a temporary user had to make one up, while the same action in the
user menu fills in the account's current name and starts at the email
field. Both now open it the same way.
2026-10-24 14:38:03 -07:00
Nariman Jelveh 09d0c5d640 fix(dashboard): link Home's card headers to their tabs
The headers of Home's cards (Recently used, Usage, Your Plan, Your
Storage, Resources) linked to "#", so opening one in a new browser tab
(middle-click, the browser's menu) showed Apps rather than the tab it
names, and Cmd/Ctrl+click switched tabs in place. They now link to the
tab they open, and a modified click is left to the browser, as on the
sidebar.
2026-10-24 14:37:39 -07:00
Nariman Jelveh b0c06f23c6 fix(dashboard): fill Home's storage and usage meters without waiting on the plan
Home read the plan first and only then asked for storage and the month's
usage, so every visit paid for one more round-trip before either meter
filled in. The plan request had no timeout either: one that never
answered kept both meters at "--" and, as later loads wait for the one
in flight, held every later refresh of the cards for the rest of the
session. The three now load side by side, and the plan read gives up
after 15 seconds.
2026-10-24 14:37:05 -07:00
Nariman Jelveh a67eea4ab2 fix(dashboard): close the drawer and Files' dialogs on browser Back and Forward
Browser Back or Forward switched tabs but left whatever was open on
screen: on a phone, Android's Back changed the tab under the open menu
drawer and left it open; in Files, the Share or Properties dialog
stayed up over Home, still acting on Files rows; an open context menu
stayed too. These now close with the tab, as with a sidebar click.
2026-10-24 14:36:23 -07:00
Nariman Jelveh bf8d9ea349 fix(dashboard): don't let a #files/<path> link pull Files back to its folder
Files doesn't keep the folder on screen in the URL, but a link like
/dashboard#files/alice/Documents stayed in the address bar once opened.
After the user moved on to Pictures, went to Home and pressed Back, the
browser returned to that link and Files jumped back to Documents; a
reload did the same, and Files' own Back/Forward lost track, as the jump
wasn't recorded in it. The link's path is now consumed once opened, as
a share link's is, and a folder opened from such a link is a step in
Files' history.
2026-10-24 14:35:41 -07:00
Nariman Jelveh 35aeb5189b fix(dashboard): let go of the phone drawer when the window widens
With the sidebar open as a drawer below 768px, widening the window past
it (an iPad turned to landscape) drew the sidebar as the desktop one but
left it marked open: Tab stayed trapped in it, so the content couldn't
be reached by keyboard, and the Apps grid ignored the arrow keys, until
an Escape or a click in the content. The drawer now closes when the
window widens, leaving focus where it is.
2026-10-24 14:34:32 -07:00
Nariman Jelveh e550ac1207 fix(dashboard): drop "Save session" when the account was saved in another tab
A temporary account saved in one browser tab stayed temporary-looking
in its other dashboard tabs: Home kept its "Save session" button, which
opened Save Account for an account already saved, and Account and
Security kept their temporary-account layout. Coming back to such a tab
re-reads the account; that now removes the button and redraws the two
tabs when the account is no longer temporary.
2026-10-24 14:34:00 -07:00
Nariman Jelveh 027280eec5 fix(dashboard): don't say "No recent apps yet" before Home's list has loaded
Home's "Recently used" card was drawn from the list as it stood when the
page opened, which is empty until the request answers: every visit
first read "No recent apps yet / Apps you use will appear here" and
then swapped in the apps, and if the request failed the card kept
saying there were none. It now stays blank until the list arrives, and
a first load that fails says the apps couldn't be loaded; a list
already shown stays when a later refresh fails.
2026-10-24 14:33:33 -07:00
Nariman Jelveh 0a5cd46373 fix(dashboard): open a recent app once on a double-click
Double-clicking an app under Home's "Recently used" opened it in two new
browser tabs, one per click. The second click of a double-click is now
ignored, as on the Apps tab's tiles.
2026-10-24 14:32:35 -07:00
Nariman Jelveh 344e7bf406 fix(dashboard): apply the hidden-files setting to a renamed Files row
With Show hidden off, renaming notes.txt to .notes.txt left the row in
the listing until the folder was next loaded, and with it on the row
wasn't dimmed as other dot-files are; renaming .env to env left it
dimmed. A rename made in another tab did the same. A renamed row now
follows the setting as listing the folder would.
2026-10-24 14:32:02 -07:00
Nariman Jelveh 95d846d13b fix(dashboard): move through Files with Home, End, Page Up and Page Down
The Files listing took the arrow keys but not Home, End, Page Up or Page
Down, and as the listing itself never has focus the browser didn't
scroll it on them either: in a folder of hundreds of items the keyboard
could only step through them one row at a time. Home and End now select
the first and last item, Page Up and Page Down move a screenful, and
Shift extends the selection as with the arrows. With a menu open they
are left to it, as the arrows are.
2026-10-24 14:30:37 -07:00
Nariman Jelveh 3c07591878 fix(dashboard): don't offer New Folder, Upload or Paste in a view-only share
Inside a folder shared with the user to view only, the Files tab still
showed New Folder and Upload, offered New and Paste in its menu, and
took files dropped on the listing or on a folder in it; each then
failed: an upload window ending in an error, or a new folder row with
its name field open, then taken back with a Forbidden alert. Those are
now left out there, as in Trash. A share whose level isn't known yet
stays as before, the server deciding.
2026-10-24 14:30:01 -07:00
Nariman Jelveh f8eaf3264c fix(dashboard): download one selected file as itself from the phone selection bar
In select mode, the floating bar's Download zipped whatever was
selected, so a single photo arrived as "photo.jpg.zip" where its own
menu's Download gives "photo.jpg". One selected file now downloads as
itself; folders and several items are still zipped.
2026-10-24 14:28:36 -07:00
Nariman Jelveh af05f0deb9 fix(dashboard): close the Files image preview when its file goes away
Deleting the previewed image (the Delete key, the menu, the phone
selection bar), moving it, or having it removed from another tab took
its row out of the listing but left the preview up, still showing the
image. The preview now closes with its row; a refresh of the folder,
which redraws the row, keeps it.
2026-10-24 14:28:07 -07:00
Nariman Jelveh 563b6b0c80 fix(dashboard): close the Files image preview when an app opens over it
The image preview sits above every window, and only a double-click
closed it on the way to an app. Opening the image with Enter, or with
Open or Open With from its menu, started the app underneath the
preview, which went on covering it while keyboard focus moved into the
app, so Space and Escape no longer reached the preview. It now closes
whenever an app window comes on screen.
2026-10-24 14:27:18 -07:00
Nariman Jelveh 0eea4cfb17 fix(gui): let Escape on a context menu close only the menu
Escape removed any open context menu and then went on to the Files tab's
own Escape handling, which, finding no menu left, cleared the selection
and closed the image preview. Select three files, open their menu,
press Escape: the menu closed and the selection was gone. When Escape
closes a menu, nothing else now acts on it; a second Escape clears the
selection as before.
2026-10-24 14:26:23 -07:00
Nariman Jelveh d748100ea9 fix(gui): leave Ctrl/Cmd+Z to the text field being typed in
Ctrl/Cmd+Z ran the file Undo wherever focus was, and cancelled the
field's own text undo. Typing a new name (F2) or anything in another
field and pressing it undid the last file action instead; when that was
an upload, the uploaded file was deleted for good. Undo now leaves text
fields alone.
2026-10-24 14:25:35 -07:00
Nariman Jelveh b63175b6d5 fix(dashboard): show 2FA as it is when its dialog closes without success
The Security tab's 2FA toggle assumed nothing had changed whenever its
dialog closed without success. With 2FA turned on in another tab, the
setup here refused ("2FA is already enabled."), the toggle went back to
off, and every retry got the same refusal, with Disable never offered;
a code accepted just as the user closed the setup dialog left the same
state. The tab now re-reads the account and shows its real 2FA state.
2026-10-24 14:24:57 -07:00
Nariman Jelveh c260e25f30 fix(dashboard): show the server's message when a session can't be revoked
Manage sessions put a refused request's raw response body in its alert,
so revoking a session already signed out from another tab read
{"message":"Session not found","code":"not_found"}, and the stale row
stayed listed; a failed rename and "Revoke all other sessions" did the
same. The alerts now show the message itself, and a session found gone
reloads the list.
2026-10-24 14:24:24 -07:00
Nariman Jelveh 6c35c2b895 fix(dashboard): let Escape cancel a session rename without closing Manage sessions
In Manage sessions (Security), Escape in a session's rename field
cancelled the rename and then closed the whole modal, and Escape in the
search box closed it too, search and all. Escape now cancels the rename
or empties the search, and only an Escape with neither closes the
modal.
2026-10-24 14:23:47 -07:00
Nariman Jelveh 5b8a93365c fix(dashboard): ask for the new password after a provider-only account sets one
An account that signs in only through a linked provider can set a
password with Change password, after which the server treats it as a
password account. This session's copy still said provider-only, so
Change username, Change email and Delete account went on hiding the
password field and sending the user through the provider's popup until
a reload. The copy now changes with the password.
2026-10-24 14:23:02 -07:00
Nariman Jelveh ffaed41ade fix(dashboard): don't send an account change to the value it already has
Change username with the account's own name got "This username is
already taken." and used up one of the hour's password attempts. Change
email with the current address was accepted: it mailed a confirmation
and a change notice to the same inbox, and following the link unlinked
any sign-in provider. Change password to the current password signed
out every other session and mailed a "password changed" notice. Each
dialog now says the value is already the account's and sends nothing.
2026-10-24 14:22:45 -07:00
Nariman Jelveh 94f4ed9eba fix(dashboard): say "Incorrect password" when the account password is wrong
The account dialogs showed the server's message as is. A wrong current
password in Change password read "Password mismatch", in English in
every language, beside a dialog that says "New Password and Confirm New
Password do not match" when those two differ, so users corrected the
wrong fields. A wrong code read "Incorrect code." and too many attempts
"Too many requests.", also always in English. These now show the
translated messages the GUI already has, and a wrong password puts the
status on the password field, focused for retyping.
2026-10-24 14:22:16 -07:00
Nariman Jelveh fd150cb475 fix(dashboard): drop into a drag-opened folder while it is still loading
When a dragged file springs a folder open, the drop anywhere in the
listing moves it into the folder now shown. Until that folder's listing
arrived, though, the Files tab still counted the folder the drag left as
the one on screen, so a drop in that time was taken as a move to where
the file already was and did nothing. It now goes to the folder being
opened.
2026-10-24 14:11:31 -07:00
Nariman Jelveh 4ca1fa5b63 fix(dashboard): return from a drag-opened folder while it is still loading
Hovering a dragged file over a folder springs it open. Cancelling the
drag (Escape, the Cancel zone, or a drop outside the listing) is meant
to go back where the drag started, but while the sprung folder was still
loading the return was dropped like any navigation during another, and
the user was left in the folder they had backed out of. The return now
takes over from that load.
2026-10-24 14:10:18 -07:00
Nariman Jelveh 251ceca2c9 fix(dashboard): give the Apps page dots a 24px touch target
The Apps pager's dots, and a folder's, are 17px buttons side by side,
under the 24px a target needs when it has neighbors, so on a phone or
tablet a tap meant for one page often opened the next. On a touch
screen each dot is now 24px around the same 7px dot; mouse layouts are
unchanged.
2026-10-24 14:08:56 -07:00
Nariman Jelveh 49d2b010e2 fix(dashboard): bring text in the selection blue up to AA contrast
The selection blue is 3.7:1 against white, and a few places set text in
it or white text on it, under the 4.5:1 that text needs: the active
folder in the Files sidebar, the share dialog's "Add to all" link, the
add-app dialog's Send button, the notification count badge, the drag
count badge, and a breadcrumb whose menu is open. They now use the same
hue darkened to 45% (5.3:1; a lighter one for text in dark mode), from
two new tokens, --select-text and --select-fill.
2026-10-24 14:08:28 -07:00
Nariman Jelveh 22febda3fa fix(dashboard): make the keyboard focus ring visible on sidebar, tiles and lists
The sidebar items, the Files folders, crumbs and arrows, the app tiles,
Home's cards and recent apps, the notification rows and the Usage sort
buttons show keyboard focus with a 2px outline and nothing else, drawn
in --select-ring: the selection color at 40% (50% dark), about 1.6:1
against the surfaces it sits on. Keyboard users could hardly tell where
focus was; a focus indicator needs 3:1. Those outlines now use the solid
selection color (3.7:1 on white), as the switch and pager dots already
do. Field halos, which come with a colored border, are unchanged.
2026-10-24 14:07:06 -07:00
Nariman Jelveh 6d8359de5c fix(dashboard): outline the off state of the 2FA switch
Off, the switch was a light grey track (#e0e0e0) with a white thumb,
1.3:1 against a white card and 1.2:1 against the amber card the 2FA
toggle sits on while 2FA is off, so the control could barely be seen
where it matters most. WCAG asks 3:1 for a control's boundary. The off
track now carries a 1px slate outline (4.3:1 on the amber card); the
on state is unchanged.
2026-10-24 14:06:29 -07:00
Nariman Jelveh 5d83d73a91 fix(dashboard): keep Share and Properties inside the visible screen
Both dialogs capped their height at 85vh (88vh as phone sheets), and vh
is the viewport with the browser's bars hidden. With the bars showing,
on a phone in landscape say, the card plus the overlay's padding was
taller than the screen, and the overlay centers without scrolling: the
title and the buttons were cut off with no way to reach them. Each card
is now also held within its overlay, as the dashboard's dialogs are,
and its body scrolls instead.
2026-10-24 14:05:44 -07:00
Nariman Jelveh dada49ffbb fix(dashboard): label collapsed sidebar items for keyboard users too
With the sidebar collapsed, each item shows only its icon, and its name
appeared in a tooltip on mouse hover alone. Tabbing through the sidebar
showed a row of icons with no names. The tooltip now also shows while
an item has keyboard focus.
2026-10-24 14:04:16 -07:00
Nariman Jelveh 75ed97e1d8 fix(dashboard): give the Delete account button an AA-contrast red
The Account tab's Delete account button used the desktop's danger red
(#f00808), which puts its white label at 4.4:1, under the 4.5:1 that
WCAG AA asks of text. It now uses the dashboard dialogs' danger red
(#d90707, 5.3:1) and their darker hover, so the two match.
2026-10-24 14:03:21 -07:00
Nariman Jelveh 3f4ac45975 fix(dashboard): skip disabled Back, Forward and Up when tabbing in Files
The Files header's arrows stayed in the Tab order while disabled, drawn
at 10% opacity, which takes their focus ring with them. Tabbing into
Files, the first two stops (Back and Forward, disabled on arrival)
looked like nothing had focus. A disabled arrow now leaves the Tab
order, as a disabled button does, and returns once it can be used.
2026-10-24 14:02:44 -07:00
Nariman Jelveh 1877e040a0 fix(dashboard): show the website-link badge on dark Files tiles
In grid view a file inside a published folder carries a link badge: a
black glyph on a disc of the page's background color. In dark mode that
is black on near-black and can't be seen. The badge now gets the white
disc the shortcut and worker badges already have.
2026-10-24 14:02:00 -07:00
Nariman Jelveh 1d8a7cb206 fix(dashboard): show the Files breadcrumb separators in dark mode
The chevrons between breadcrumbs are a black stroke at 20% opacity,
which vanishes on the dark header: a nested folder read "Home Documents
Work" with nothing between the names. In dark mode they are now
inverted, like the toolbar's icons.
2026-10-24 14:00:06 -07:00
Nariman Jelveh c89c191ab6 fix(dashboard): style the Usage table's Show less button in dark mode
The button's dark variant was written for an `html.dark-mode` class that
nothing sets, so in dark mode it kept its light look: grey text at 3.4:1
on the dark card, a light border, and a light chip on hover. Its rules
move under prefers-color-scheme, as the table's fade overlay already
did.
2026-10-24 13:59:49 -07:00
Nariman Jelveh 562e3ab9ac fix(dashboard): write Files and Properties dates in the dashboard's language
The Files tab's Modified column and Properties' Modified, Created and
version times came from timeago, which has no language but English
registered, so a German dashboard read "3 hours ago" beside "Geändert".
They now use the same localized formatter as the notification panel
("vor 3 Stunden"), which also gives a date past a week rather than
"3 weeks ago". Its formatters are now made once per language, since a
listing formats one date per row.
2026-10-24 13:59:13 -07:00
Nariman Jelveh 9af451a5d0 refactor(gui): move the localized time formatting to i18n/formatTime.js
formatRelativeTime and formatAbsoluteTime lived in the notification
center's module; Files and Properties are about to use them too.
2026-10-24 13:57:43 -07:00
Nariman Jelveh 853beb0b1b fix(dashboard): don't pull focus back after a share change saves
When a change in the share dialog finished saving (a level change, Add
to all, a removal, a share), focus was put back on that row's control or
the add field, wherever the user had gone meanwhile. Someone who changed
a person's level and then started typing a name had the next keystrokes
land on that person's level select, where a matching letter picked
another level and granted it. Focus now goes back only when the save
took it away; focus the user moved elsewhere stays there.
2026-10-24 13:57:06 -07:00
Nariman Jelveh acb306a82d fix(dashboard): say the access list couldn't load, not that sharing failed
When the share dialog couldn't read who has access (a network error,
which carries no message of its own), it said "Could not share this
item." though nothing had been shared or attempted. It now says "Could
not load who has access."
2026-10-24 13:55:49 -07:00
Nariman Jelveh 1537846e08 fix(dashboard): close the share dialog after leaving a share
Someone the owner let share could remove their own row in the share
dialog to leave. The removal went through, but the dialog then reloaded
its access list with the access just given up: the server refused it,
the success line gave way to "Subject does not exist" over an empty
list, and the item stayed in the Shared view until a reload. Leaving
now closes the dialog and refreshes the folder on screen, which drops
the item, as "Remove from Shared" does.
2026-10-24 13:55:08 -07:00
Nariman Jelveh b99b5c1bad fix(dashboard): show an email change confirmed in another tab
Confirming a change of email from its link sets the new address and
marks it confirmed, but sends no socket event, unlike confirming a code.
Back in the dashboard the user was re-read on returning to the browser
tab, yet nothing redrew: Account kept the old address under the
username, Create token stayed disabled and Security had no 2FA card
until a reload. Account and Security now redraw after that re-read when
the address or its confirmation changed.
2026-10-24 13:52:49 -07:00
Nariman Jelveh 92a5eae92d fix(dashboard): announce that a new API token is shown only once
After a token was created, focus moved to Done and a screen reader read
"Done, button" and nothing else: the done state arrives already filled
in a status region, which isn't announced, and Done is described by the
message only for plain-text results. The token dialog's warning ("Copy
this token now — it won't be shown again…") now describes Done, so it
is read out when focus lands there.
2026-10-24 13:51:40 -07:00
Nariman Jelveh a90cbd29b3 fix(dashboard): say why Create token is off where everyone can read it
When the account's email isn't confirmed, Create token is disabled and
the reason ("Verify your email address to create an API token.") lived
only in a hover tooltip: a disabled button isn't reachable by Tab, a
phone has no hover, and screen readers don't read a plain span's title,
so those users met a dead button with no explanation. The reason now
replaces the card's description while the button is off. The tooltip,
which encoded the already-encoded text again, now uses it as is.
2026-10-24 13:50:47 -07:00
Nariman Jelveh 93d1a60f33 fix(dashboard): write credit and dollar amounts in the dashboard's language
Credits were always formatted with English separators and dollar amounts
with "$" and English separators, so in German Home read "5,000
monatliche Credits", which a German reader takes as five, and Usage
showed "2.5" and "$1,234.50". Both now use the dashboard's language
("5.000", "2,5", "1.234,50 $"); English is unchanged.
2026-10-24 13:49:52 -07:00
Nariman Jelveh a24980c44e fix(dashboard): write Home's trial dates in the dashboard's language
The free-trial note on Home formats its dates with the browser's
language, not the one chosen in Puter, so with Puter in French and the
browser in English it read "…le Mar 5", and the other way round "First
charge on 5 mars". The dates now follow the dashboard's language. The
notification panel's mapping of Puter's language codes to Intl tags
(Brazilian Portuguese, Ukrainian, Traditional Chinese) moves to
i18n/intlLocale.js so both use it.
2026-10-24 13:48:44 -07:00
Nariman Jelveh a6f0d7c091 fix(dashboard): show placeholders in the Usage headers until they load
The Storage and Resources headers started out as " ·  used of " with the
numbers left blank, and stayed that way when reading the storage space
or the month's usage failed. They now start at "--% · -- used of --",
as Home's cards start at "--", and keep that if the read fails.
2026-10-24 13:47:41 -07:00
Nariman Jelveh 0bb656b582 fix(dashboard): don't let a slow picture removal undo a newer picture
Saving a profile picture is ordered so an older save can't put its
picture back over a newer one, but Remove picture stood outside that
order. Clicking Remove on a slow connection and then saving a new photo
before the removal answered left the default avatar on screen, and
Remove hidden, while the account had the new photo. A removal now
yields to a picture saved after it.
2026-10-24 13:46:32 -07:00
Nariman Jelveh 05b455fa09 fix(dashboard): don't offer Change email to a team seat
A team seat's Account tab offered Change email, and after the user
filled in the new address and their password the server refused it:
"Your team set this address. Ask a team admin to change it." The Email
card now shows that note in place of the button, as the Username card
already does for seats.
2026-10-24 13:44:29 -07:00
Nariman Jelveh d95cfd102c fix(dashboard): don't offer Change username to a temporary account
A temporary account's Account tab offered Change username, whose dialog
asks for an account password the account doesn't have: left empty it
said "All fields are required", and anything typed got a 403 from the
server, which refuses temporary accounts. The button is now left out
until the account is saved, as the Password card already is; saving it
re-renders the tab with the button.
2026-10-24 13:43:56 -07:00
Nariman Jelveh 10372d5824 fix(dashboard): keep keyboard focus on an Apps tile across a re-render
The grid is rebuilt wholesale whenever its data or size changes: the
refresh that follows opening the tab, a resize or zoom that changes how
many columns fit, an uninstall that fails. A keyboard user who had
arrowed onto a tile lost focus to <body>, so Enter did nothing and the
next arrow started over from the first tile. Focus now moves to the
same app's or folder's new tile.
2026-10-24 13:43:18 -07:00
Nariman Jelveh 1f0a199b34 fix(dashboard): tell screen readers when an Apps search finds nothing
Typing in the Apps search re-renders the grid silently, and the "No apps
match your search" line is drawn into it fresh each time, which screen
readers don't announce. A search that matched nothing gave no feedback
at all. A persistent status region now says so.
2026-10-24 13:42:36 -07:00
Nariman Jelveh de6b753a0b fix(dashboard): make the Apps search's clear control a button
The "×" that clears the Apps search was a bare SVG with a click handler:
Tab couldn't reach it, screen readers had nothing to announce, and on a
phone it was a 16px target inside the field. It is now a labelled button
after the field in the Tab order, with a focus ring, and on touch it
hit-tests at 44x44 while keeping its look.
2026-10-24 13:41:33 -07:00
Nariman Jelveh 96d10bfe3f fix(dashboard): open an app from the add-app dialog at once during a search
"Browse the App Center" and "Build with AI" play an intro from the
app's tile, and wait up to 3s for that tile to appear. With a search
that leaves the app out (the case the "+" tile stays for: a search that
found nothing), the tile never appears, so the app opened only after the
full 3s. The intro now gives up as soon as the search is what hides the
tile, and the app opens straight away.
2026-10-24 13:39:34 -07:00
Nariman Jelveh 12cf5b3a31 fix(dashboard): don't blank the Apps grid when it re-flows on resize
When a resize changed how many columns or rows fit (resizing the window,
collapsing the sidebar, zooming, an Android keyboard), the grid was
rebuilt behind the first load's fade, which waits on page one's icons.
With an icon host that was slow or unreachable the grid went blank for
up to 1.5s each time. A grid already on screen now re-flows at once;
one still loading keeps its fade.
2026-10-24 13:37:26 -07:00
Nariman Jelveh 60b9fc0366 fix(dashboard): act once on a held Enter or Space in Apps
Holding Enter on an Apps folder opened it, moved focus to its first app,
and the key's repeats then launched that app; held on the "+" tile, the
repeats picked "Browse the App Center" in the dialog it opened, and held
in the search box they launched the first match again and again. Key
repeats now do nothing on a tile, in the search box or on the add-app
dialog's options.
2026-10-24 13:36:26 -07:00
Nariman Jelveh 4c47fb048f fix(dashboard): keep arrow keys in the open phone drawer on Apps
With the drawer open over the Apps tab, an arrow key moved focus out of
it to the first app tile behind the scrim, and the next Tab then walked
the page behind it. The open drawer now keeps the arrow keys, as the
notifications panel and dialogs already do.
2026-10-24 13:34:19 -07:00
Nariman Jelveh 1913483f1c fix(dashboard): treat Ctrl+click on a Mac as a right-click in Files
On a Mac, Ctrl+click opens the context menu, but the Files rows read it
as a Ctrl-toggle first: Ctrl+clicking one of three selected files
deselected it and the menu then acted on that file alone, and on an
unselected file it was added to the selection. On a Mac it now leaves
the selection alone, as a right-click does; Cmd+click still toggles.
2026-10-24 13:33:33 -07:00
Nariman Jelveh b100f2f329 fix(dashboard): open one touch menu per long-press
A long-press on Android fires both contextmenu and taphold. The Files
rows already ignore the second, but a breadcrumb, a sidebar folder,
Home's Shared and Trash rows and the listing's empty space each opened a
second sheet over the first; picking an item or tapping the backdrop
then left the other one up. The touch menu now doesn't open while
another is on screen.
2026-10-24 13:28:09 -07:00
Nariman Jelveh 64e1a93a1a fix(dashboard): step through Files names on a repeated letter
Type-to-select meant to move past the selected row to the next name with
the same start, but returned early whenever the selected row matched, so
the stepping never ran: with readme, report-a and report-b, pressing "r"
again stayed on readme, and "rr" matched nothing. A repeated letter now
steps through the names it starts, wrapping around, as file managers do;
a longer prefix still stays on a selected row it matches.
2026-10-24 13:26:41 -07:00
Nariman Jelveh ef2547e520 fix(dashboard): don't drop New Folder during a Files refresh
New Folder did nothing while the folder on screen was refreshing (after
an upload or paste, or on returning to the browser tab). A refresh keeps
the rows up with no spinner, so the click just looked ignored. It now
takes over the refresh and makes the folder once the listing is in;
during a navigation to another folder it is still dropped, as before.
2026-10-24 13:25:53 -07:00
Nariman Jelveh cddcc6d39f fix(dashboard): let a focused folder or crumb take the Menu key
The Files tab answered the Menu key and Shift+F10 with the selection's
menu wherever focus was. With a file selected and a sidebar folder, a
breadcrumb or Home's Shared row focused, the key opened the file's menu;
with nothing selected it cancelled the browser's contextmenu, so the
focused element opened none. A focused control now gets the browser's
contextmenu event, and its own menu, as with a right-click.
2026-10-24 13:25:06 -07:00
Nariman Jelveh 6ec1c0aaf1 fix(dashboard): don't add a Files row to a folder opened meanwhile
A file added from elsewhere (an upload, another tab, an app) is checked
against the folder on screen and then drawn once its icon resolves. A web
link's or app's icon is read from the file, and opening another folder
during that read put the row into the new folder's listing, where it
stayed and opened the file from the old one. The checks now run again
once the icon is in.
2026-10-24 13:22:11 -07:00
Nariman Jelveh 97d25bb054 fix(gui): read the Trash's state fresh when updating its icon
refresh_trash_state stats the Trash to pick the empty or full icon, and
its comment calls for a strong read, but it asked for an eventual one.
Once a listing of Home had cached the Trash entry (without `is_empty`),
the stat came back without it: permanently deleting the last items from
Trash in Files left the full-Trash icon up, and other tabs were told the
same. The stat is now strong, as intended.
2026-10-24 13:21:15 -07:00
Nariman Jelveh 86304b1fbc fix(dashboard): show delete and log-out confirmations without entities
The Files tab's "delete these items permanently?" confirmation and the
dashboard's "close your apps and log out?" one passed HTML-encoded i18n
strings to UIAlert, which encodes its message and button labels again.
In Igbo both questions read "Ị ji n&#039;aka …". These alerts, and the
Files tab's name-conflict and folder-depth ones, now pass plain strings.
2026-10-24 13:19:01 -07:00
Nariman Jelveh 6df270a133 fix(dashboard): title a trashed folder's Properties with its name
Properties from the breadcrumb of a folder opened inside Trash was
titled with the folder's uid, the last segment of its trashed path,
while the crumb itself showed the original name. It now uses that name.
2026-10-24 13:17:53 -07:00
Nariman Jelveh 08c5ed405e fix(dashboard): give Files footer sizes in the rows' format
The footer used the desktop's byte formatter while the rows use their
own, so a 5-byte file read "5 B" in its row and "5.00 Bytes" below it,
1.5 KB read "1.50 KB", and a folder of folders read "0 Byte". The footer
now formats sizes as the rows do.
2026-10-24 13:16:25 -07:00
Nariman Jelveh e2545d1b9c fix(dashboard): show the phone selection bar when Select starts with a selection
Uploaded or pasted items arrive selected. Tapping Select on a phone then
turned select mode on with them selected but no floating action bar,
until a row was toggled. Entering select mode now brings the bar up for
the selection it starts with.
2026-10-24 13:16:06 -07:00
Nariman Jelveh 6acf64f31e fix(dashboard): keep the phone selection bar after cancelling a delete
In select mode on a phone, tapping Delete permanently and then Cancel
left every item selected but hid the floating action bar, the only way
to act on the selection, until the selection next changed. The same
happened when nothing in the selection could be moved to Trash. The bar
now goes away only once something is actually deleted or trashed.
2026-10-24 13:15:51 -07:00
Nariman Jelveh f7884986f4 fix(dashboard): don't drop a Files refresh asked for during another
A refresh of the folder on screen was ignored while another refresh of
it was loading. Drop two files: when the second finished during the
first's refresh, its own refresh was dropped, the earlier listing (read
before the file existed) was drawn, and the file's item.added was
ignored meanwhile too, so it stayed missing until a manual refresh.
Paste, Refresh and returning to the browser tab could lose items the
same way. A refresh now takes over from one in flight, as opening
another folder already did; a navigation in progress still holds.
2026-10-24 13:14:37 -07:00
Nariman Jelveh 62743d0a2e fix(dashboard): don't drop a Files selection onto one of its own folders
With folders A and B selected, dragging A onto B moved A into B and then
stopped on "Cannot move a directory into itself" for B. Hovering B also
sprang it open mid-drag. A folder row that is part of the drag is no
longer a drop target, so the drag reverts as it does over any other
non-target. A drop elsewhere (a sidebar folder, say) also leaves out the
target folder itself and any folder that holds it.
2026-10-24 13:13:26 -07:00
Nariman Jelveh 132a60d53e fix(dashboard): let Undo take back a Files cut and paste
Pasting cut items in the Files tab moves them itself rather than through
the desktop's move_items, and recorded nothing for Undo. Undo after a
paste reversed the action before it instead, which deletes an upload for
good. The paste now records the move as move_items does, so Undo puts
the items back where they were cut from.
2026-10-24 13:10:25 -07:00
Nariman Jelveh e8c3e38027 fix(dashboard): let Undo take back a new Files folder or file
The Files tab creates folders and New › files itself rather than through
the desktop's helpers, and never recorded them for Undo. Undo then
reversed whatever came before: upload notes.txt, create a folder, press
Ctrl+Z, and notes.txt was deleted for good (undoing an upload deletes it
outright, not to Trash) while the new folder stayed. Both now record
themselves, so Undo removes what was just created, as on the desktop.
2026-10-24 13:09:06 -07:00
Nariman Jelveh 79e1b3377e fix(dashboard): keep every unread notification in the panel
The socket replays up to 200 unread notifications on connect, but the
panel's listing held only the 30 newest, and the first refresh after that
dropped the rest: 45 unread read "45" until the panel was opened, then
"30", and "Mark all as read" left the other 15 unread on the server for
the next visit. The panel now keeps unread notifications past the newest
30, up to the 200 a listing returns.
2026-10-24 13:06:11 -07:00
Nariman Jelveh ea51080f06 fix(dashboard): don't let deploy results crowd notifications out of the panel
The panel listed the 30 newest notification rows and then dropped the
ones it never shows, a worker's deploy results among them. A developer
with 30 deploys since their last share saw "No notifications" and no
badge while the share sat behind them. The listing now asks for up to
200 rows and keeps the newest 30 the panel shows.
2026-10-24 13:04:50 -07:00
Nariman Jelveh 5a0c2db90f fix(dashboard): count "yesterday" and "N days ago" in calendar days
Notification times rounded the elapsed hours to whole days, so at 23:30
something from 00:45 the same day read "yesterday", and on Wednesday
morning something from Monday night (34 hours earlier) read "yesterday"
too. Days are now counted from midnight to midnight in the reader's time
zone; anything from today stays in hours.
2026-10-24 13:01:04 -07:00
Nariman Jelveh 83fd00d16d fix(dashboard): show notification times in Brazilian Portuguese, Ukrainian and Traditional Chinese
The notification panel and toasts handed Puter's language code straight to
Intl, and a few of those codes aren't BCP 47 tags. Brazilian Portuguese
(`br`) is Breton to Intl, so times read "5 munut zo"; Ukrainian (`ua`)
fell back to the browser's default language; and `zhtw` threw, so Taiwan
users got English. Those codes are now mapped to pt-BR, uk and zh-TW
before formatting.
2026-10-24 13:01:00 -07:00
1320 changed files with 39190 additions and 169111 deletions

No files matched your search

@@ -47,13 +47,3 @@ jobs:
PUTER_TEST_AI_MOONSHOT_API_KEY: ${{ secrets.PUTER_TEST_AI_MOONSHOT_API_KEY }}
PUTER_TEST_AI_ZAI_API_KEY: ${{ secrets.PUTER_TEST_AI_ZAI_API_KEY }}
PUTER_TEST_AI_ELEVENLABS_API_KEY: ${{ secrets.PUTER_TEST_AI_ELEVENLABS_API_KEY }}
# Realtime conversations through the SDK against live OpenAI. Builds
# the SDK bundle these tests load itself, so it runs even when the
# step above fails; the tests skip themselves without the key.
- name: Run live realtime conversation tests
if: ${{ !cancelled() }}
run: npm run setupExtensions && (cd src/puter-js && npm run build) && npx vitest run --config src/puter-js/tests/api/vitest.config.ts src/puter-js/tests/api/realtime/realtimeLive.test.ts
env:
CI: 'true'
PUTER_TEST_AI_OPENAI_API_KEY: ${{ secrets.PUTER_TEST_AI_OPENAI_API_KEY }}
-48
View File
@@ -1,48 +0,0 @@
name: Notify HeyPuter (dev)
on:
workflow_dispatch:
inputs:
puter_ref:
description: "puter branch, tag or SHA"
type: string
default: "main"
permissions:
contents: read
jobs:
authorize:
if: github.repository == 'HeyPuter/puter'
runs-on: ubuntu-latest
steps:
- name: Require maintain or admin
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.triggering_actor }}
run: |
ROLE=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$ACTOR/permission" --jq .role_name)
if [ "$ROLE" != "maintain" ] && [ "$ROLE" != "admin" ]; then
echo "::error::$ACTOR has role '$ROLE'; maintain or admin required"
exit 1
fi
notify:
needs: authorize
runs-on: ubuntu-latest
# Holds HEYPUTER_DISPATCH_TOKEN; limited to main so branch copies of this file can't read it.
environment: heyputer-dispatch
steps:
- name: Trigger heyputer dev deploy
env:
PUTER_REF: ${{ inputs.puter_ref }}
TOKEN: ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}
run: |
jq -nc --arg ref "$PUTER_REF" \
'{event_type: "puter-dev-requested", client_payload: {puter_ref: $ref}}' |
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
-d @-
echo "Requested \`$PUTER_REF\`" >> "$GITHUB_STEP_SUMMARY"
+1 -2
View File
@@ -8,7 +8,6 @@ on:
jobs:
notify:
runs-on: ubuntu-latest
environment: heyputer-dispatch
steps:
- name: Trigger heyputer build
run: |
@@ -16,4 +15,4 @@ jobs:
-H "Authorization: token ${{ secrets.HEYPUTER_DISPATCH_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/HeyPuter/heyputer/dispatches \
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
-d '{"event_type":"puter-main-updated","client_payload":{"puter_ref":"main"}}'
+3 -11
View File
@@ -118,13 +118,9 @@ jobs:
fail-fast: false
matrix:
include:
# The base branch lives in this repo; for a fork PR, the head
# repo's copy of it can be arbitrarily stale.
- ref: ${{ github.base_ref }}
repository: ${{ github.repository }}
artifact: base
- ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
artifact: pr
steps:
@@ -133,7 +129,7 @@ jobs:
uses: actions/checkout@v7
with:
ref: ${{ matrix.ref }}
repository: ${{ matrix.repository }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Set up Node.js
if: needs.changes.outputs.puterjs == 'true'
@@ -176,10 +172,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
# Missing when the PR leg failed; `coverage (pr)` already reports that.
- name: Download PR coverage
id: pr-coverage
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: puterjs-coverage-pr
@@ -189,7 +182,6 @@ jobs:
# base run) — then report without the comparison column.
- name: Download base coverage
id: base-coverage
if: steps.pr-coverage.outcome == 'success'
continue-on-error: true
uses: actions/download-artifact@v8
with:
@@ -197,7 +189,7 @@ jobs:
path: coverage-base
- name: Report coverage on PR
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome == 'success'
if: steps.base-coverage.outcome == 'success'
uses: davelosert/vitest-coverage-report-action@v2
with:
name: puter.js SDK
@@ -206,7 +198,7 @@ jobs:
json-summary-compare-path: coverage-base/coverage-summary.json
- name: Report coverage on PR (no base comparison)
if: steps.pr-coverage.outcome == 'success' && steps.base-coverage.outcome != 'success'
if: steps.base-coverage.outcome != 'success'
uses: davelosert/vitest-coverage-report-action@v2
with:
name: puter.js SDK
+2 -33
View File
@@ -159,42 +159,13 @@ The reference has two kinds of page:
An overview page answers "what is this API and is it what I need?", and a function page answers "how exactly does this call behave?". A change to one function usually only touches that function's page, so a new `kv.list()` option goes in `KV/list.md` without a matching update to `KV.md`. Don't copy a function's options, limits, or edge cases onto the overview page for completeness. Where a detail goes is a judgment call, not a hard rule: update the overview when a change affects how someone would describe the API as a whole.
When you add a page, add it to [src/docs/src/sidebar.js](src/docs/src/sidebar.js) under its area, or it won't show up in the navigation.
**Sidebar order.** Order the API areas in the sidebar by how often developers use them, most common first. Overview stays first and Objects stays last. Inside an area, order the pages by use too, and keep pages that make up one feature next to each other, such as `share()`, `unshare()`, `listShared()`, and `listSharedByMe()`. A group goes where its most-used page would go. A helper that only supports another function, such as `listModels()` for `chat()`, goes by its own use. Put create, list, get, update, and delete in that order, and constants such as `MAX_KEY_SIZE` last. The function list on the area's overview page uses the sidebar's order. When you add a runnable example, add its HTML to `src/docs/src/playground/examples/<slug>.html`, register the slug in [src/docs/src/examples.js](src/docs/src/examples.js), and link the code block to it by putting the slug after the language with a semicolon (` ```html;kv-set `).
### Function page layout
A function page opens with one sentence that says what the function does, in its own paragraph, such as "Given a prompt, generate an image using AI." Put any more detail in the paragraphs after it.
After the intro, a function page has these `##` sections, in this order:
1. Syntax
2. Parameters
3. Return value
4. Errors, if the function has any
5. Sections for specific topics, if the page needs them, with `## Model-specific features` last
6. Examples
[chat.md](src/docs/src/AI/chat.md) follows this layout.
- **Options as a table.** Document an options object as a table with Option, Type, and Description columns. Give each option its own row, even when two options go together, such as `width` and `height`. Order the rows by how often developers use them, most common first, without group rows. In chat.md that's the core options, then reasoning, tools, response handling, request control, and output.
- **Common options in the table, model-specific behavior in its own section.** A page whose function works with many models or providers, such as [chat.md](src/docs/src/AI/chat.md) and [txt2img.md](src/docs/src/AI/txt2img.md), splits its options this way:
- **The options table** under Parameters lists the options that work with every model, and describes each one the way it works for every model.
- **`## Model-specific features`**, the last section before Examples, covers everything else. It has a `###` for each model family or provider, named after it, such as "OpenAI models" or "Cloudflare models". Under it go the options only some models accept, and the common options a model handles differently, such as a narrower range, other allowed values, or an option it ignores.
- **Write there only what's different.** A `###` lists an option only when that model or provider adds it or handles it differently. An option that works the same everywhere stays out, even if the provider supports it, and a model or provider with nothing different gets no `###`.
- **Model-specific features is the only place for model-specific details.** Every limit, format, allowed value, default, price, or quirk that applies to some models goes there, in that model's or provider's `###`, and nowhere else. The options table and topic sections (such as Image inputs or Video input) describe what's true for every model and link to Model-specific features for the rest. Don't add a table or sentence there that compares providers or repeats a model's limits. Each copy is another source of truth to keep in sync, and copies drift when a model changes.
- **Link the two.** Directly under the options table, put `Some models accept additional options or handle these options differently. See [Model-specific features](#model-specific-features).` A row whose option behaves differently on some models can also end with `See [Model-specific features](#model-specific-features).`
- **A long topic gets its own `##` section.** When explaining an option or feature takes more than a sentence or two, such as function calling or output dimensions, give it a section between Errors and Examples and link to it from the options table. This keeps the reference sections short.
- **Usage patterns are examples.** A way of using the API, such as stopping a stream, goes in a runnable example that the relevant section links to ("See the [stop a stream example](/playground/ai-chat-stop-stream/)."), not in a section of its own.
- **Each example has a `###` heading** under `## Examples`, so every example shows in the table of contents.
When you add a page, add it to [src/docs/src/sidebar.js](src/docs/src/sidebar.js) under its area, or it won't show up in the navigation. When you add a runnable example, add its HTML to `src/docs/src/playground/examples/<slug>.html`, register the slug in [src/docs/src/examples.js](src/docs/src/examples.js), and link the code block to it by putting the slug after the language with a semicolon (` ```html;kv-set `).
### Writing docs
These apply to reference pages and recipes alike.
- **Document the developer-facing side only.** The reader is building on top of puter.js, so cover what affects their code: signatures, defaults, return shapes, errors, limits, and behavior they can observe. Leave out internal workings (which service handles the call, storage layout, caching, retries) unless they change what the developer has to do. [rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) is the exception: it publishes every limit, including timeouts and caps that come from how the backend works.
- **Document Puter as it runs on puter.com.** The docs are for developers building on production Puter. Leave out behavior that only happens on a self-hosted or differently configured server, such as a provider that isn't set up there.
- **Describe the current state.** Docs aren't a changelog. Write how the API behaves now, without "now", "no longer", "existing code is unaffected", or other wording that compares it to an older version.
- **Verify before you document.** Run the calls you describe against a running Puter instead of inferring behavior from the source.
- **Write plainly.** Use the literal words a developer would say out loud, with contractions and everyday words. No idioms or figurative verbs ("walk away from something shared with you" → "remove your own access", "nothing to page through" → "without pagination"). No formal register ("present only while more pages remain" → "includes a `cursor` if there are more pages to load"). Prefer dev terms such as atomic, concurrent, and insertion order.
@@ -202,7 +173,6 @@ These apply to reference pages and recipes alike.
- **Describe behavior directly.** No quoted inner-voice phrases. Write "`isNew` is `true` when the person did not have access before", not "this tells 'I just gave this to someone' from 'they already had it'".
- **Keep references easy to follow.** Name what a list or code block belongs to in the sentence directly above it. Name a thing once and keep that name. Introduce an actor as "a user" before giving them a role: "A user can create a team. The user who creates the team becomes its owner."
- **Sentence mechanics.** Never start a sentence with inline code: write "To store data, use the [`set()`](/KV/set/) method." Link every inline mention of an API to its page, every time. No em dashes, and no colons or semicolons mid-sentence; a colon that introduces a code block is fine.
- **No `##` or `###` headings inside tabs.** The table of contents lists every `##` and `###` heading on the page, including the ones in a hidden tab (an `example-content` block switched by the `example-group` pills), so its links lead to content the reader can't see. Put the heading above the tabs, or use `####` inside a tab, which the table of contents skips.
- **Show the correct way.** No "don't" and no wrong-way-first examples, in recipes and playground examples alike. Mention a wrong approach only when developers already hit it in practice, and state how it fails as a fact: "`add()` reads a plain object as a path map, so the call rejects with …".
### Recipes
@@ -214,7 +184,6 @@ Recipes are tested, copy-pasteable patterns for a task, one layer above the per-
- **Description.** Written for someone who has never heard of Puter. Start with "Learn how to", put the action first, and say what the reader's app gains: "Learn how to add file sharing to your app with Puter.js, so users can share files in their Puter account with other people." No API names, no mechanism, no list of example cases, and no "not just …" contrast.
- **Intro.** Explain the situation and why you'd do this, tied to something the reader already knows ("`puter.kv` is your database in Puter.js"). No "Use this when" bullet list.
- **Structure.** Get to the first working call as early as possible, then add validation, rate limits, and sign-in after it. Explain a Puter concept the first time it appears, such as what `me.puter` is or that `puter.workers.exec()` opens the Puter sign-in window. Split recipes by what the reader needs, not by technique, and give a family of recipes a shared tag.
- **Reading order.** A recipe reads top to bottom. Each code block uses only functions and variables from blocks above it, so the reader never has to search further down the page to understand it. Start with a small version that works, and have each section add one case. When a section changes a function from earlier, show the whole new version of that function there. If the code is built up over several sections, end with a `## Full Code` block that has all of it.
- **Link to the reference for full option lists.** Mention only the options the recipe uses, then link to the function page for the rest ("All the sort options are listed on [`puter.fs.readdir()`](/FS/readdir/)."). A copied list of options, rules, or scopes goes stale when the reference changes.
- **Limits.** Mention a limit only when it's the reason for a pattern the recipe uses, such as one wildcard subscription instead of one per key, and link to [rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) or the function page for the number. Leave out limits that code following the recipe never reaches.
- **Code.** Platform-neutral: just the code, with top-level `await`. No `<script>` or `<html>` wrapper, no IIFE.
@@ -231,6 +200,6 @@ Recipes are tested, copy-pasteable patterns for a task, one layer above the per-
- **Reuse existing UI primitives before writing new ones.** Windows and dialogs are `UIWindow*` functions in [src/gui/src/UI/](src/gui/src/UI/); generic pieces already exist (`UIAlert`, `UIPrompt`, `UIContextMenu`, `UINotification`, `UIPopover`, widgets in [UI/Components/](src/gui/src/UI/Components/)). A new window should read like its neighbors: an async function taking an options object, composing an HTML string, wiring behavior with jQuery, delegating to `UIWindow(...)`.
- **Match the visual language.** Use existing CSS classes (`button`, `button-primary`, window chrome, form styles) and copy the layout patterns of neighboring windows; new styles go in [src/gui/src/css/](src/gui/src/css/) following existing conventions. Verify anything positional (menus, overlays, z-index) on both desktop and mobile viewports.
- **i18n every user-facing string.** No hardcoded UI text — use `i18n('key')` and add the key to [src/gui/src/i18n/translations/en.js](src/gui/src/i18n/translations/en.js).
- **i18n every user-facing string.** No hardcoded UI text — use `i18n('key')` and add the key to [src/gui/src/i18n/translations/en.js](src/gui/src/i18n/translations/en.js). Run `npm run check-translations` before opening the PR.
- **Shared logic goes in helpers/services.** Reusable non-UI logic belongs in [src/gui/src/helpers/](src/gui/src/helpers/) or [src/gui/src/services/](src/gui/src/services/), not copy-pasted between windows.
- **Tests.** Vitest is wired for the GUI (`src/**/*.test.js`; see [appOrder.test.js](src/gui/src/UI/Dashboard/appOrder.test.js) for the shape). Extract pure logic into functions and test those. Desktop behavior driven through puter.js (`puter.ui.*`) is covered by the Playwright harness in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/) — add a spec there when you change how the desktop renders SDK-driven UI.
+1 -1
View File
@@ -40,7 +40,7 @@ The following have already been reviewed and determined **not to be vulnerabilit
- **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
- **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
- **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (`/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (taskbar items, `/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
- **Worker hot-reload driven by a share recipient.** Sharing a worker's source file with write access is how you let a collaborator change that worker's code and have it deploy live. The redeploy running as the file owner, with the owner's worker credentials, is the intended behavior of that grant — the owner chose to hand out write access to executable code. If you do not want that, do not share the source file with write access.
- **Re-claiming a site or worker name its owner deleted.** A hosted site or worker name that its owner deletes is treated as abandoned by that owner. If one of their apps still points at it, whoever claims the name next serves that app's `index_url` and receives what the app is launched with, and a new app on the same origin may share the old one's identity. Guarding app identities against being recycled this way is ongoing hardening, not a security boundary, so new ways to re-claim an abandoned name, or to inherit an app's tokens or data through one, are not eligible.
- **Share-mode downgrades that do not cascade.** Demoting a delegate does not retroactively revoke the grants that delegate already issued downstream. Shares are independent grants once issued, not a live-evaluated chain. Revoking the downstream grant is a separate, supported action.
+1 -1
View File
@@ -1 +1 @@
FOLLOW @AGENTS.md
FOLLOW ./AGENTS.md
+1 -7
View File
@@ -73,12 +73,6 @@ COPY --from=build --chown=node:node /opt/puter .
RUN mkdir -p /etc/puter /var/puter && \
chown -R node:node /etc/puter /var/puter
# Fix outdated HOME_URL in /etc/os-release (see issue #4329)
# The base image may contain a reference to the old phoenix repository.
RUN if [ -f /etc/os-release ]; then \
sed -i 's|HOME_URL="https://github.com/HeyPuter/phoenix"|HOME_URL="https://github.com/HeyPuter/puter"|' /etc/os-release; \
fi
# Self-hosters mount their override at this exact path. The v2 loader
# deep-merges it over config.default.json (see backend/index.ts).
ENV PUTER_CONFIG_PATH=/etc/puter/config.json
@@ -93,4 +87,4 @@ USER node
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
CMD ["node", "-r", "./dist/src/backend/telemetry.js", "./dist/src/backend/index.js"]
CMD ["node", "-r", "./dist/src/backend/telemetry.js", "./dist/src/backend/index.js"]
-5
View File
@@ -350,9 +350,6 @@
// not, a row past this is swept. Off by default; any value above 0 arms
// the sweep.
// "notificationRetentionDays": 14,
//
// Ms a live notification waits for open tabs to acknowledge it.
// "notificationAckTimeoutMs": 5000,
// ── Alarms / alerting ───────────────────────────────────────────────
// Where system alarms go. Severity is the routing decision — each
@@ -436,8 +433,6 @@
"mistral": { "apiKey": "" },
"xai": { "apiKey": "" },
"moonshot": { "apiKey": "" },
// MiniMax. One key powers chat and video generation (H3, Hailuo);
// video calls go to the same host, beside the chat API's `/v1`.
"minimax": {
"apiKey": "",
"apiBaseUrl": "https://api.minimax.io/v1"
+1 -1
View File
@@ -47,7 +47,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
Provider sign-in requires the account's authenticator or a recovery code when 2FA is enabled, including signup callbacks that resolve an existing account. The five-minute challenge uses the same single-use `/login/otp` and `/login/recovery-code` verification as password sign-in. The GUI carries it in a URL fragment and removes it before initialization. A session and successful popup-return proof are issued only after verification; cancelling retains the popup's signed retry context without marking it authenticated.
Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and either the current six-digit authenticator `code` or an unused recovery code in `code`. Recovery codes are consumed atomically. Missing or incorrect codes leave the secret and remaining recovery codes intact. A team's requirement for 2FA still prevents disabling it.
Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and the current six-digit authenticator `code`. Missing, malformed, or incorrect codes leave the secret and recovery codes intact. A team's requirement for 2FA still prevents disabling it.
Email changes through `/user-protected/change-email` require the password or OIDC revalidation cookie, plus a six-digit `code` from the authenticator when 2FA is enabled. Missing or malformed codes return `400 bad_request`; incorrect codes return `401 code_mismatch`. Both include `factor: 'otp'`. Confirmation links are bound to that verified request. A 2FA-enabled account must request a new link if its existing link was issued without second-factor verification.
-365
View File
@@ -1,365 +0,0 @@
# Embedded app browser
`GET /embed/apps` on the deployment's main GUI origin renders the same app browser
as the dashboard's Apps tab, without the desktop or dashboard navigation.
It supports search, paging, saved folders, and saved ordering. It uses the same
installed-app and recommendation data as the dashboard, including the user's
removed recommendations. Editing, adding, reordering, renaming, and uninstalling
are disabled. Selecting an app opens it in a new browser tab at its own URL, the
same way the dashboard does, and the host page stays open. The app's token isn't
put in the URL: the app's SDK asks the embed for it once the tab loads, so the
embed must still be open then. If the iframe is removed first, the app falls back
to its normal sign-in. Apps that need the desktop (built-in and godmode apps,
private apps the user can't open, or a failed token request) open `/app/<name>`
on the GUI origin, cut off from the embed. Website shortcuts open their external
destination with no opener.
The embed follows the OS's light or dark setting. A host with a fixed theme adds
`theme=dark` or `theme=light` to the fragment (`#user=<uuid>&theme=dark`), since
Chromium doesn't pass an iframe's `color-scheme` on to the page inside it.
Apps opened from the embed carry `utm_source=puter.com&utm_medium=referral&utm_campaign=app_browser_embed`;
the desktop and dashboard send `utm_campaign=desktop` and `utm_campaign=dashboard`.
## Session and framing
The embed shows the GUI session only to the user the host page is signed in as.
The host names that user's `uuid` (from `puter.auth.getUser()`) in the iframe
URL's fragment, as `/embed/apps#user=<uuid>`. The embed reads the session, asks
`/whoami` which user it belongs to, and shows the signed-out view unless the two
match. So a host that names no user, a malformed one, or a user other than the
session's, gets the signed-out view, and so does a session the API refuses. A
failed `/whoami` check shows the error state. The fragment never reaches the
server and can only narrow what the embed shows: naming the session's own user
gives the same view as a direct visit to the GUI. Changing the fragment of a
loaded embed reloads it.
The embed reads the existing GUI session from local storage on its own origin.
If that storage holds no session, the embed gets the session from the GUI origin's session cookie through
`/get-gui-token`. This matters in Safari, which gives a framed origin separate
local storage under each top-level origin, so even a sibling subdomain's iframe
can't see the GUI's stored token. The session cookie is still sent, because the
host is on the same site. The embed doesn't write that token back to storage. It
does not create accounts, show a login dialog, or accept a token from its parent.
Being signed in on a subdomain alone does not create a session on the main GUI
origin.
Without a session it can show, the embed shows the recommended apps (`RecommendedAppsService`)
that the server put in the page, and nothing else: no installed apps, folders, or
saved order, and no puter.js. Selecting one opens it at the same URL a signed-in
launch uses, with no opener and no token on offer, so the app signs in on its own.
Apps that need the desktop open `/app/<name>`. If the page carries no recommended
apps, or a load fails, the embed displays an unavailable/error state.
The route's `Content-Security-Policy: frame-ancestors` allows the GUI origin and
the embedding page's origin when it is exactly one label under `config.domain`
with the scheme and port from `config.origin`. Subdomains the server answers
itself (`api`, `dav`, `js`, `www`, the native-app hosts, and the host of
`api_base_url`) and the user-content hosting domains (`static_hosting_domain`,
`private_app_hosting_domain`, and their `_alt` variants) are refused. For
example, `https://puter.com` permits `https://spreadsheet.puter.com` but not
`https://api.puter.com`, and `http://puter.localhost:4100` permits
`http://spreadsheet.puter.localhost:4100` but not
`http://someone.site.puter.localhost:4100`. The embedding page is read from the
iframe request's referrer, so the host must not suppress it (the browser default
`strict-origin-when-cross-origin` is enough). All ancestors in a nested iframe
chain must be allowed. Other GUI routes retain `X-Frame-Options: SAMEORIGIN`.
The embed accepts no query parameters. Requests with a query string redirect to
the same embed path so SDK bootstrap parameters cannot override the session or API
origin. The host needs to allow the GUI origin in its own `frame-src` CSP if it
sets one. If sandboxing the iframe, allow scripts, same-origin storage, popups,
and popups to escape the sandbox.
## Host integration
A first-party app that signs in with puter.js can place `<puter-user-menu>` from
the SDK in its header instead (see `src/puter-js/src/ui/components.md`). It opens
this embed with the signed-in user named and closes it when the app's sign-in
changes. The rest of this section is for a host that builds the iframe itself.
The iframe can be shown whether or not the user is signed in. Create it when
“More apps” is opened, naming the signed-in user in its fragment, and remove it
on close so reopening loads current apps and preferences. Remove it when the
host's own sign-in changes too, such as on sign-out, so an open embed doesn't
keep the previous user. The host owns the modal and close control;
there is no parent messaging API and no app list or credential is sent to it.
The app browser fills its iframe and responds to its size. It shows a spinner until its apps load.
This example assumes `puterOrigin` comes from the host app's deployment
settings. Translate the button and frame labels using the host app's
localization system.
```html
<button id="more-apps" type="button">More apps</button>
<dialog id="apps-dialog" aria-label="More apps">
<button id="close-apps" type="button">Close</button>
<div id="apps-frame"></div>
</dialog>
<style>
#apps-dialog { padding: 12px; width: min(900px, 90vw); }
#apps-frame iframe { width: 100%; height: min(650px, 75vh); border: 0; }
</style>
<script type="module">
const puterOrigin = 'https://puter.com'; // Use this deployment's configured origin.
const dialog = document.querySelector('#apps-dialog');
const container = document.querySelector('#apps-frame');
const trigger = document.querySelector('#more-apps');
trigger.addEventListener('click', async () => {
const url = new URL('/embed/apps', puterOrigin);
// Signed out, the embed shows the recommended apps.
const user = puter.auth.isSignedIn() ? await puter.auth.getUser().catch(() => null) : null;
if (user) url.hash = `user=${encodeURIComponent(user.uuid)}`;
const frame = document.createElement('iframe');
frame.title = 'Your apps';
frame.src = url.href;
container.replaceChildren(frame);
dialog.showModal();
});
document.querySelector('#close-apps').addEventListener('click', () => dialog.close());
dialog.addEventListener('close', () => {
container.replaceChildren();
trigger.focus();
});
</script>
```
## Implementation
`createAppBrowser` in `src/gui/src/UI/Dashboard/AppBrowser.js` owns the shared UI
and data behavior; its `guestApps` option is the signed-out list. `TabApps.js`
injects desktop actions. `appsEmbed.js` supplies new-tab launching through
`launchAppInTab` (signed in) and `guestAppLaunchUrl` (signed out) in
`src/gui/src/helpers/appTabLaunch.js`, which build the same app URL as
`launch_app`, and loads only the embed entry, its SDK, and shared styles. `embedHostUser` in
`src/gui/src/helpers/embedHostUser.js` reads the host's user from the fragment, and
`embedHostTheme.js` beside it applies the host's theme.
The normal GUI webpack build emits `bundle.min.js`, `apps-embed.min.js`, and `feedback-embed.min.js`.
The `/embed/apps` route puts the recommended list in the page's config as
`guestApps`. No puter.js API or app-list endpoint changes are needed.
## Feedback dialog
`GET /embed/feedback` is the app feedback dialog (`puter.ui.showFeedbackDialog()`)
for a first-party page, with the same framing rules, query redirect, and
host-user binding as `/embed/apps`. It sends the feedback for the framing
page's origin, which the server reads from the referrer and pins with
`frame-ancestors`; opened top-level, it does nothing. Its page is transparent
so the dialog's own scrim covers the host.
puter.js uses it on a page one label under the GUI origin, other than the
subdomains the server refuses, when the page is signed in, framing it in a
modal `<dialog>` that stays hidden until the embed is ready. A session check that takes longer than 200ms shows a loading scrim
with a spinner in its place; Escape or a click on it cancels. The embed posts `{ msg: 'feedbackEmbed', state }` to its parent
(`loading`, then `ready` or `unavailable`) and finally
`{ msg: 'feedbackDialogClosed', sent }`. It reports `unavailable` without the
named user's session, or while that user's email still needs confirming, and
puter.js then uses the `/action/send-feedback` popup, which can sign in. So does
a frame that never answers, such as one the host's CSP blocks. `feedbackEmbed.js`
is the entry, sharing `embedSession` in `src/gui/src/helpers/embedSession.js`
with the app browser; `showFeedbackEmbed` in `src/puter-js/src/lib/guiEmbed.js`
is the host side.
## Sign-in and file pickers
`GET /embed/sign-in`, `/embed/show-open-file-picker`, `/embed/show-save-file-picker`
and `/embed/show-directory-picker` serve the same GUI popups as `/action/<action>`
for a first-party page, with the same framing rules, query redirect, and
`no-store` as `/embed/apps`. Any other spelling of the path (another case, a
trailing slash) redirects to it, since the shell boots the desktop for it. They render the normal GUI shell, which names the
framing page (read from the referrer, pinned with `frame-ancestors`) as
`gui_params.embed_host_origin`. The GUI then runs the popup boot unchanged, with
the framing page as `window.opener`, and `window.close()` asks the page to
remove the frame (`src/gui/src/helpers/popupFrame.js`). Without a framing page
to answer (opened top-level, or framed by a refused page) it does nothing. The
popup's parameters come from the fragment instead of the query: `msg_id`,
`options` and `request_auth`, plus `user` for a picker.
The frame posts `{ msg: 'puterPopupFrame', state }` to its parent: `loading` as
soon as the shell boots, `ready` once it shows a window or dialog, `unavailable`
when the page has to use the popup, `provider` when the user picks a sign-in
provider (below), and `closed` where a popup would close. Its
answers (`puter.token`, `fileOpenPicked`, `directoryPicked`, `fileSaved`, the
cancel messages, `sendMeFileData`) are the popup's.
puter.js uses them on a page one label under the GUI origin, other than the
subdomains the server refuses, unless the page is cross-origin isolated or in a
frame of another site (which `frame-ancestors` refuses too).
`showPopupFrame` in `src/puter-js/src/lib/guiEmbed.js` shows the frame in a box
the size of the popup window, over a scrim, hidden until `ready` and behind the
loading spinner after 200ms. Escape closes it, on the page or in the frame. One shows per popup, as each
popup has its own window name: a second picker request of the same kind
replaces the one showing, and a `signIn()` call made while a framed sign-in is
open settles with it. On
screens up to 600px wide the box fills the screen. A frame that is refused,
never answers, or reports `unavailable` falls back to the popup, opened through
a consent dialog when the click that asked for it has expired. A reload or navigation the frame
starts itself (logging out, a sign-out in another tab) names the frame as its
referrer, so the server refuses it; the page then shows its spinner and opens
the frame again, once per `ready`.
- **Sign-in** (`puter.auth.signIn()`) has no host-user binding: it is how the
page gets a user. The box has a close button, which rejects with
`auth_window_closed` as closing the popup does. Providers refuse to be
framed, so choosing one posts `{ state: 'provider', url }` with the
provider's start URL for this sign-in's `msg_id`, then `unavailable`. The
page opens the popup straight at that URL, once it checks the URL is the
GUI's `/auth/oidc/<provider>/start` for this `msg_id`, and the provider
returns it as this sign-in's popup. `attempt_temp_user_creation` keeps the popup, since
only the popup creates a temporary account.
- **Pickers** frame only for a signed-in page. The GUI reports `unavailable`
unless its session belongs to the user named in `user`, and the page then uses
the popup, which can sign in and switch accounts. The token the GUI sends
before a picker's answer isn't adopted, since it is for the page's own user.
Dismissing the loading scrim settles the picker as cancelled.
## Upgrade
`GET /embed/upgrade` is the deployment's upgrade dialog for a first-party page,
served like the popups above: the GUI shell with the same framing rules, query
redirect, exact-path redirect and `no-store`, naming the framing page as
`gui_params.embed_host_origin`. It runs only when framed by an allowed page.
puter.js frames it on a signed-in first-party page (the same pages as the
pickers) for `puter.ui.requestUpgrade(details)`, and for the Upgrade button of
the usage limit dialog a refused call shows there. Elsewhere both keep their
behavior: inside an app `requestUpgrade()` asks the desktop, and on any other
page the usage limit dialog opens the dashboard's upgrade page in a new tab.
The frame covers the page with no box or close button, and the GUI page is
transparent (`html.popup-frame-transparent`) so the dialog's own scrim covers
the host. The frame allows `payment` as well as `otp-credentials`. Escape on the
page is left to the dialog, since one there shouldn't drop a payment in
progress. Its fragment names `user` (the page's signed-in user) and, when the
refusal gave them, `reason` (`funds`, `subscription` or `storage`) and `method`.
The GUI reports `unavailable` unless its session belongs to `user`, the account
has no verification or password change waiting (which the frame couldn't be
closed from), and the deployment defines `window.UIUpgradeAccount` with
`UIUpgradeAccount.embeddable === true`. Otherwise it runs:
```js
new UIUpgradeAccount({ attribution: { appUid, reason, method }, onClose }).open_as_window();
```
`appUid` is the app the framing page's origin resolves to, never one the
fragment names. `open_as_window()` resolves once the dialog shows, and the GUI
then posts `ready`. While the page still hides the frame, which Chromium and
WebKit lay out at 0x0, the GUI posts `ready` first and opens the dialog once
the frame has a viewport, so a window it opens doesn't center on 0x0.
`onClose({ upgraded })` must run exactly once, when the
dialog closes or when it declines to open for the account; the GUI then posts
`{ msg: 'puterPopupFrame', state: 'closed', upgraded }` and the page removes
the frame. If `open_as_window()` rejects, the GUI posts `unavailable` instead. `requestUpgrade()` resolves `{ upgraded }`. A call made while the
frame shows settles with it rather than replacing it, and a call refused while
it shows still rejects but opens no usage limit dialog over it.
When the frame can't show the upgrade (refused, never ready, `unavailable`, or
no user to name), puter.js opens the dashboard's upgrade page in a new tab with
what was refused, through a consent dialog when the click has expired or the
tab is blocked, and `requestUpgrade()` resolves `{ upgraded: false }`.
## Top-up
`GET /embed/top-up` is the deployment's top-up dialog, where a user with a plan
buys add-on credit, served and framed like the upgrade: a transparent GUI page
covering the host with no box or close button, allowing `payment` and
`otp-credentials`, with Escape left to the dialog. Its fragment names `user`.
puter.js frames it for the **Add credits** item of `<puter-user-menu>`, which
shows only when whoami reports `subscribed: true` and no `team` paying for the
account. On a page that can't frame the GUI, the item opens the dashboard with
`?topup=` in a new tab instead.
The GUI reports `unavailable` unless its session belongs to `user`, the account
has no verification or password change waiting, and the deployment defines
`window.UITopUp` with `UITopUp.embeddable === true`. Otherwise it runs:
```js
new UITopUp({ onClose }).open();
```
`open()` resolves once the dialog has loaded the account and shows what it can
buy, and `onClose({ toppedUp })` must run exactly once, when the dialog closes
or when it declines to open for the account. The GUI then posts `{ msg: 'puterPopupFrame', state: 'closed', toppedUp }`
and the page removes the frame. When the frame can't show the top-up, puter.js
opens the dashboard's top-up in a new tab, as it does the upgrade.
While the top-up shows, a call refused for funds still rejects but opens no
usage limit dialog over it, and `requestUpgrade()` frames nothing over it: it
resolves `{ upgraded: false }` once the top-up closes.
## Email confirmation
`GET /embed/request-email-confirmation` walks a first-party page's user through
confirming their email, served like the upgrade: the GUI shell with the same
framing rules, redirects and `no-store`, run only when framed by an allowed
page. puter.js frames it on a signed-in first-party page for
`puter.ui.requestEmailConfirmation()`. Inside an app the desktop shows the same
windows over the app instead.
Like the upgrade, the frame covers the page with no box or close button, over a
transparent GUI page, and its fragment names `user`. Escape closes it, on the
page or on the save-account or code window. On a window, Escape closes that
window as its close button does, so a code already sent still answers. The GUI
reports `unavailable` unless its session belongs to `user` and the account has
no phone, card or password check waiting.
An email check is fine: the boot skips its own email gate for this action, so
the user gets one code window they can close.
The GUI then runs `ensureConfirmedEmail` (`src/gui/src/helpers/ensureConfirmedEmail.js`),
which the desktop's handler uses too: a temporary account gets the save-account
window first, then an unconfirmed email gets the code window, each at most
once. When the page's whoami already reads the email as confirmed,
`requestEmailConfirmation()` resolves `{ response: true }` without framing
anything, and a confirmation in the frame marks that whoami confirmed for the
calls after it. An email the GUI reads as confirmed shows nothing. Before
opening a window the GUI waits for the frame to have a viewport, as the upgrade
does. It then posts
`{ msg: 'puterPopupFrame', state: 'closed', confirmed }`, and
`requestEmailConfirmation()` resolves `{ response: confirmed }`, the field an
app reads from the desktop's answer. A call made while the frame shows settles
with it. A frame that is refused, never ready, or `unavailable` resolves
`{ response: false }`, since there's no popup to fall back to.
## Custom domains
`GET /embed/attach-custom-domain` is the custom domain dialog of
`puter.hosting.attach()`, the popup at `/action/attach-custom-domain`, served
like the upgrade: the GUI shell with the same framing rules, redirects and
`no-store`, run only when framed by an allowed page. puter.js frames it on a
signed-in first-party page (the same pages as the pickers).
The frame covers the page with no box or close button, over a transparent GUI
page, so the dialog floats over the host with its own scrim as it does on the
desktop. The frame allows `clipboard-write`, for the dialog's copy buttons, as
well as `otp-credentials`. Escape on the page is left to the dialog, which
doesn't close while a request is in flight. Its fragment names `user` (the
page's signed-in user), `subdomain` (the site to attach a domain to) and
`msg_id`.
The GUI reports `unavailable` unless its session belongs to `user` and the
account has no verification or password change waiting. It then runs the
popup's action unchanged, with the framing page as the requester the dialog
names: it posts `ready` once the dialog is on the page, answers
`{ msg: 'customDomainAttached', domain, original_msg_id }` once the dialog has
closed and its exit has played, and then posts `closed`. `domain` is the domain the user chose, or
null. `attach()` resolves it. A frame that closes without an answer, or a
loading scrim the user dismisses, resolves null.
A call made while the frame shows resolves null without replacing it, as a
second call does on the desktop. A frame that is refused, never ready, or
`unavailable` falls back to the popup, which can sign in, through a consent
dialog when the click has expired.
## Checks
Run the GUI component/browser tests (with the SDK bundle built and Playwright
Chromium installed), and the backend route tests:
```sh
npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser src/feedbackEmbed
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/modules/UI.showTopUp.test.js src/puter-js/src/modules/UI.attachCustomDomainFrame.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
npx vitest run --config src/gui/vitest.config.js src/gui/src/helpers/popupFrame.test.js
npx vitest run --config src/backend/vitest.config.ts src/backend/server.test.ts
```
+1 -11
View File
@@ -51,21 +51,11 @@ two pages can't be decoded into how fast the table grows; `openCursor` still
reads a plain cursor. What a cursor wraps is an implementation detail per
store:
- DynamoDB-backed lists wrap `LastEvaluatedKey`. A kv listing of another
namespace (cross-app or through a share handle) resumes after the last entry
it returned instead, since the scan can stop on a private one, and its cursor
is sealed to that listing and padded to one length.
- DynamoDB-backed lists wrap `LastEvaluatedKey`.
- SQL-backed lists wrap a keyset position — `(sortValue, id)` of the last
row — and the query seeks past it (`WHERE (col, id) > (?, ?) ORDER BY col,
id`). Any SQL list gaining a cursor must have a deterministic `ORDER BY`
ending in a unique tiebreaker (`id`).
- The notification catch-up (`GET /events/fetch`) holds two keysets in one
sealed cursor: the last `id` it returned, and the last `(replaced_at, id)`
among rows up to that id. Each is its own indexed read, merged into one
page, so a row replaced after a client read it comes back from that cursor.
`from=end` with no cursor answers an empty page whose cursor holds the
slice's newest `id` and the slice's latest `(replaced_at, id)` as the
replacement mark.
Cursors that carry a sort also pin it: a request that passes a cursor plus a
conflicting sort is rejected with 400.
-80
View File
@@ -1,80 +0,0 @@
# Browser push client
Puter's desktop shows pushes through `/sw.js`. Core shows Settings → Notifications only when the `pushEnabled` gui param is `true`; core never sets it. The worker is registered only when push is turned on in the browser, and a load removes one left without a subscription. A backend extension that delivers pushes sets it with `services.homepage.setGuiParam('pushEnabled', true)` and can load its own code into the worker with `services.homepage.registerServiceWorkerScript(path)`.
Core subscribes the browser to a push service only when the server also publishes `pushWebPushKey` (see [Built-in Web Push client](#built-in-web-push-client)), and it has no `/push/*` routes of its own. The device, app, preference and subscription endpoints below come from that extension. On a deployment without one, such as most self-hosted servers, they answer core's plain 404 (`code: 'not_found'`), which `puter.notifications` reports as `push_unavailable` and the Notifications tab shows as unavailable. A device the extension can't find is `device_not_found`.
## Push payload
`sw.js` reads the push's JSON body. The message is its `puter` field, or `data.puter` for services that only carry string data, as an object or a JSON string:
```ts
{ uid?: string; title?: string; body?: string; icon?: string; group?: string;
target?: { app?: string; path?: string }; data?: unknown; channel?: string;
type?: 'challenge'; challenge?: string }
```
`uid` is the notification's uid, a later push with the same `group` replaces the one showing, and `target` and `data` reach the app when the user taps it.
When the browser replaces the subscription (`pushsubscriptionchange`), `sw.js` posts `{ type: 'puter-push-subscription-changed' }` to every open window on Puter's origin. The worker has no token or session, so the page's push client registers the new subscription.
## Receipts
Before a notification is pushed, the server asks the open clients whether they received it live. It sends `notif.receipt` with `{ uid }` on the user's sockets for the app that sent it, or on the desktop's sockets for Puter's own notifications. The SDK remembers the uids its page received through `onNotification()`, `onPush()` or the desktop's feed, and answers once its page has that notification, or with `{ received: false }` when it never gets it. The server waits up to 5 seconds (`notificationAckTimeoutMs`).
An answer carries `visibilityState` and `focused` as they were when the page received the notification. Pages never show a browser notification themselves; pushes do.
- An answer from a page that was visible and focused stops the push (`handled` on `notif.delivery.settled`). An older client that doesn't send `focused` counts while visible.
- Otherwise the notification is pushed, to every browser the user subscribed. A website's pushes go only to the browsers that subscribed through its own worker, never to Puter's `/sw.js`. An app inside the desktop pushes through `/sw.js`, which shows the app's name and icon.
- A client that can't answer, such as an older puter.js, doesn't hold the push back.
The ask reaches only the sockets in the asking region. So that other regions hear about a page too, a page that receives a notification live while visible and focused also sends `notif.handled` with `{ uid }` on the socket it came on. Core relays it as the node-local `notif.handled` event with the socket's user and app, at most 60 a minute per user; an extension that delivers pushes decides what to do with it.
## Registering
The registry is in [src/gui/src/helpers/pushClient.js](../src/gui/src/helpers/pushClient.js). A service script registers its client like this:
```js
service_script(async (api) => {
api.use()['push.registerPushClient']?.(client);
});
```
`use(id)` throws when nothing is registered under `id`, so the form above also runs on a Puter version without the registry and does nothing there. Any script that runs after the GUI bundle can call `use()['push.registerPushClient']?.(client)` the same way. Registering again replaces the client. Registering dispatches `push-client-registered` on `window`, and an open Notifications tab redraws.
With no client registered, the tab says push notifications aren't available in this browser.
### Built-in Web Push client
When the server also sets the `pushWebPushKey` gui param (a VAPID public key), core registers its own client ([webPushClient.js](../src/gui/src/helpers/webPushClient.js)) unless an extension registered one first. It uses `puter.notifications.enable()` / `disable()`, the same standard Web Push flow apps use on their own origins:
1. `GET /push/web/key` for the key, then `pushManager.subscribe()` on the `/sw.js` registration.
2. `POST /push/web/subscriptions { subscription }`. A subscription the server already has answers `{ status: 'active' }`. A new one answers `{ status: 'pending' }`, and the server sends it a push with `{ type: 'challenge', challenge }`.
3. The worker shows that push and posts `{ type: 'puter-push-challenge', challenge }` to the origin's windows; the page sends it to `POST /push/web/subscriptions/verify`. The device is registered once the page hands the confirmation back.
4. `DELETE /push/web/subscriptions { endpoint }` removes it.
Apps on their own origin do the same through `push-sw.js`, which they import into their own worker. A subscription made with an app token is the app's: its pushes are that app's notifications only.
## Interface
```ts
interface PushClient {
enable(): Promise<{ deviceUid: string }>;
disable(): Promise<void>;
status(): Promise<{
permission: 'default' | 'granted' | 'denied';
registered: boolean;
deviceUid?: string;
}>;
}
```
- `enable()` may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includes `notifications:push` while this browser is off, and the "Turn on notifications" button of the `/action/enable-push` popup and the matching desktop dialog (`UIEnablePushDialog`). Core also calls it without a click, from `helpers/pushSync.js`, when `Notification.permission` is already `granted`, `status()` reports the browser as not registered, the user has not turned push off in this browser, and an app holds `notifications:push`; the browser does not prompt then. The built-in client runs `puter.notifications.enable()`, which subscribes with the `/sw.js` registration and registers the browser through the Web Push flow above (`platform: 'webpush'`). It then resolves the `uid` of this browser's `webpush` device from `listDevices()`. It rejects when permission is refused or the browser wasn't registered.
- `disable()` runs `puter.notifications.disable()`, which sends `DELETE /push/web/subscriptions { endpoint }` and ends the browser's subscription. The tab calls it for Turn off, and for Remove on the device marked as this browser. Remove on any other device is `DELETE /push/devices/:uid`.
- `status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user, and `deviceUid` is that device's `uid`.
After `enable()` resolves, the tab calls `POST /push/devices/:uid/test` and asks the user whether a notification showed up.
The tab also reads `GET /push/apps` and `GET`/`POST /push/preferences`.
Core keeps its own record of a browser the user turned off, `puter.push.browser-off:<user uuid>` in `localStorage`, written by Turn off and by Remove on this browser's device, and cleared when `enable()` resolves. The silent registration above skips a browser with that record, so a device removed here stays removed while one removed from another device, or a new sign-in in a browser that already allows notifications, registers again.
+1 -19
View File
@@ -12,17 +12,7 @@ Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker
## Requirements
- **Docker** with the `compose` plugin. This is the one thing you have to install
yourself — the installer checks for it and stops with an explanation rather
than installing it for you.
- **macOS / Windows:** [Docker Desktop](https://docs.docker.com/get-docker/).
Both ship the `compose` plugin.
- **Linux:** [Docker Engine](https://docs.docker.com/engine/install/) plus the
[`docker-compose-plugin`](https://docs.docker.com/compose/install/) package.
- Confirm before running anything else: `docker compose version` should print a
version, not `command not found`.
- If Docker is installed but the shell can't see it, make sure your user is in
the `docker` group (Linux) or that Docker Desktop is actually running.
- **Docker** with the `compose` plugin.
- A **domain** with DNS access — you need a wildcard record (`*.your-domain.com` → server IP). Puter routes by subdomain (`api.<domain>`, `site.<domain>`, `app.<domain>`).
- Optional: **TLS certs** (or `certbot` to grab them — see Step 3).
@@ -667,14 +657,6 @@ Migrations re-apply idempotently across pulls. Volumes are preserved.
## Troubleshooting
**`missing required command: docker` — the installer stops immediately.**
Docker isn't on your `PATH` yet. Puter self-hosting is a Docker Compose stack, so
nothing can run without it. Install [Docker Desktop](https://docs.docker.com/get-docker/)
on macOS/Windows or [Docker Engine](https://docs.docker.com/engine/install/) plus
`docker-compose-plugin` on Linux, then confirm `docker compose version` works in
the same shell and re-run the installer. On Linux, a fresh Docker install usually
needs a new login session before your user picks up the `docker` group.
**Site loads but I get a 502 / "Bad Gateway" from Caddy.**
The puter container failed to come up. `docker compose logs puter` will tell you which dependency rejected it (most often DB password mismatch between `.env` and `config.json`).
-35
View File
@@ -314,41 +314,6 @@ describe('thumbnails extension — handleThumbnailRead', () => {
await server?.shutdown();
});
it('keeps read URLs stable within an hour and rotates them at the next hour', async () => {
const key = mintedKey();
const signedAt = Date.UTC(2026, 9, 26, 10);
const read = async () => {
const entry: Record<string, unknown> = {
thumbnail: `s3://${BUCKET}/${key}`,
};
await handleThumbnailRead(entry, {
s3,
s3Presign,
bucketName: BUCKET,
bucketEndpoint: BUCKET_ENDPOINT,
fsEntry: server.stores.fsEntry,
});
return entry.thumbnail as string;
};
vi.useFakeTimers({ toFake: ['Date'] });
try {
vi.setSystemTime(signedAt + 1000);
const first = await read();
vi.setSystemTime(signedAt + 3599_000);
expect(await read()).toBe(first);
expect(new URL(first).searchParams.get('X-Amz-Date')).toBe(
'20261026T100000Z',
);
expect(new URL(first).searchParams.get('X-Amz-Expires')).toBe(
'604800',
);
vi.setSystemTime(signedAt + 3600_000);
expect(await read()).not.toBe(first);
} finally {
vi.useRealTimers();
}
});
// Thumbnails stored before keys were bound to entries keep displaying.
it.each([
['an entry-bound key', () => mintedKey()],
+2 -5
View File
@@ -307,9 +307,6 @@ export const handleThumbnailRead = async (
const thumb = entry.thumbnail;
if (typeof thumb !== 'string' || !thumb) return;
const presignClient = deps.s3Presign;
const signingDate = new Date(
Math.floor(Date.now() / 3_600_000) * 3_600_000,
);
if (isStoragePointer(thumb, deps.bucketEndpoint)) {
const key = resolveThumbnailKey(thumb);
@@ -322,7 +319,7 @@ export const handleThumbnailRead = async (
entry.thumbnail = await getSignedUrl(
presignClient,
new GetObjectCommand({ Bucket: deps.bucketName, Key: key }),
{ expiresIn: 604800, signingDate },
{ expiresIn: 604800 },
);
} else if (thumb.startsWith('data')) {
// Move an inline thumbnail into storage. The row only takes the new
@@ -376,7 +373,7 @@ export const handleThumbnailRead = async (
entry.thumbnail = await getSignedUrl(
presignClient,
new GetObjectCommand({ Bucket: deps.bucketName, Key: key }),
{ expiresIn: 604800, signingDate },
{ expiresIn: 604800 },
);
}
};
+10 -40
View File
@@ -257,41 +257,8 @@ describe('whoami extension — handleWhoami', () => {
expect(body.created_ts).toBeGreaterThan(0);
// `directories` is only sent to user actors — confirm it’s present.
expect(body.directories).toBeDefined();
});
it('leaves out taskbar items saved on the account', async () => {
const user = await seedUser();
await server.stores.user.update(user.id as number, {
taskbar_items: JSON.stringify([{ type: 'app', name: 'editor' }]),
});
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleWhoami(makeReq(), res),
);
expect(captured.body).not.toHaveProperty('taskbar_items');
});
it('leaves out a desktop background saved on the account', async () => {
const user = await seedUser();
await server.stores.user.update(user.id as number, {
desktop_bg_url: 'https://cdn.test/wallpaper.png',
desktop_bg_color: '#000000',
desktop_bg_fit: 'cover',
});
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleWhoami(makeReq(), res),
);
const body = captured.body as Record<string, unknown>;
expect(body).not.toHaveProperty('desktop_bg_url');
expect(body).not.toHaveProperty('desktop_bg_color');
expect(body).not.toHaveProperty('desktop_bg_fit');
// taskbar_items is only sent to user actors.
expect(body).toHaveProperty('taskbar_items');
});
it('reports the SMS-to-card fallback only once a send has opened it', async () => {
@@ -355,7 +322,7 @@ describe('whoami extension — handleWhoami', () => {
expect(flags.payment_bypass).toBeUndefined();
});
it('strips created_ts and human_readable_age fields for app actors', async () => {
it('strips desktop_bg_*, created_ts and human_readable_age fields for app actors', async () => {
const user = await seedUser();
const { res, captured } = makeRes();
@@ -373,6 +340,9 @@ describe('whoami extension — handleWhoami', () => {
expect(body.app_name).toBe('app-test-actor');
// is_user_token is stripped for app actors.
expect(body.is_user_token).toBeUndefined();
expect(body.desktop_bg_url).toBeUndefined();
expect(body.desktop_bg_color).toBeUndefined();
expect(body.desktop_bg_fit).toBeUndefined();
expect(body.human_readable_age).toBeUndefined();
// Account age, in either form, is not exposed to apps.
expect(body.created_ts).toBeUndefined();
@@ -462,7 +432,7 @@ describe('whoami extension — handleWhoami', () => {
});
});
it('cannot add directories for an app actor, which core omits', async () => {
it('cannot add taskbar_items for an app actor, which core omits', async () => {
const user = await seedUser();
const body = await whoamiWithListener(
makeActor({
@@ -470,11 +440,11 @@ describe('whoami extension — handleWhoami', () => {
app: { uid: 'app-test-actor' },
}),
(_key, event) => {
event.details.directories = { '/injected': 'uuid' };
event.details.taskbar_items = [{ name: 'injected' }];
},
);
expect(body).not.toHaveProperty('directories');
expect(body).not.toHaveProperty('taskbar_items');
});
});
@@ -555,7 +525,7 @@ describe('whoami extension — handleWhoami', () => {
expect(body.otp_secret).toBeUndefined();
expect(body.signup_ip).toBeUndefined();
// Not just absent as a key — the values must not appear anywhere
// in the payload (nested under metadata, …).
// in the payload (nested under metadata, taskbar items, …).
const serialized = JSON.stringify(body);
expect(serialized).not.toContain('5551234567');
expect(serialized).not.toContain('fp_ABC123');
+16 -2
View File
@@ -1,5 +1,6 @@
import { Context } from '@heyputer/backend/src/core';
import { extension } from '@heyputer/backend/src/extensions';
import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js';
import {
buildUserDetails,
scrubSensitive,
@@ -53,10 +54,19 @@ export const handleWhoami = async (
return;
}
const rawIconSize =
typeof req.query?.icon_size === 'string'
? Number(req.query.icon_size)
: undefined;
const iconSize =
rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize)
? rawIconSize
: undefined;
const details = await buildUserDetails(
user,
{ config: extension.config, clients, stores, services },
{ isUser },
{ isUser, iconSize, noIcons: !iconSize },
);
details.human_readable_age = user.timestamp
? timeago.format(new Date(user.timestamp as string))
@@ -71,6 +81,9 @@ export const handleWhoami = async (
delete details.email;
delete details.unconfirmed_email;
}
delete details.desktop_bg_url;
delete details.desktop_bg_color;
delete details.desktop_bg_fit;
delete details.human_readable_age;
delete details.created_ts;
delete details.is_user_token;
@@ -108,7 +121,8 @@ export const handleWhoami = async (
}
const subscription = details.subscription as
{ offering?: Record<string, unknown> } | undefined;
| { offering?: Record<string, unknown> }
| undefined;
if (subscription?.offering) {
delete subscription.offering.group;
delete subscription.offering.benefits;
-7
View File
@@ -190,13 +190,6 @@ S3_BUCKET=puter-local
providers = [ordered]@{
ollama = [ordered]@{ enabled = $false }
}
# Off because a self-hosted install has no paid plans: every account
# is on the free policy, so the plan gates would 402 the surfaces that
# declare one (the OpenAI/Anthropic-compatible AI endpoints) for
# everybody. Same as install.sh.
meteringEnforcement = [ordered]@{
subscriptions = $false
}
trust_proxy = 1
}
$configJson = $config | ConvertTo-Json -Depth 10
+1 -21
View File
@@ -53,29 +53,9 @@ need() {
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
}
check_docker() {
if command -v docker >/dev/null 2>&1; then
return 0
fi
echo >&2 ""
echo >&2 "[puter-install] missing required command: docker"
echo >&2 ""
echo >&2 "Docker is required to run Puter self-hosted."
echo >&2 ""
echo >&2 "Installation instructions:"
echo >&2 " - macOS (Apple Silicon & Intel): https://docs.docker.com/desktop/setup/install/mac-install/"
echo >&2 " (Docker Desktop is recommended. Alternatives: OrbStack, Colima)"
echo >&2 " - Linux: https://docs.docker.com/engine/install/"
echo >&2 " - Windows: https://docs.docker.com/desktop/setup/install/windows-install/"
echo >&2 ""
echo >&2 "After installing Docker, make sure it's running and try again."
echo >&2 ""
exit 1
}
# ── Step 1: dependency check ────────────────────────────────────────
log "checking dependencies"
check_docker
need docker
need curl
need openssl
docker compose version >/dev/null 2>&1 \
+769 -787
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -54,6 +54,7 @@
"dev": "npm start",
"build": "npm run setupExtensions && npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
"build:workerLib": "cd src/puter-js && npm run build && cd ../worker && npm run build",
"check-translations": "node tools/check-translations.js",
"prepare": "husky",
"build:ts": "tsc -p tsconfig.build.json && node ./tools/write-dist-package-json.mjs",
"check:puterjs:types": "node tools/checkPuterjsTypes.mjs",
@@ -78,8 +79,7 @@
"javascript-time-ago": "^2.5.11",
"libphonenumber-js": "1.13.15",
"miniflare": "^4.20260617.1",
"open": "^10.1.0",
"sharp": "^0.35.5"
"open": "^10.1.0"
},
"engines": {
"node": ">=24.0.0"
+1
View File
@@ -125,6 +125,7 @@ describe('core without a teams registration', () => {
actor: { user: row },
} as never,
{ title: 'Puter' } as never,
{} as never,
);
const params = JSON.parse(
/gui\((\{.*?\})\);/s.exec(html)![1].replaceAll('\\u003c', '<'),
@@ -108,25 +108,6 @@ describe('AbstractDatabaseClient — unimplemented surface', () => {
});
describe('AbstractDatabaseClient — replica-aware reads', () => {
it('avoids a primary read when the replica has rows in sequential mode', async () => {
const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]);
expect(
await client.tryHardRead('SELECT 1', [], { race: false }),
).toEqual([{ id: 1 }]);
expect(client.preads).toEqual([]);
});
it.each([[], new Error('replica unavailable')])(
'falls back once in sequential mode',
async (replica) => {
const client = new FixtureDatabaseClient(replica, [{ id: 2 }]);
expect(
await client.tryHardRead('SELECT 1', [], { race: false }),
).toEqual([{ id: 2 }]);
expect(client.preads).toEqual(['SELECT 1']);
},
);
it('returns replica rows without waiting on the primary', async () => {
const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]);
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
@@ -275,28 +256,6 @@ describe('AbstractDatabaseClient — SQL generation', () => {
expect(postgres().timestampFromUnixSql()).toBe('to_timestamp(?)');
});
it('reads a timestamp column back as unix seconds in each engine', () => {
expect(sqlite().unixSecondsSql('`t`')).toBe(
"CAST(strftime('%s', `t`) AS INTEGER)",
);
expect(mysql().unixSecondsSql('`t`')).toBe('UNIX_TIMESTAMP(`t`)');
expect(postgres().unixSecondsSql('`t`')).toBe(
'EXTRACT(EPOCH FROM `t`)::bigint',
);
});
it('reads the database clock as epoch milliseconds in each dialect', () => {
expect(sqlite().nowMsExpr()).toBe(
"CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)",
);
expect(mysql().nowMsExpr()).toBe(
"(TIMESTAMPDIFF(MICROSECOND, '1970-01-01 00:00:00', UTC_TIMESTAMP(6)) DIV 1000)",
);
expect(postgres().nowMsExpr()).toBe(
'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)',
);
});
it('describes a stored timestamp the way each driver reads it back', () => {
const seconds = Date.UTC(2026, 9, 11, 8, 5, 9) / 1000;
expect(sqlite().timestampValue(seconds)).toBe('2026-10-11 08:05:09');
@@ -318,13 +277,3 @@ describe('AbstractDatabaseClient — SQL generation', () => {
);
});
});
it('builds an insert for batchWrite and shares it with insert', async () => {
const client = new FixtureDatabaseClient([]);
const entry = client.insertEntry('widget', { name: "reader's", enabled: true });
expect(entry.values).toEqual(["reader's", true]);
expect(entry.statement).toContain('VALUES (?, ?)');
await client.insert('widget', { name: "reader's", enabled: true });
expect(client.writes).toEqual([{ query: entry.statement, params: entry.values }]);
});
+12 -51
View File
@@ -110,23 +110,14 @@ export class AbstractDatabaseClient extends PuterClient {
tableName: string,
data: Record<string, unknown>,
): Promise<WriteResult> {
const entry = this.insertEntry(tableName, data);
return this.write(
entry.statement + this.returningIdClause(),
entry.values,
);
}
/** An insert that can join other writes in a transaction. */
insertEntry(tableName: string, data: Record<string, unknown>): BatchEntry {
const cols = Object.keys(data);
return {
statement:
`INSERT INTO ${this.quoteIdentifier(tableName)} ` +
`(${cols.map((c) => this.quoteIdentifier(c)).join(', ')}) ` +
`VALUES (${cols.map(() => '?').join(', ')})`,
values: Object.values(data),
};
const values = Object.values(data);
const sql =
`INSERT INTO ${this.quoteIdentifier(tableName)} ` +
`(${cols.map((c) => this.quoteIdentifier(c)).join(', ')}) ` +
`VALUES (${cols.map(() => '?').join(', ')})` +
this.returningIdClause();
return this.write(sql, values);
}
/** Whether `read()` goes to a separate replica rather than the primary. */
@@ -147,27 +138,25 @@ export class AbstractDatabaseClient extends PuterClient {
/**
* Like `read()`, but an empty or failed replica read falls back to the
* primary, so replication lag can't hide a row that was just written. By
* default the primary read starts alongside the replica one; `race: false`
* reads the primary only after the replica returns empty or fails. Without
* a replica this is a single `read()`.
* primary, so replication lag can't hide a row that was just written. The
* primary read starts alongside the replica one rather than after it.
* Without a replica this is a single `read()`.
*/
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
async tryHardRead(
query: string,
params: unknown[] = [],
{ race = true }: { race?: boolean } = {},
): Promise<Record<string, unknown>[]> {
if (!this.hasReadReplica()) return this.read(query, params);
const primary = race ? settle(this.pread(query, params)) : null;
const primary = settle(this.pread(query, params));
try {
const rows = await this.replicaRead(query, params);
if (rows.length > 0) return rows;
} catch {
// replica failed — fall through to primary
}
const result = await (primary ?? settle(this.pread(query, params)));
const result = await primary;
if ('error' in result) throw result.error;
return result.value;
}
@@ -397,34 +386,6 @@ export class AbstractDatabaseClient extends PuterClient {
});
}
/**
* A timestamp column as unix seconds, computed by the database: the mysql
* driver reads a stored UTC timestamp as local time, so parsing it in JS
* can be off by the server's UTC offset.
*/
unixSecondsSql(column: string): string {
return this.case({
postgres: `EXTRACT(EPOCH FROM ${column})::bigint`,
mysql: `UNIX_TIMESTAMP(${column})`,
otherwise: `CAST(strftime('%s', ${column}) AS INTEGER)`,
});
}
/**
* The database's own clock as epoch milliseconds, so nodes whose clocks
* disagree still agree on it. The mysql form ignores the session time zone,
* so a DST change can't shift it.
*/
nowMsExpr(): string {
return this.case({
sqlite: "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)",
postgres:
'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)',
otherwise:
"(TIMESTAMPDIFF(MICROSECOND, '1970-01-01 00:00:00', UTC_TIMESTAMP(6)) DIV 1000)",
});
}
/**
* What a read of a timestamp column holding `seconds` returns: sqlite gives
* back the UTC text it stored, mysql2 and pg a `Date` read in node's time
@@ -217,8 +217,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
return this.configuration === Configuration.REPLICA;
}
// No failover: `tryHardRead` falls back to the primary on an empty result
// or error (raced alongside by default, after the replica with `race: false`).
// No failover: `tryHardRead` already has a primary read in flight.
protected override async replicaRead(
query: string,
params: unknown[],
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
const CURRENT_SCHEMA_VERSION = 98;
const CURRENT_SCHEMA_VERSION = 90;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -673,38 +673,6 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
});
});
it('adds kv_share_handles.notify from 0099, off for every existing share', async () => {
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-notify-'));
const path = join(dir, 'puter.sqlite');
try {
const before = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path, targetVersion: 94 }),
);
await before.onServerStart();
await before.write(
'INSERT INTO `kv_share_handles` (`handle`, `owner_user_id`, ' +
'`grantee_user_id`, `app_uid`, `key_prefix`, `permission`, ' +
'`created_at`) VALUES (?, 1, 1, ?, ?, ?, 0)',
['kvh-before-notify', 'os-global', 'a:', 'kv-share:x'],
);
before.onServerShutdown();
const after = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path }),
);
await after.onServerStart();
await expect(
after.read(
'SELECT `notify` FROM `kv_share_handles` WHERE `handle` = ?',
['kvh-before-notify'],
),
).resolves.toEqual([{ notify: 0 }]);
after.onServerShutdown();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('indexes notification.created_at for the retention sweep', async () => {
const rows = await client.read('PRAGMA index_list(`notification`)');
expect(rows.map((r) => String(r.name))).toContain(
@@ -712,97 +680,6 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
);
});
it('indexes notification.replaced_at per mailbox slice for catch-up', async () => {
const columns = await client.read(
'PRAGMA index_info(`idx_notification_replaced`)',
);
expect(columns.map((c) => String(c.name))).toEqual([
'user_id',
'audience',
'app_uid',
'replaced_at',
]);
});
it('keeps notification.send_key unique', async () => {
const rows = await client.read('PRAGMA index_list(`notification`)');
expect(
rows.find((r) => r.name === 'idx_notification_send_key'),
).toMatchObject({ unique: 1 });
const columns = await client.read(
'PRAGMA index_info(`idx_notification_send_key`)',
);
expect(columns.map((c) => String(c.name))).toEqual(['send_key']);
});
it('creates event_user_handlers with its unique and holder indexes', async () => {
const columns = await client.read(
'PRAGMA table_info(`event_user_handlers`)',
);
expect(columns.map((c) => String(c.name))).toEqual([
'id',
'app_uid',
'holder_user_id',
'name',
'source',
'source_hash',
'created_at',
'updated_at',
'scope_since',
]);
const indexes = await client.read(
'PRAGMA index_list(`event_user_handlers`)',
);
expect(
indexes.find(
(i) => i.name === 'idx_event_user_handlers_app_holder_name',
),
).toMatchObject({ unique: 1 });
expect(
indexes.find((i) => i.name === 'idx_event_user_handlers_holder_app'),
).toMatchObject({ unique: 0 });
const columnsOf = async (index: string) =>
(await client.read(`PRAGMA index_info(\`${index}\`)`)).map((c) =>
String(c.name),
);
expect(await columnsOf('idx_event_user_handlers_app_holder_name')).toEqual(
['app_uid', 'holder_user_id', 'name'],
);
expect(await columnsOf('idx_event_user_handlers_holder_app')).toEqual([
'holder_user_id',
'app_uid',
]);
});
it('leaves event_handlers as it was', async () => {
const columns = await client.read('PRAGMA table_info(`event_handlers`)');
expect(columns.map((c) => String(c.name))).toEqual([
'id',
'app_uid',
'name',
'source',
'source_hash',
'created_at',
'updated_at',
]);
});
it('adds event_subscriptions.handler_scope, app scope for existing rows', async () => {
const columns = await client.read(
'PRAGMA table_info(`event_subscriptions`)',
);
expect(columns.find((c) => c.name === 'handler_scope')).toMatchObject({
notnull: 1,
dflt_value: '0',
});
const indexes = await client.read(
'PRAGMA index_list(`event_subscriptions`)',
);
expect(indexes.map((i) => String(i.name))).toContain(
'idx_event_subscriptions_holder',
);
});
it('leaves an already-migrated database untouched on a second boot', async () => {
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-'));
const path = join(dir, 'nested', 'puter.sqlite');
@@ -129,14 +129,6 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[87, ['0092_share-entry-owner.sql']],
[88, ['0093_fsentries-parent-uid-index.sql']],
[89, ['0094_deleted-apps.sql']],
[90, ['0095_kv-share-handle-levels.sql']],
[91, ['0096_kv-share-handle-quota-indexes.sql']],
[92, ['0097_user-to-user-holder-permission-index.sql']],
[93, ['0098_notification-groups.sql']],
[94, ['0099_kv-share-handle-notify.sql']],
[95, ['0100_notification-send-key.sql']],
[96, ['0101_user-scoped-handlers.sql']],
[97, ['0102_notification-scope-id-index.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -1,50 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Share levels on `kv_share_handles`. See
-- sqlite/0095_kv-share-handle-levels.sql for the rationale. No per-file
-- applied-state tracking, so every step is guarded and a replay does nothing.
CALL _puter_add_col('kv_share_handles', 'revoked_by', '`revoked_by` varchar(8) DEFAULT NULL');
CALL _puter_add_col('kv_share_handles', 'label', '`label` varchar(512) DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_indexes;
DELIMITER //
CREATE PROCEDURE _puter_add_kv_share_handle_indexes()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_grantee'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_grantee` (`grantee_user_id`, `id`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_app'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_app` (`app_uid`);
END IF;
END//
DELIMITER ;
CALL _puter_add_kv_share_handle_indexes();
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_indexes;
@@ -1,72 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Share-handle quota, listing and purge indexes. See
-- sqlite/0096_kv-share-handle-quota-indexes.sql for what each serves. No
-- per-file applied-state tracking, so every index is guarded and a replay does
-- nothing.
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_quota_indexes;
DELIMITER //
CREATE PROCEDURE _puter_add_kv_share_handle_quota_indexes()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_owner_live'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_owner_live` (`owner_user_id`, `revoked_at`, `app_uid`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_owner_app'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_owner_app` (`owner_user_id`, `app_uid`, `id`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_grantee_app'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_grantee_app` (`grantee_user_id`, `app_uid`, `id`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_pair'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_pair` (`owner_user_id`, `grantee_user_id`, `revoked_at`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_revoked'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_revoked` (`revoked_at`);
END IF;
END//
DELIMITER ;
CALL _puter_add_kv_share_handle_quota_indexes();
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_quota_indexes;
@@ -1,38 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Lets a holder's grant check read only the permissions it names. Guarded, so
-- a replay does nothing.
DROP PROCEDURE IF EXISTS _puter_add_u2u_holder_permission_index;
DELIMITER //
CREATE PROCEDURE _puter_add_u2u_holder_permission_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_to_user_permissions'
AND INDEX_NAME = 'idx_user_to_user_permissions_holder_permission'
) THEN
ALTER TABLE `user_to_user_permissions` ADD INDEX `idx_user_to_user_permissions_holder_permission` (`holder_user_id`, `permission`);
END IF;
END//
DELIMITER ;
CALL _puter_add_u2u_holder_permission_index();
DROP PROCEDURE IF EXISTS _puter_add_u2u_holder_permission_index;
@@ -1,53 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- `notification`.`group_key` and `replaced_at`. See
-- sqlite/0098_notification-groups.sql for the rationale. The key is a hex
-- digest, so ascii and compared byte for byte. No per-file applied-state
-- tracking, so every step is guarded and a replay does nothing.
CALL _puter_add_col('notification', 'group_key', '`group_key` char(64) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL');
CALL _puter_add_col('notification', 'replaced_at', '`replaced_at` bigint DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_notification_group_indexes;
DELIMITER //
CREATE PROCEDURE _puter_add_notification_group_indexes()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'notification'
AND INDEX_NAME = 'idx_notification_group'
) THEN
ALTER TABLE `notification` ADD INDEX `idx_notification_group`
(`user_id`, `app_uid`, `group_key`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'notification'
AND INDEX_NAME = 'idx_notification_replaced'
) THEN
ALTER TABLE `notification` ADD INDEX `idx_notification_replaced`
(`user_id`, `audience`, `app_uid`, `replaced_at`);
END IF;
END//
DELIMITER ;
CALL _puter_add_notification_group_indexes();
DROP PROCEDURE IF EXISTS _puter_add_notification_group_indexes;
@@ -1,23 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Notifications through a share handle. See
-- sqlite/0099_kv-share-handle-notify.sql for the column. No per-file
-- applied-state tracking, so it goes through _puter_add_col and a replay does
-- nothing.
CALL _puter_add_col('kv_share_handles', 'notify', '`notify` tinyint(1) NOT NULL DEFAULT 0');
@@ -1,43 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- `notification`.`send_key`. See sqlite/0100_notification-send-key.sql for the
-- rationale. The key is a uuid, so ascii and compared byte for byte. No
-- per-file applied-state tracking, so every step is guarded and a replay does
-- nothing.
CALL _puter_add_col('notification', 'send_key', '`send_key` char(36) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_notification_send_key_index;
DELIMITER //
CREATE PROCEDURE _puter_add_notification_send_key_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'notification'
AND INDEX_NAME = 'idx_notification_send_key'
) THEN
ALTER TABLE `notification` ADD UNIQUE KEY `idx_notification_send_key`
(`send_key`), ALGORITHM=INPLACE, LOCK=NONE;
END IF;
END//
DELIMITER ;
CALL _puter_add_notification_send_key_index();
DROP PROCEDURE IF EXISTS _puter_add_notification_send_key_index;
@@ -1,66 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Handlers an app's user published into their own scope, kept apart from
-- `event_handlers` so code that predates them never reads them. See
-- sqlite/0101_user-scoped-handlers.sql for the column rationale.
--
-- Idempotent: `CREATE TABLE IF NOT EXISTS` with the indexes declared inline,
-- and a guarded column add. There is no per-file applied-state tracking, so a
-- replay has to be a no-op.
CREATE TABLE IF NOT EXISTS `event_user_handlers` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
-- Matches `apps`.`uid` exactly, charset included, so an equality against
-- one never falls back to a conversion.
`app_uid` char(40) CHARACTER SET latin1 COLLATE latin1_swedish_ci NOT NULL,
`holder_user_id` int unsigned NOT NULL,
`name` varchar(128) NOT NULL,
`source` mediumtext NOT NULL,
`source_hash` char(64) NOT NULL,
`created_at` bigint NOT NULL,
`updated_at` bigint NOT NULL,
-- When the scope first held a handler; kept across handler churn, so the
-- rent sweep sees the scope's age, not its oldest remaining handler's.
`scope_since` bigint NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_event_user_handlers_app_holder_name` (`app_uid`, `holder_user_id`, `name`),
KEY `idx_event_user_handlers_holder_app` (`holder_user_id`, `app_uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Guarded here rather than through `_puter_add_col`, so the file also applies
-- on its own to a database that never ran the earlier migrations.
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_handler_scope;
DELIMITER //
CREATE PROCEDURE _puter_add_event_subscriptions_handler_scope()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'event_subscriptions'
AND COLUMN_NAME = 'handler_scope'
) THEN
ALTER TABLE `event_subscriptions`
ADD COLUMN `handler_scope` tinyint unsigned NOT NULL DEFAULT 0,
ALGORITHM=INSTANT;
END IF;
END//
DELIMITER ;
CALL _puter_add_event_subscriptions_handler_scope();
DROP PROCEDURE IF EXISTS _puter_add_event_subscriptions_handler_scope;
@@ -1,41 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- `notification` (`user_id`, `audience`, `app_uid`, `id`). See
-- sqlite/0102_notification-scope-id-index.sql for the rationale. No per-file
-- applied-state tracking, so the index is added only when missing and a
-- replay does nothing.
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_id_index;
DELIMITER //
CREATE PROCEDURE _puter_add_notification_scope_id_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'notification'
AND INDEX_NAME = 'idx_notification_scope_id'
) THEN
ALTER TABLE `notification` ADD INDEX `idx_notification_scope_id`
(`user_id`, `audience`, `app_uid`, `id`), ALGORITHM=INPLACE, LOCK=NONE;
END IF;
END//
DELIMITER ;
CALL _puter_add_notification_scope_id_index();
DROP PROCEDURE IF EXISTS _puter_add_notification_scope_id_index;
@@ -1,29 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Share levels on kv_share_handles. See
-- sqlite/0095_kv-share-handle-levels.sql for the rationale. Idempotent via
-- IF NOT EXISTS; there is no per-file applied-state tracking.
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS revoked_by VARCHAR(8) DEFAULT NULL;
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS label VARCHAR(512) DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_grantee
ON kv_share_handles (grantee_user_id, id);
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_app
ON kv_share_handles (app_uid);
@@ -1,35 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Share-handle quota, listing and purge indexes. See
-- sqlite/0096_kv-share-handle-quota-indexes.sql for what each serves.
-- Idempotent via IF NOT EXISTS; there is no per-file applied-state tracking.
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_owner_live
ON kv_share_handles (owner_user_id, revoked_at, app_uid);
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_owner_app
ON kv_share_handles (owner_user_id, app_uid, id);
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_grantee_app
ON kv_share_handles (grantee_user_id, app_uid, id);
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_pair
ON kv_share_handles (owner_user_id, grantee_user_id, revoked_at);
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_revoked
ON kv_share_handles (revoked_at);
@@ -1,21 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Lets a holder's grant check read only the permissions it names.
CREATE INDEX IF NOT EXISTS idx_user_to_user_permissions_holder_permission
ON user_to_user_permissions (holder_user_id, permission);
@@ -1,29 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- notification.group_key and replaced_at. See
-- sqlite/0098_notification-groups.sql for the rationale. Idempotent via IF NOT
-- EXISTS.
ALTER TABLE notification ADD COLUMN IF NOT EXISTS group_key varchar(64) DEFAULT NULL;
ALTER TABLE notification ADD COLUMN IF NOT EXISTS replaced_at bigint DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_notification_group
ON notification (user_id, app_uid, group_key);
CREATE INDEX IF NOT EXISTS idx_notification_replaced
ON notification (user_id, audience, app_uid, replaced_at);
@@ -1,22 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Notifications through a share handle. See
-- sqlite/0099_kv-share-handle-notify.sql for the column. Idempotent via
-- IF NOT EXISTS; there is no per-file applied-state tracking.
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS notify smallint NOT NULL DEFAULT 0;
@@ -1,24 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- notification.send_key. See sqlite/0100_notification-send-key.sql for the
-- rationale. Idempotent via IF NOT EXISTS.
ALTER TABLE notification ADD COLUMN IF NOT EXISTS send_key char(36) DEFAULT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_send_key
ON notification (send_key);
@@ -1,39 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Handlers an app's user published into their own scope. See
-- sqlite/0101_user-scoped-handlers.sql for the column rationale.
-- Idempotent via IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS event_user_handlers (
id BIGSERIAL PRIMARY KEY,
app_uid VARCHAR(40) NOT NULL,
holder_user_id INTEGER NOT NULL,
name VARCHAR(128) NOT NULL,
source TEXT NOT NULL,
source_hash VARCHAR(64) NOT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
scope_since BIGINT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_event_user_handlers_app_holder_name
ON event_user_handlers (app_uid, holder_user_id, name);
CREATE INDEX IF NOT EXISTS idx_event_user_handlers_holder_app
ON event_user_handlers (holder_user_id, app_uid);
ALTER TABLE event_subscriptions ADD COLUMN IF NOT EXISTS handler_scope smallint NOT NULL DEFAULT 0;
@@ -1,23 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- notification (user_id, audience, app_uid, id). See
-- sqlite/0102_notification-scope-id-index.sql for the rationale. Idempotent via
-- IF NOT EXISTS.
CREATE INDEX IF NOT EXISTS idx_notification_scope_id
ON notification (user_id, audience, app_uid, id);
@@ -1,35 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- What share levels need from `kv_share_handles`. The level itself is not a
-- column: it is the root of the stored `permission`.
--
-- - `revoked_by` : `owner`, `holder` or `system` (the namespace app was
-- deleted). Null while live, and on rows retired before
-- this existed.
-- - `label` : optional owner-set text the holder is shown.
-- - the grantee index serves the holder's listing and their unshare, and
-- the app index retiring a deleted app's handles.
ALTER TABLE `kv_share_handles` ADD COLUMN `revoked_by` TEXT DEFAULT NULL;
ALTER TABLE `kv_share_handles` ADD COLUMN `label` TEXT DEFAULT NULL;
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_grantee`
ON `kv_share_handles` (`grantee_user_id`, `id`);
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_app`
ON `kv_share_handles` (`app_uid`);
@@ -1,42 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Indexes behind the share-handle quotas, the app-filtered listings and the
-- retention purge on `kv_share_handles`.
--
-- - owner_live : the owner's live count, in total and per app, read off
-- the index alone.
-- - owner_app : the owner's listing narrowed to one app, in id order.
-- - grantee_app : the holder's listing narrowed to one app, in id order.
-- - pair : live handles one owner holds out to one holder, counted at
-- mint and walked on revoke and unshare.
-- - revoked : retired rows past retention, for the purge.
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_owner_live`
ON `kv_share_handles` (`owner_user_id`, `revoked_at`, `app_uid`);
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_owner_app`
ON `kv_share_handles` (`owner_user_id`, `app_uid`, `id`);
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_grantee_app`
ON `kv_share_handles` (`grantee_user_id`, `app_uid`, `id`);
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_pair`
ON `kv_share_handles` (`owner_user_id`, `grantee_user_id`, `revoked_at`);
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_revoked`
ON `kv_share_handles` (`revoked_at`);
@@ -1,21 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Lets a holder's grant check read only the permissions it names.
CREATE INDEX IF NOT EXISTS `idx_user_to_user_permissions_holder_permission`
ON `user_to_user_permissions` (`holder_user_id`, `permission`);
@@ -1,38 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- What app message groups need from `notification`:
--
-- - `group_key` : a sha256 hex digest of an app message's `group`, so the
-- notification a newer one replaces is found by index.
-- Null on every other row, and on rows written before
-- this, which therefore never match.
-- - `replaced_at` : epoch milliseconds of the last replacement, null for a
-- row never replaced. Live and fetched copies carry it as
-- their version, so a client can tell a replacement from a
-- copy it already has. Indexed per mailbox slice, so a
-- catch-up reads the rows replaced since its cursor
-- without scanning the ones that never were.
ALTER TABLE `notification` ADD COLUMN `group_key` TEXT DEFAULT NULL;
ALTER TABLE `notification` ADD COLUMN `replaced_at` INTEGER DEFAULT NULL;
CREATE INDEX IF NOT EXISTS `idx_notification_group`
ON `notification` (`user_id`, `app_uid`, `group_key`);
CREATE INDEX IF NOT EXISTS `idx_notification_replaced`
ON `notification` (`user_id`, `audience`, `app_uid`, `replaced_at`);
@@ -1,23 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Whether the two parties of a share may send each other notifications through
-- its handle. Off by default, so every existing share reads as not allowing
-- them.
ALTER TABLE `kv_share_handles`
ADD COLUMN `notify` INTEGER NOT NULL DEFAULT 0;
@@ -1,27 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- `notification`.`send_key`: the key of the delayed send (a `deferred`
-- schedule) that wrote this row or last replaced it, derived from the
-- schedule and its slot. Unique, so one slot can never write two rows, and a
-- send that crashed after writing finds its row again by it. Null on every row
-- a delayed send never touched.
ALTER TABLE `notification` ADD COLUMN `send_key` TEXT DEFAULT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS `idx_notification_send_key`
ON `notification` (`send_key`);
@@ -1,48 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Handlers an app's user published into their own scope, in a table of their
-- own so `event_handlers` stays the app owner's set. Same columns as
-- `event_handlers` (see 0076_event-handlers.sql) plus the holder.
--
-- - `holder_user_id` : the user whose scope this is. A name is unique per
-- (app, holder), so the same name can live in the app's
-- set and in each holder's.
-- - `(holder_user_id, app_uid)` : a holder's scopes, for listing and for the
-- rent count.
--
-- `event_subscriptions.handler_scope` says which table a row's `handler_name`
-- refers to: 0 the app's set, 1 the row holder's scope.
CREATE TABLE IF NOT EXISTS `event_user_handlers` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"app_uid" TEXT NOT NULL,
"holder_user_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"source" TEXT NOT NULL,
"source_hash" TEXT NOT NULL,
"created_at" INTEGER NOT NULL, -- unix seconds
"updated_at" INTEGER NOT NULL,
"scope_since" INTEGER NOT NULL -- unix seconds; the scope's first handler, kept across churn
);
CREATE UNIQUE INDEX IF NOT EXISTS `idx_event_user_handlers_app_holder_name`
ON `event_user_handlers` (`app_uid`, `holder_user_id`, `name`);
CREATE INDEX IF NOT EXISTS `idx_event_user_handlers_holder_app`
ON `event_user_handlers` (`holder_user_id`, `app_uid`);
ALTER TABLE `event_subscriptions` ADD COLUMN `handler_scope` INTEGER NOT NULL DEFAULT 0;
@@ -1,22 +0,0 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Index a mailbox slice by row id, so a fetch finds the newest row of a slice,
-- and pages forward through one, with a seek rather than a scan of the slice.
CREATE INDEX IF NOT EXISTS `idx_notification_scope_id`
ON `notification` (`user_id`, `audience`, `app_uid`, `id`);
@@ -188,47 +188,6 @@ describe('DDBClient — item operations', () => {
).toBeUndefined();
});
it('puts only while its condition holds', async () => {
const absent = {
expression: 'attribute_not_exists(#pk)',
names: { '#pk': 'pk' },
};
await client.put(
TABLE,
{ pk: 'u7', sk: 'once', v: 1 },
{
condition: absent,
},
);
await expect(
client.put(
TABLE,
{ pk: 'u7', sk: 'once', v: 2 },
{
condition: absent,
},
),
).rejects.toMatchObject({ name: 'ConditionalCheckFailedException' });
expect(
(await client.get(TABLE, { pk: 'u7', sk: 'once' })).Item?.v,
).toBe(1);
await client.put(
TABLE,
{ pk: 'u7', sk: 'once', v: 3 },
{
condition: {
expression: '#v = :held',
names: { '#v': 'v' },
values: { ':held': 1 },
},
},
);
expect(
(await client.get(TABLE, { pk: 'u7', sk: 'once' })).Item?.v,
).toBe(3);
});
it('fetches keys from several tables in one batch', async () => {
await client.put(TABLE, { pk: 'b1', sk: 'a', v: 1 });
await client.put(TABLE, { pk: 'b1', sk: 'b', v: 2 });
+1 -39
View File
@@ -252,19 +252,7 @@ export class DDBClient extends PuterClient {
}
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
async put<T extends Record<string, unknown>>(
table: string,
item: T,
opts?: {
/** Refused (`ConditionalCheckFailedException`) unless it holds. */
condition?: {
expression: string;
names?: Record<string, string>;
values?: Record<string, unknown>;
};
},
) {
const condition = opts?.condition;
async put<T extends Record<string, unknown>>(table: string, item: T) {
const client = await this.#getDocumentClient();
return sendRepairingValues(
item,
@@ -274,23 +262,6 @@ export class DDBClient extends PuterClient {
TableName: table,
Item: itemToWrite,
ReturnConsumedCapacity: 'TOTAL',
...(condition
? {
ConditionExpression: condition.expression,
...(condition.names
? {
ExpressionAttributeNames:
condition.names,
}
: {}),
...(condition.values
? {
ExpressionAttributeValues:
condition.values,
}
: {}),
}
: {}),
}),
),
() => `a put to ${table}`,
@@ -534,8 +505,6 @@ export class DDBClient extends PuterClient {
scanIndexForward?: boolean;
beginsWith?: { key: string; value: string };
select?: 'COUNT';
/** Attributes to return instead of the whole item. */
projection?: string[];
filter?: {
expression: string;
values?: Record<string, unknown>;
@@ -576,10 +545,6 @@ export class DDBClient extends PuterClient {
Object.assign(expressionAttributeValues, options.filter.values);
Object.assign(expressionAttributeNames, options.filter.names);
}
const projection = (options?.projection ?? []).map((name) => {
expressionAttributeNames[`#p_${name}`] = name;
return `#p_${name}`;
});
const command = new QueryCommand({
TableName: table,
@@ -594,9 +559,6 @@ export class DDBClient extends PuterClient {
? { FilterExpression: options.filter.expression }
: {}),
...(options?.select ? { Select: options.select } : {}),
...(projection.length > 0
? { ProjectionExpression: projection.join(', ') }
: {}),
...(options?.scanIndexForward !== undefined
? { ScanIndexForward: options.scanIndexForward }
: {}),
+17 -188
View File
@@ -75,15 +75,6 @@ export type EventMap = {
serverStart: Record<string, never>;
serverPrepareShutdown: Record<string, never>;
serverShutdown: Record<string, never>;
/**
* Emitted by a host extension that knows this server will restart soon.
* Long-lived connections ask their clients to reconnect; nothing is refused
* or closed, so a false alarm is harmless.
*/
serverRestartAnnounced: {
/** How long until the server stops, when known. */
closeInMs?: number;
};
// ---- AI ----
'ai.prompt.validate': {
@@ -117,7 +108,7 @@ export type EventMap = {
model_used: string;
service_used: string;
intended_service: string;
model_details?: {
model_details: {
id: string;
provider: string;
input_cost_key: string;
@@ -146,17 +137,6 @@ export type EventMap = {
};
'app.new-icon': { app_uid: string; data_url: string };
'app.opened': { app_uid: string; user_id: number; ts: number };
/**
* A user removed an app: its grants and worker sessions are gone. With
* `deleteData`, the user asked for the app's data in their account to go
* too. Awaited by the emitter.
*/
'app.uninstalled': {
userId: number;
userUuid: string;
appUid: string;
deleteData: boolean;
};
'app.rename': {
app_uid: string;
old_name: string;
@@ -200,16 +180,6 @@ export type EventMap = {
};
// ---- Auth / signup ----
/**
* Which signup methods are open, emitted via `emitAndWait` before every
* signup and whenever the GUI reads the provider list. A listener sets
* `oidcOnly` to refuse form and temp signups, leaving OIDC and team
* provisioning open; `message` is what a refused form signup is told.
*/
'puter.signup.policy': {
oidcOnly: boolean;
message: string | null;
};
'puter.signup.validate': {
allow: boolean;
email?: string;
@@ -227,7 +197,7 @@ export type EventMap = {
*/
is_temp?: boolean;
ip?: string | null;
source?: 'oidc' | 'team_provision';
source?: 'oidc';
req?: unknown;
data?: unknown;
abuse?: unknown;
@@ -287,7 +257,7 @@ export type EventMap = {
ip?: string | null;
fingerprint?: string | null;
reputation?: number | null;
source?: 'oidc' | 'team_provision';
source?: 'oidc';
requires_phone_verification: boolean;
requires_card_verification: boolean;
accepted: boolean;
@@ -333,8 +303,6 @@ export type EventMap = {
// the abuse extension cap sends per device (across accounts) so a
// device farm can't dodge the per-account cap with fresh signups.
device_fingerprint: string | null;
// Browser-signals dispatch id the client sent, or null.
dispatch_id: string | null;
allowed: boolean;
reason: string | null;
// With `allowed` false, offer the card fallback in place of SMS. Only
@@ -420,14 +388,7 @@ export type EventMap = {
old_username: string;
new_username: string;
};
'user.email-changed': {
user_id: number;
old_email: string | null;
new_email: string;
};
/** An account was suspended, or its suspension lifted. Local events. */
'user.suspended': { userId: number };
'user.unsuspended': { userId: number };
'user.email-changed': { user_id: number; new_email: string };
// Fired after an account is torn down (self-serve, admin, or temp-user
// logout cleanup). Listeners purge external state tied to the account —
// e.g. the marketplace extension cancels the user's Stripe subscriptions.
@@ -436,8 +397,6 @@ export type EventMap = {
user_id: number;
user_uuid?: string;
stripe_customer_id?: string | null;
/** Apps the account owned, which cascaded away with it. */
appUids?: string[];
};
// ---- Team billing ---- the trigger, never the charge ----
@@ -531,43 +490,15 @@ export type EventMap = {
noShareKeys?: string[];
/** Events handler runs behind the write; see `Actor.handlerDepth`. */
handlerDepth?: number;
/**
* The user who made the change. Differs from `userId` when a share
* holder wrote into the owner's namespace.
*/
actingUserId?: number;
/** Made by a worker session or an events handler; see `viaWorkerOf`. */
viaWorker?: true;
};
/**
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
* key enumeration is truncated for a large namespace, so the keys it saw
* are not the keys it removed.
*/
'kv.flushed': {
namespace: string;
userId: number;
actingUserId?: number;
viaWorker?: true;
};
/**
* Share handles stopped being addresses, whoever retired them. `userId` is
* one user who could have subscribed through them, their owner or their
* holder. Listeners end what was made through them even where another grant
* still covers the region. Local, like `permission.revoked`: one region
* settling covers every other.
*/
'kv.share.retired': { handles: string[]; userId: number };
'kv.flushed': { namespace: string; userId: number };
// ---- Notifications ----
/** An app message was acknowledged after its state committed. */
'notif.acknowledged': {
userId: number;
uid: string;
type: string;
audience: string;
appUid: string;
};
/**
* A notification row exists. Emitted after the insert commits — nothing
* pushes a uid before there is a row to name — and carries the scope tuple
@@ -582,32 +513,9 @@ export type EventMap = {
appUid: string | null;
value: Record<string, unknown>;
createdAt: number;
/** An earlier notification replaced in place; `createdAt` is when. */
replaced?: true;
/** Events handler runs behind whatever created it. */
handlerDepth?: number;
};
/**
* Every non-silent write or update, only on the node that wrote it. The
* value is the listener's own copy of what the socket carries.
*/
'notif.written': {
userId: number;
uid: string;
type: string;
audience: string;
/** The row's app; null when it names none or an update could not tell. */
appUid: string | null;
value: Record<string, unknown>;
/** An earlier notification rewritten in place. */
updated?: true;
/** Replaced an earlier notification in its group. */
replaced?: true;
/** Names this one send, for a receipt asked about it. */
sendId: string;
/** The same `sendId` may be emitted again, as a delayed send is. */
replayable?: true;
};
/**
* One notification, addressed at whatever sockets each region holds for the
* recipient. `outer.*` reaches peer regions and never sibling nodes, so
@@ -616,43 +524,9 @@ export type EventMap = {
*/
'outer.notif.delivery': {
userId: number;
wire: 'notif.message' | 'notif.unreads' | 'notif.ack' | 'notif.removed';
wire: 'notif.message' | 'notif.unreads' | 'notif.ack';
response: unknown;
};
/**
* How one live notification went, on the node that asked for its receipt:
* from the desktop's sockets, or with `appUid` the app's own.
*/
'notif.delivery.settled': {
sendId?: string;
uid: string;
userId: number;
type: string;
/** The app whose own sockets were asked; absent for the desktop's. */
appUid?: string;
outcome: NotifDeliveryOutcome;
/** A socket in this region acknowledges the receipt. */
ackCapable: boolean;
/** No socket here is an older client that acknowledges nothing. */
everySocketAcks: boolean;
/** Every node here answered the ask, not just this one. */
complete: boolean;
/** A peer region holds a socket for the user. */
peerRegions: boolean;
responders: NotifDeliveryResponder[];
/** A page here got it live while visible and focused. */
handled: boolean;
};
/**
* A page reported that it got a notification live while visible and
* focused. Node-local; `appUid` is the reporting socket's app, absent for
* the desktop.
*/
'notif.handled': {
userId: number;
appUid?: string;
uid: string;
};
// ---- Metering ----
// Recurring charges are pure mechanism here: the metering service knows
@@ -702,18 +576,9 @@ export type EventMap = {
// and going away (1→0, whether by removing the last handler or by the
// destroy route). `actor.user` is the app's owner, not the caller — a
// developer session publishing for an app it owns is the common case, but
// billing follows ownership. For `scope: 'user'` the unit is one user's
// handlers in one app instead, and `actor.user` is that user.
'events.worker.create': {
actor: Actor;
appUid: string;
scope: 'app' | 'user';
};
'events.worker.destroy': {
actor: Actor;
appUid: string;
scope: 'app' | 'user';
};
// billing follows ownership.
'events.worker.create': { actor: Actor; appUid: string };
'events.worker.destroy': { actor: Actor; appUid: string };
// ---- Outer / GUI broadcast ----
'outer.cacheUpdate': {
@@ -736,8 +601,6 @@ export type EventMap = {
'outer.permission.flatInvalidated': {
entries: Array<{ holderUserId: number; permission: string }>;
};
/** Links on these nodes were removed; read them from the primary. */
'outer.share.linksRevoked': { fsentryIds: number[]; uuids: string[] };
/**
* A user's subscription set changed, so every process must drop its cached
* "does this user have any subscriptions" answer. Carries the counter the
@@ -780,13 +643,6 @@ export type EventMap = {
* reason as the events generation bump: a per-process cache.
*/
'outer.pubsub.share.ownerShared': { ownerUserId: number };
/**
* Share handles were retired or changed, so every process must drop its
* cached copy. `outer.pubsub.*` because the cache is a per-process map.
*/
'outer.pubsub.kv.share.forget': { handles: string[] };
/** The same, for every cached handle one owner holds out. */
'outer.pubsub.kv.share.forgetOwner': { ownerUserId: number };
'outer.fs.write-hash': { hash: string; uuid: string };
/**
* Cache keys the KV read cache must stop serving, because the entries
@@ -805,7 +661,6 @@ export type EventMap = {
'outer.gui.item.renamed': GuiEvent;
'outer.gui.notif.ack': GuiEvent<{ uid: string }>;
'outer.gui.notif.message': GuiEvent<{ uid: string; notification: unknown }>;
'outer.gui.notif.removed': GuiEvent<{ uid: string }>;
'outer.gui.notif.unreads': GuiEvent<{
unreads: { uid: string; notification: unknown }[];
}>;
@@ -813,7 +668,7 @@ export type EventMap = {
// ---- Subdomains ----
// `uid` is the row uuid — for `delete` it is the only surviving handle a
// listener can use to find state keyed to the row after it is gone.
'subdomain.delete': { subdomain: string; uid?: string; userId: number };
'subdomain.delete': { subdomain: string; uid?: string };
'subdomain.update': { subdomain: string; uid?: string };
'site.htmlServed': {
subdomain: string;
@@ -876,17 +731,8 @@ export type EventMap = {
session_uids: string[];
};
/**
* One access token was revoked; only its own connections should drop.
* `outer.*` for the same reason as `outer.auth.sessions.revoked`.
*/
'outer.auth.access-token.revoked': { token_uid: string };
/** Same-cluster eviction for raw realtime WebSockets. */
'pubsub.realtime.auth-revoked': {
sessionUids?: string[];
tokenUid?: string;
};
/** One access token was revoked; only its own connections should drop. */
'auth.access-token.revoked': { token_uid: string };
/**
* A grant was withdrawn, so whatever was standing on it has to be settled
@@ -1010,9 +856,6 @@ export type AiCostFactorEvent = {
*/
export type LifecyclePhase = 'before' | 'after' | 'error' | 'reject';
/** Status a vetoing `before` listener may answer with. */
export type LifecycleRejectStatus = 403 | 429;
/**
* Payload for `driver.<iface>.<method>.<phase>` events.
*
@@ -1042,8 +885,6 @@ export type DriverMethodLifecycleEvent = {
allow?: boolean;
/** Optional human-readable reason surfaced to the caller when vetoed. */
rejectReason?: string;
/** Status for a veto: 403 (default) or 429 for a throttle. */
rejectStatus?: LifecycleRejectStatus;
};
/**
@@ -1078,8 +919,6 @@ export type RouteLifecycleEvent = {
allow?: boolean;
/** Optional human-readable reason surfaced to the caller when vetoed. */
rejectReason?: string;
/** Status for a veto: 403 (default) or 429 for a throttle. */
rejectStatus?: LifecycleRejectStatus;
};
export type EventKey = keyof EventMap & string;
@@ -1088,10 +927,11 @@ export type EventKey = keyof EventMap & string;
// Generates a wildcard for every non-final dot-separated prefix of K.
export type WildcardPrefixes<K extends string> =
K extends `${infer Head}.${infer Tail}`
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
@@ -1100,17 +940,6 @@ export type MatchingEvents<P extends ListenKey> = P extends `${infer Prefix}.*`
? Extract<EventKey, `${Prefix}.${string}`>
: P & EventKey;
/** Acked, not acked, no socket anywhere, or sockets only in a peer region. */
export type NotifDeliveryOutcome =
'received' | 'unreceived' | 'no-socket' | 'remote-only';
/** One socket's receipt for a live notification. */
export interface NotifDeliveryResponder {
visibilityState: 'visible' | 'hidden' | 'unknown';
/** The page's window has focus. Absent from older clients. */
focused?: boolean;
}
export type EventMetadata = {
from_outside?: boolean;
/** Set by `emitAndWait` when a listener threw, so a verdict hook can refuse. */
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-73
View File
@@ -21,8 +21,6 @@
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -202,77 +200,6 @@ describe('S3Client — remote endpoint configuration', () => {
});
});
describe('S3Client — bucket in another region', () => {
let server: Server;
let client: S3Client;
let signedRegions: string[];
// Answers like a bucket that lives in us-west-2: a request signed for any
// other region gets S3's 301 naming the bucket's region.
beforeEach(async () => {
signedRegions = [];
server = createServer((req, res) => {
const region =
/Credential=[^/]+\/\d{8}\/([^/]+)\//.exec(
req.headers.authorization ?? '',
)?.[1] ?? '';
signedRegions.push(region);
if (region !== 'us-west-2') {
res.writeHead(301, {
'content-type': 'application/xml',
'x-amz-bucket-region': 'us-west-2',
});
res.end(
'<?xml version="1.0" encoding="UTF-8"?><Error><Code>PermanentRedirect</Code><Message>The bucket you are attempting to access must be addressed using the specified endpoint.</Message></Error>',
);
return;
}
res.writeHead(200, {
'content-type': 'text/plain',
'content-length': '5',
});
res.end('hello');
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve),
);
const { port } = server.address() as AddressInfo;
client = new S3Client({
port: 0,
extensions: [],
s3: {
s3Config: {
endpoint: `http://127.0.0.1:${port}`,
accessKeyId: 'fakeAccessKeyId',
secretAccessKey: 'fakeSecretAccessKey',
forcePathStyle: true,
},
},
} as unknown as IConfig);
await client.onServerStart();
});
afterEach(async () => {
await client.onServerShutdown();
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it("retries a read in the bucket's region when S3 redirects it", async () => {
const result = await client
.get('ap-northeast-2')
.send(
new GetObjectCommand({
Bucket: 'other-bucket',
Key: 'index.html',
}),
);
expect(await result.Body?.transformToString()).toBe('hello');
expect(signedRegions).toEqual(['ap-northeast-2', 'us-west-2']);
});
});
describe('S3Client — legacy storage migration', () => {
let client: S3Client;
let dir: string;
-3
View File
@@ -235,9 +235,6 @@ export class S3Client extends PuterClient {
const client = new AwsS3Client({
region,
// Rows without a stored region are read with the server's own, so
// retry where S3's 301 says the bucket actually lives.
followRegionRedirects: true,
requestStreamBufferSize: 32 * 1024,
requestHandler: new NodeHttpHandler({
socketTimeout: 5000,
@@ -52,8 +52,8 @@ const APP_READ_LIMIT = {
/**
* Unauthenticated icon serving; no actor to key on, so the bucket is the
* address — and an address is a NAT, a campus or a carrier gateway that can
* hold hundreds of desktops. Each dashboard load pulls its app icons at once,
* so a single burst from one network is already thousands of requests.
* hold hundreds of desktops. Each desktop boot pulls the taskbar's icons at
* once, so a single burst from one network is already thousands of requests.
* Responses are publicly cacheable and usually a redirect, so the ceiling is
* not protecting bandwidth; it is there so a client looping on a broken icon
* can't spin unbounded.
@@ -523,7 +523,7 @@ export class AppController extends PuterController {
};
// Icons are <img src> targets from the GUI (root) AND resolved via
// api_base_url in app summaries. Register on both so either origin
// api_base_url in taskbar payloads. Register on both so either origin
// works without a cross-subdomain redirect.
router.get(
'/app-icon/:app_uid',
@@ -1271,7 +1271,7 @@ describe('AppController app-icon rate limit', () => {
it('sizes the icon bucket for a whole network rather than one desktop', () => {
// Icons are unauthenticated `<img src>` targets, so the only key is
// the address — which one NAT shares across every desktop behind it,
// and each dashboard load pulls its app icons in a burst.
// and each boot pulls the taskbar's icons in a burst.
for (const path of ['/app-icon/:app_uid', '/app-icon/:app_uid/:size']) {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === path,
@@ -1,85 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
describe('auth elevation cookies over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
it.each(['bearer', 'cookie'])(
'API host returns a usable token without setting cookies (%s auth)',
async (credential) => {
const { token, password, username } = env.users.user;
const res = await fetch(new URL('/auth/elevate', env.apiOrigin), {
method: 'POST',
headers: {
'content-type': 'application/json',
...(credential === 'cookie'
? { cookie: `puter_auth_token=${token}` }
: { authorization: `Bearer ${token}` }),
},
body: JSON.stringify({ password }),
});
expect(res.status).toBe(200);
expect(res.headers.get('set-cookie')).toBeNull();
const body = (await res.json()) as {
elevated: boolean;
token: string;
};
expect(body.elevated).toBe(true);
expect(typeof body.token).toBe('string');
const user = await env.server.stores.user.getByUsername(username);
const { verifyStepUpSession } =
await import('../../core/http/middleware/stepUpSession.js');
expect(
verifyStepUpSession(
{
headers: { 'x-puter-elevation': body.token },
actor: { user: { uuid: user!.uuid } },
} as never,
{ tokenService: env.server.services.token },
),
).toBe(true);
},
);
it('GUI host sets the elevation cookie and omits the raw token for cookie auth', async () => {
const { token, password } = env.users.user;
const res = await fetch(new URL('/auth/elevate', env.origin), {
method: 'POST',
headers: {
'content-type': 'application/json',
cookie: `puter_auth_token=${token}`,
},
body: JSON.stringify({ password }),
});
expect(res.status).toBe(200);
expect(res.headers.get('set-cookie')).toContain('puter_elevated=');
expect(await res.json()).toEqual({ elevated: true });
});
});
@@ -23,7 +23,6 @@ import { makeActor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { tokenClaimsDigest } from '../../../gui/src/helpers/guiToken.js';
import {
createTestUser,
setupPuterTestEnv,
@@ -715,67 +714,4 @@ describe('session token helpers over HTTP', () => {
'puter_auth_token=',
);
});
describe("the shell's guiTokenDigest", () => {
/** The session cookie `/session/sync-cookie` sets for `token`. */
const sessionCookie = async (token: string) => {
const sync = await get('/session/sync-cookie', token);
expect(sync.status).toBe(204);
const value = /puter_auth_token=([^;]+)/.exec(
sync.headers.get('set-cookie') ?? '',
)?.[1];
expect(value).toBeTruthy();
return `puter_auth_token=${value}`;
};
/** What the shell at `path` renders for `guiTokenDigest`. */
const shellDigest = async (path: string, cookie?: string) => {
const res = await fetch(new URL(path, env.origin), {
headers: cookie ? { cookie } : {},
});
expect(res.status).toBe(200);
const html = await res.text();
const match = /"guiTokenDigest":(null|"[0-9a-f]{32}")/.exec(html);
if (!match) {
expect(html).not.toContain('guiTokenDigest');
return undefined;
}
return JSON.parse(match[1]) as string | null;
};
it("names the token /get-gui-token hands over for the session cookie", async () => {
const { token } = await signupTemp();
const cookie = await sessionCookie(token);
const gui = await fetch(new URL('/get-gui-token', env.origin), {
headers: { cookie },
});
expect(gui.status).toBe(200);
const { token: guiToken } = (await gui.json()) as { token: string };
const digest = await shellDigest('/', cookie);
expect(digest).toBe(await tokenClaimsDigest(guiToken));
// The token the browser stored at sign-up is for the same session.
expect(await tokenClaimsDigest(token)).toBe(digest);
});
it('is null without a session cookie, even with a token in the URL', async () => {
const { token } = await signupTemp();
expect(await shellDigest('/')).toBe(null);
expect(
await shellDigest(`/?auth_token=${encodeURIComponent(token)}`),
).toBe(null);
});
it('is left out when the session cookie does not resolve to a session', async () => {
const { token } = await signupTemp();
const cookie = await sessionCookie(token);
await env.server.services.auth.revokeSession(claims(token).session_uid);
expect(await shellDigest('/', cookie)).toBe(undefined);
expect(await shellDigest('/', 'puter_auth_token=garbage')).toBe(
undefined,
);
});
});
});
File diff suppressed because it is too large. Load diff
+47 -183
View File
@@ -33,7 +33,7 @@ import {
hasVerifiedPhone,
} from '../../core/http/middleware/gates.js';
import type { Actor } from '../../core/actor.js';
import { isAppActor, isPlainUserActor, makeActor } from '../../core/actor.js';
import { isPlainUserActor, makeActor } from '../../core/actor.js';
import {
checkRateLimit,
consumeRouteRateLimit,
@@ -66,7 +66,6 @@ import {
isCardFallbackEnabled,
phoneAttemptsKey,
} from '../../util/cardFallback.js';
import { isEmailGateLast } from '../../util/verificationOrder.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isStorableEmail } from '../../util/email.js';
import { isGodmodeApp } from '../../util/godmodeApps.js';
@@ -89,16 +88,13 @@ import {
import { promoteToVerifiedGroup } from '../../util/userProvisioning.js';
import {
assertBoundedManageGrant,
assertNewShareSegments,
assertPrefixFitsAccess,
isKvSharePermission,
} from '../../services/kvShare/kvShares.js';
} from '../../services/events/kvShares.js';
import {
APP_DATA_PERMISSION_PREFIX,
appDataSharingAllowed,
parseAppDataPermission,
} from '../../services/permission/appDataScopes.js';
import { parseAppGrantablePermission } from '../../services/permission/appGrantable.js';
import {
assertCreatablePath,
isOwnHomePath,
@@ -1221,18 +1217,6 @@ export class AuthController extends PuterController {
}
}
/** The email-gate-last confirmation email; a failure never fails the gate. */
private async sendDeferredConfirmation(userId: number): Promise<void> {
try {
await this.services.signup.sendDeferredConfirmationEmail(userId);
} catch (e) {
console.warn(
'[verification] deferred confirmation email failed:',
e,
);
}
}
private async isCardFallbackEligible(user: {
id: number;
requires_phone_verification?: boolean | number | null;
@@ -1278,12 +1262,6 @@ export class AuthController extends PuterController {
throw new HttpError(403, 'Account suspended.', {
legacyCode: 'account_suspended',
});
if (isEmailGateLast(user) && user.requires_card_verification)
throw new HttpError(
409,
'Card verification must be completed first.',
{ legacyCode: 'conflict' },
);
if (!this.clients.prelude?.isConfigured())
throw await this.smsSendError(
503,
@@ -1354,7 +1332,6 @@ export class AuthController extends PuterController {
user_uid: user.uuid,
phone: parsed.e164,
device_fingerprint: req.deviceFingerprint ?? null,
dispatch_id: dispatchId ?? null,
allowed: true,
reason: null as string | null,
card_fallback_instead: false,
@@ -1593,7 +1570,6 @@ export class AuthController extends PuterController {
requires_phone_verification: 0,
phone: pendingPhone,
});
await this.sendDeferredConfirmation(user.id);
// Run the verified-event listeners synchronously (emitAndWait, not
// fire-and-forget emit) so a carrier-based card-verification waiver in
@@ -1674,11 +1650,7 @@ export class AuthController extends PuterController {
res.json({ card_verified: true });
return;
}
if (
user.requires_phone_verification &&
!fallbackEligible &&
!isEmailGateLast(user)
)
if (user.requires_phone_verification && !fallbackEligible)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1741,7 +1713,6 @@ export class AuthController extends PuterController {
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
await this.sendDeferredConfirmation(user.id);
res.json({ card_verified: true, disabled: true });
return;
}
@@ -1801,11 +1772,7 @@ export class AuthController extends PuterController {
res.json({ card_verified: true });
return;
}
if (
user.requires_phone_verification &&
!fallbackEligible &&
!isEmailGateLast(user)
)
if (user.requires_phone_verification && !fallbackEligible)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1846,7 +1813,6 @@ export class AuthController extends PuterController {
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
await this.sendDeferredConfirmation(user.id);
res.json({ card_verified: true, disabled: true });
return;
}
@@ -1871,7 +1837,6 @@ export class AuthController extends PuterController {
requires_card_verification: 0,
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
});
await this.sendDeferredConfirmation(user.id);
// Awaited like `user.phone-verified`, so whatever a listener grants on
// verification is in place before the client refreshes. emitAndWait
@@ -2647,7 +2612,6 @@ export class AuthController extends PuterController {
'user.email-changed' as never,
{
user_id: user.id,
old_email: user.email ?? null,
new_email: newEmail,
} as never,
{},
@@ -3033,45 +2997,6 @@ export class AuthController extends PuterController {
return typeof permission === 'string' && permission ? [permission] : [];
}
/**
* Refuses any entry outside the families an app can be granted. Anything
* else is a row the backend never checks for an app, or one wider than the
* consent prompt can describe.
*/
async #assertAppGrantable(
actor: Actor,
appIdentifier: string,
list: string[],
): Promise<void> {
const refuse = (entry: string) =>
new HttpError(
400,
`Permission cannot be granted to an app: ${entry}`,
{ legacyCode: 'permission_not_grantable' },
);
let appUid: string | undefined;
for (const entry of list) {
// A delegation over a whole key-value namespace has no bounded
// description; refused with its own code.
assertBoundedManageGrant(entry);
const parsed = parseAppGrantablePermission(entry, actor.user!.uuid);
if (!parsed) throw refuse(entry);
if (parsed.family !== 'kvShare') continue;
// Held to what a new share's prefix may be, so a consent never
// covers shares that could not be made.
assertNewShareSegments(parsed.segments);
assertPrefixFitsAccess(
parsed.ownerUuid,
parsed.appUid,
parsed.segments.join(':'),
parsed.level,
);
// An app shares regions of its own namespace only.
appUid ??= (await this.stores.app.resolveApp(appIdentifier))?.uid;
if (parsed.appUid !== appUid) throw refuse(entry);
}
}
/**
* Gate a cross-app data grant: the target must exist, must not have opted
* out of sharing, and must be named. Also creates the target's AppData
@@ -3250,7 +3175,6 @@ export class AuthController extends PuterController {
}
// 400 on a bad value, before anything else runs. Absent means create.
const create = parseCreateFlag(req.body?.create);
await this.#assertAppGrantable(req.actor!, app_uid, list);
// Validate every entry before writing or creating anything, so a bad
// one in the list cannot leave a partially-granted set behind: the
@@ -3263,6 +3187,10 @@ export class AuthController extends PuterController {
kind: FsCreateKind;
}> = [];
for (const entry of list) {
// A delegation over a whole key-value namespace has no bounded
// description, so it is refused where it is asked for rather than
// prompted for and then refused at use.
assertBoundedManageGrant(entry);
await this.#prepareAppDataGrant(req.actor!, entry);
const plan = await this.#planFsPathCreate(
req.actor!,
@@ -3337,14 +3265,6 @@ export class AuthController extends PuterController {
{ legacyCode: 'bad_request' },
);
}
// Only unsharing retires the handle along with its grant; withdrawing
// the grant alone would leave the share listed and dead.
if (typeof permission === 'string' && isKvSharePermission(permission))
throw new HttpError(
400,
'Stop a key-value share with `puter.kv.unshare()` (`DELETE /kv/shares/:handle`)',
{ legacyCode: 'bad_request' },
);
await this.services.permission.revokeUserUserPermission(
req.actor!,
target_username,
@@ -3368,8 +3288,7 @@ export class AuthController extends PuterController {
})
async handleRevokeUserApp(req: Request, res: Response): Promise<void> {
let { app_uid } = req.body ?? {};
const { origin, permission, permissions, meta, deleteData } =
req.body ?? {};
const { origin, permission, permissions, meta } = req.body ?? {};
this.#validateAppPermissionParams({
app_uid,
origin,
@@ -3385,25 +3304,20 @@ export class AuthController extends PuterController {
legacyCode: 'bad_request',
});
}
if (deleteData !== undefined && typeof deleteData !== 'boolean') {
throw new HttpError(400, '`deleteData` must be a boolean', {
legacyCode: 'bad_request',
});
}
if (deleteData && permission !== '*') {
throw new HttpError(
400,
'`deleteData` only applies when `permission` is "*"',
{ legacyCode: 'bad_request' },
);
}
// Deliberately not gated by the target's sharing flag: a user must
// always be able to withdraw a grant, whatever the target now says.
if (permission === '*') {
await this.services.auth.uninstallApp(req.actor!, app_uid, {
deleteData: deleteData === true,
meta: meta ?? undefined,
});
await this.services.permission.revokeUserAppAll(
req.actor!,
app_uid,
meta ?? undefined,
);
// Withdrawing everything also retires the workers the app deployed
// under this user; their tokens would otherwise act as the app.
await this.services.auth.deleteWorkerSessionsForApp(
req.actor!.user!.id as number,
app_uid,
);
} else {
for (const entry of list) {
await this.services.permission.revokeUserAppPermission(
@@ -3483,28 +3397,11 @@ export class AuthController extends PuterController {
const unique = [...new Set(permissions)] as string[];
const result: Record<string, boolean> = {};
// A share grant names its owner and prefix, which its holder is never
// told, so asking about one would confirm a guess. `manage:` arms are
// the app's own consent and stay answerable.
const answerable: string[] = [];
for (const permission of unique) {
if (
typeof permission === 'string' &&
isKvSharePermission(permission)
)
result[permission] = false;
else answerable.push(permission);
}
// `fs:` reaches down a tree, and only ACL walks that. An app asking
// about itself gets the same answer, so a grant on a parent folder
// doesn't read as missing and prompt again.
// `fs:` reaches down a tree, and only ACL walks that.
const viaAcl = new Set(
app_uid || isAppActor(actor)
? answerable.filter((p) => aclAnswerableFsPermission(p))
: [],
app_uid ? unique.filter((p) => aclAnswerableFsPermission(p)) : [],
);
const viaScan = answerable.filter((p) => !viaAcl.has(p));
const viaScan = unique.filter((p) => !viaAcl.has(p));
let granted: Map<string, boolean>;
try {
@@ -3585,18 +3482,21 @@ export class AuthController extends PuterController {
{ legacyCode: 'bad_request' },
);
}
// Missing, already revoked and other users' rows all answer 404; an
// expired row is still revoked so its children go with it.
const session = await this.stores.session.getByUuidAny(uuid);
if (
!session ||
session.revoked_at != null ||
session.user_id !== req.actor!.user.id
) {
// `getByUuid` returns null when the row is missing, already
// soft-revoked, or past `expires_at` — surface as 404 so a stale
// manage-sessions UI doesn't 500 when it clicks revoke on a row
// that already went away.
const session = await this.stores.session.getByUuid(uuid);
if (!session) {
throw new HttpError(404, 'Session not found', {
legacyCode: 'not_found',
});
}
if (session.user_id !== req.actor!.user.id) {
throw new HttpError(403, 'Can only revoke your own sessions', {
legacyCode: 'unauthorized',
});
}
await this.services.auth.revokeSession(uuid);
const sessions = await this.services.auth.listSessions(req.actor!);
res.json({ sessions });
@@ -3848,9 +3748,8 @@ export class AuthController extends PuterController {
let app = await this.stores.app.getByUid(app_uid);
if (!app && resolvedFromOrigin) {
// Hosted-subdomain and custom-domain origins get the site owner
// stamped as the app's creator at bootstrap; external origins
// stay unowned.
// Hosted-subdomain origins get the site owner stamped as the
// app's creator at bootstrap; external origins stay unowned.
// Canonical origin only, so alternate hosts share one row.
const canonicalOrigin =
this.services.auth.canonicalizeOrigin(origin);
@@ -3866,10 +3765,9 @@ export class AuthController extends PuterController {
// An origin's uid is a deterministic uuidv5, so a deleted app
// reappears here under the identical uid. Withdraw any cross-app
// data grants left pointing at it before this new row can inherit
// consent the user gave its predecessor. Only `createFromOrigin`
// reuses a uid, here or wherever else an origin app is bootstrapped:
// `AppStore.create` mints a random uuid4, which no deleted app can
// ever hold again.
// consent the user gave its predecessor. Only *this* path can reuse
// a uid: `AppStore.create` mints a random uuid4, which no deleted
// app can ever hold again.
//
// Called directly rather than through `app.changed`: the token is
// issued below, so this has to be able to stop that, and
@@ -3880,9 +3778,6 @@ export class AuthController extends PuterController {
app_uid,
'uid reused by a new app',
);
// Key-value shares the predecessor left read as gone already,
// being older than this row; this only cleans them up.
this.services.kvShare.retireSharesOlderThanApp(app);
}
if (!app) {
throw new HttpError(404, `App ${app_uid} does not exist`, {
@@ -4318,27 +4213,7 @@ export class AuthController extends PuterController {
);
}
const code = req.body?.code;
if (
user.otp_enabled &&
typeof code === 'string' &&
/^[A-Z0-9]{8}$/.test(code)
) {
await this.#assertSecondFactorAttemptsRemain(user.uuid);
const consumed = await this.stores.user.consumeOtpRecoveryCode(
user.id,
hashRecoveryCode(code),
);
if (!consumed) {
await this.#recordSecondFactorFailure(user.uuid);
throw new HttpError(401, 'Incorrect code.', {
legacyCode: 'code_mismatch',
fields: { factor: 'otp' },
});
}
} else {
this.#requireAccountTwoFactor(user, code);
}
this.#requireAccountTwoFactor(user, req.body?.code);
await this.stores.user.update(user.id, {
otp_enabled: false,
@@ -4548,14 +4423,11 @@ export class AuthController extends PuterController {
}
const token = signStepUpToken(this.services.token, user as never);
const isApiHost = req.subdomains?.at(-1) === 'api';
if (!isApiHost) {
res.cookie(
STEP_UP_COOKIE_NAME,
token,
stepUpCookieOptions(this.config),
);
}
res.cookie(
STEP_UP_COOKIE_NAME,
token,
stepUpCookieOptions(this.config),
);
// A browser reads its elevation back from the httpOnly cookie and never
// needs the raw value; handing it to page JS would put the second factor
@@ -4564,9 +4436,7 @@ export class AuthController extends PuterController {
// password/TOTP — this only avoids needless exposure, it isn't a gate.
const cookieName = this.config.cookie_name ?? 'puter_token';
const usedSessionCookie =
!isApiHost &&
!!req.token &&
req.token === req.cookies?.[cookieName];
!!req.token && req.token === req.cookies?.[cookieName];
res.json(
usedSessionCookie ? { elevated: true } : { elevated: true, token },
);
@@ -4679,19 +4549,13 @@ export class AuthController extends PuterController {
await checkRateLimit(`${scope}:${userUid}`, limit, window);
}
/**
* An `otp-login` JWT completes one sign-in; a second redemption is refused.
* Fails closed: while the store can't say whether the token was used, the
* sign-in is refused rather than letting a captured token replay.
*/
/** An `otp-login` JWT completes one sign-in; a second redemption is refused. */
async #consumeOtpLoginToken(token: string): Promise<void> {
const digest = crypto.createHash('sha256').update(token).digest('hex');
const firstUse = await checkRateLimit(
`login-otp-token-used:${digest}`,
1,
OTP_LOGIN_TOKEN_USED_WINDOW_MS,
undefined,
{ failOpen: false },
);
if (!firstUse) {
throw new HttpError(400, 'Invalid token.', {
@@ -15,6 +15,7 @@ export interface OidcLoginContinuation {
embeddedInPopup: boolean;
openerOrigin: string | null;
messageId: string | null;
switchedToLogin: boolean;
}
interface OidcReturnDeps {
@@ -55,6 +56,7 @@ export function completeOidcLoginReturn(
data.userUuid !== user.uuid ||
typeof data.redirectUri !== 'string' ||
typeof data.embeddedInPopup !== 'boolean' ||
typeof data.switchedToLogin !== 'boolean' ||
(data.openerOrigin !== null && typeof data.openerOrigin !== 'string') ||
(data.messageId !== null && typeof data.messageId !== 'string')
) {
@@ -89,5 +91,6 @@ export function completeOidcLoginReturn(
),
);
}
if (data.switchedToLogin) append('oidc_switched', 'login');
return target;
}
@@ -1,89 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
import {
createTestUser,
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
describe('2FA after signing in with a recovery code', () => {
let env: PuterTestEnv;
const password = 'lost-authenticator-password';
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = (
path: string,
body: Record<string, unknown>,
cookie?: string,
) =>
fetch(new URL(path, env.origin), {
method: 'POST',
headers: {
'content-type': 'application/json',
...(cookie ? { cookie } : {}),
},
body: JSON.stringify(body),
});
it('allows an unused recovery code to disable 2FA after password revalidation', async () => {
const username = `lost2fa_${uuidv4().slice(0, 8)}`;
await createTestUser(env.server, { username, password });
const user = (await env.server.stores.user.getByUsername(username))!;
await env.server.stores.user.update(user.id, {
email: `${username}@example.com`,
email_confirmed: true,
otp_enabled: true,
otp_secret: 'TESTSECRETBASE32',
otp_recovery_codes: ['RECOVER1', 'RECOVER2', 'RECOVER3']
.map(hashRecoveryCode)
.join(','),
});
// Sign in with a recovery code: works, and yields a web session.
const login = await post('/login', { username, password });
expect(login.status).toBe(202);
const { otp_jwt_token } = await login.json();
const recovered = await post('/login/recovery-code', {
token: otp_jwt_token,
code: 'RECOVER1',
});
expect((await recovered.json()).next_step).toBe('complete');
const cookie = recovered.headers
.getSetCookie()
.find((value) => value.startsWith('puter_auth_token='))!
.split(';')[0]!;
const reused = await post(
'/user-protected/disable-2fa',
{ password, code: 'RECOVER1' },
cookie,
);
expect(reused.status).toBe(401);
expect(
(await env.server.stores.user.getById(user.id, { force: true }))!
.otp_enabled,
).toBeTruthy();
const disable = await post(
'/user-protected/disable-2fa',
{ password, code: 'RECOVER2' },
cookie,
);
expect(disable.status).toBe(200);
expect(await disable.json()).toMatchObject({ success: true });
const after = await env.server.stores.user.getById(user.id, {
force: true,
});
expect(after!.otp_enabled).toBeFalsy();
expect(after!.otp_secret).toBeNull();
expect(after!.otp_recovery_codes).toBeNull();
});
});
@@ -19,7 +19,6 @@
import type { Request, Response } from 'express';
import { Controller, Post } from '../../core/http/decorators.js';
import { schedulesUnavailable } from '../../core/schedules.js';
import type { BroadcastService } from '../../services/broadcast/BroadcastService.js';
import type { ForwardBatch } from '../../services/events/forwardQueue.js';
import { PuterController } from '../types.js';
@@ -109,42 +108,6 @@ export class BroadcastController extends PuterController {
);
res.status(200).json({ ok: true, ...reply });
}
/**
* Schedule changes a region made for schedules this one owns, handed to
* `services.schedules`; `events_disabled` on a server without it.
*/
@Post('/events/schedules', { subdomain: '*' })
async schedules(req: Request, res: Response): Promise<void> {
const broadcast = this.services.broadcast as unknown as
BroadcastService | undefined;
if (!broadcast) {
res.status(503).json({
error: { message: 'Broadcast service not registered' },
});
return;
}
const verified = await broadcast.verifySignedRequest(
req.rawBody,
broadcastHeaders(req),
);
if (!verified.ok) {
res.status(verified.status ?? 400).json({
error: { message: verified.message ?? 'Bad request' },
});
return;
}
if (verified.info?.ignored) {
res.status(200).json({ ok: true, ...verified.info });
return;
}
const schedules = this.services.schedules;
if (!schedules) throw schedulesUnavailable();
const reply = await schedules.receivePeerChanges(req.body);
res.status(200).json({ ok: true, ...reply });
}
}
const broadcastHeaders = (req: Request) => {
@@ -25,13 +25,18 @@ const ALLOWED_SORT_BY = ['name', 'size', 'modified', 'type'];
const ALLOWED_SORT_ORDER = ['asc', 'desc'];
/**
* Folder view preference routes: layout and sort_by/sort_order, stored on the
* folder's fsentries row.
* Desktop/UI preference routes.
*
* Two categories:
*
* - User-level: desktop background, taskbar items (UserStore)
* - Folder-level: layout, sort_by/sort_order (fsentries table)
*/
/**
* Desktop preference writes — layout, sort order. Each persists on every call,
* and the GUI fires them on direct user action, so a per-minute ceiling well
* above human speed is enough to catch a stuck client.
* Desktop preference writes — background, taskbar, layout, sort order. All four
* persist to the user row on every call, and the GUI fires them on direct user
* action, so a per-minute ceiling well above human speed is enough to catch a
* stuck client.
*/
const PREFERENCE_WRITE_LIMIT = {
scope: 'desktop-preference',
@@ -42,6 +47,89 @@ const PREFERENCE_WRITE_LIMIT = {
export class DesktopController extends PuterController {
registerRoutes(router) {
// -- Desktop background --------------------------------------
router.post(
'/set-desktop-bg',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { url, color, fit } = req.body ?? {};
const patch = {};
if (url !== undefined) {
if (url !== null && typeof url !== 'string') {
throw new HttpError(
400,
'`url` must be a string or null',
{ legacyCode: 'bad_request' },
);
}
patch.desktop_bg_url = url;
}
if (color !== undefined) {
if (color !== null && typeof color !== 'string') {
throw new HttpError(
400,
'`color` must be a string or null',
{ legacyCode: 'bad_request' },
);
}
patch.desktop_bg_color = color;
}
if (fit !== undefined) {
if (fit !== null && typeof fit !== 'string') {
throw new HttpError(
400,
'`fit` must be a string or null',
{ legacyCode: 'bad_request' },
);
}
patch.desktop_bg_fit = fit;
}
if (Object.keys(patch).length === 0) {
throw new HttpError(400, 'No fields provided', {
legacyCode: 'bad_request',
});
}
await this.stores.user.update(req.actor.user.id, patch);
res.json({});
},
);
// -- Taskbar items -------------------------------------------
router.post(
'/update-taskbar-items',
{
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: PREFERENCE_WRITE_LIMIT,
},
async (req, res) => {
const { items } = req.body ?? {};
if (!Array.isArray(items)) {
throw new HttpError(
400,
'Missing or invalid `items` array',
{ legacyCode: 'bad_request' },
);
}
await this.stores.user.update(req.actor.user.id, {
taskbar_items: JSON.stringify(items),
});
res.json({});
},
);
// -- Folder layout -------------------------------------------
router.post(
@@ -130,17 +130,147 @@ const callRoute = async (
});
};
describe('DesktopController routes', () => {
it('has no desktop background route', () => {
expect(
router.routes.some((r) => r.path === '/set-desktop-bg'),
).toBe(false);
// ── /set-desktop-bg ─────────────────────────────────────────────────
describe('DesktopController POST /set-desktop-bg', () => {
it('persists url/color/fit on the user row', async () => {
const { actor, userId } = await makeUser();
const { res, captured } = makeRes();
await callRoute(
'post',
'/set-desktop-bg',
makeReq({
body: {
url: 'https://cdn.test/wallpaper.png',
color: '#000000',
fit: 'cover',
},
actor,
}),
res,
);
expect(captured.body).toEqual({});
const refreshed = await server.stores.user.getById(userId);
expect(refreshed?.desktop_bg_url).toBe(
'https://cdn.test/wallpaper.png',
);
expect(refreshed?.desktop_bg_color).toBe('#000000');
expect(refreshed?.desktop_bg_fit).toBe('cover');
});
it('has no taskbar items route', () => {
expect(
router.routes.some((r) => r.path === '/update-taskbar-items'),
).toBe(false);
it('persists only the supplied fields (partial update)', async () => {
const { actor, userId } = await makeUser();
const { res } = makeRes();
await callRoute(
'post',
'/set-desktop-bg',
makeReq({ body: { color: '#ffffff' }, actor }),
res,
);
const refreshed = await server.stores.user.getById(userId);
expect(refreshed?.desktop_bg_color).toBe('#ffffff');
// Untouched fields remain at their column default.
expect(refreshed?.desktop_bg_url).toBeFalsy();
expect(refreshed?.desktop_bg_fit).toBeFalsy();
});
it('passes through `null` to clear a field', async () => {
const { actor, userId } = await makeUser();
// First populate, then clear.
const populate = makeRes();
await callRoute(
'post',
'/set-desktop-bg',
makeReq({
body: { url: 'https://cdn.test/x.png' },
actor,
}),
populate.res,
);
const before = await server.stores.user.getById(userId);
expect(before?.desktop_bg_url).toBe('https://cdn.test/x.png');
const clear = makeRes();
await callRoute(
'post',
'/set-desktop-bg',
makeReq({ body: { url: null }, actor }),
clear.res,
);
const after = await server.stores.user.getById(userId);
expect(after?.desktop_bg_url).toBeNull();
});
it('throws 400 when url is not a string or null', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set-desktop-bg',
makeReq({ body: { url: 123 }, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 when no fields are provided', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/set-desktop-bg',
makeReq({ body: {}, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
// ── /update-taskbar-items ───────────────────────────────────────────
describe('DesktopController POST /update-taskbar-items', () => {
it('persists items as a JSON-encoded string', async () => {
const { actor, userId } = await makeUser();
const items = [{ name: 'editor' }, { name: 'browser' }];
const { res } = makeRes();
await callRoute(
'post',
'/update-taskbar-items',
makeReq({ body: { items }, actor }),
res,
);
const refreshed = await server.stores.user.getById(userId);
expect(refreshed?.taskbar_items).toBe(JSON.stringify(items));
});
it('throws 400 when items is missing', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/update-taskbar-items',
makeReq({ body: {}, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 400 when items is not an array', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
callRoute(
'post',
'/update-taskbar-items',
makeReq({ body: { items: 'oops' }, actor }),
res,
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
@@ -21,7 +21,7 @@
import { EventEmitter } from 'node:events';
import type { Request, RequestHandler, Response } from 'express';
import { beforeEach, describe, expect, it } from 'vitest';
import { Context, runWithContext } from '../../core/context.js';
import { runWithContext } from '../../core/context.js';
import { isHttpError } from '../../core/http/HttpError.js';
import { configureRateLimit } from '../../core/http/middleware/rateLimit.js';
import { DriverController } from './DriverController.js';
@@ -278,86 +278,6 @@ describe('DriverController — concurrent acquire/release', () => {
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
});
// A driver that keeps working after the caller hangs up (async video
// polling) must keep counting against the cap until it settles.
it('holds the slot while the driver still works after a disconnect', async () => {
let finish = () => {};
const driver = {
...makeSyntheticDriver(),
ping: () =>
new Promise<string>((resolve) => {
finish = () => resolve('pong');
}),
};
const handler = captureCallHandler(buildController(driver));
const req = () =>
makeReq({ interface: 'test-iface', method: 'ping' }, 'working');
const res1 = new StubRes();
const first = callInScope(handler, req(), res1 as unknown as Response);
await new Promise((r) => setImmediate(r));
res1.emit('close');
await new Promise((r) => setImmediate(r));
await expect(
callInScope(handler, req(), new StubRes() as unknown as Response),
).rejects.toSatisfy(
(e) =>
isHttpError(e) &&
(e as { statusCode: number }).statusCode === 429,
);
finish();
await first;
await new Promise((r) => setImmediate(r));
const res2 = new StubRes();
const second = callInScope(handler, req(), res2 as unknown as Response);
await new Promise((r) => setImmediate(r));
finish();
await second;
res2.emit('finish');
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
});
it('holds the slot for work a driver keeps past its response', async () => {
let settleWork = () => {};
const driver = {
...makeSyntheticDriver(),
ping: async () => {
Context.get('keepDriverSlot')?.(
new Promise<void>((resolve) => {
settleWork = resolve;
}),
);
return 'pong';
},
};
const handler = captureCallHandler(buildController(driver));
const req = () =>
makeReq({ interface: 'test-iface', method: 'ping' }, 'kept');
const res1 = new StubRes();
await callInScope(handler, req(), res1 as unknown as Response);
res1.emit('finish');
await new Promise((r) => setImmediate(r));
await expect(
callInScope(handler, req(), new StubRes() as unknown as Response),
).rejects.toSatisfy(
(e) =>
isHttpError(e) &&
(e as { statusCode: number }).statusCode === 429,
);
settleWork();
await new Promise((r) => setImmediate(r));
const res2 = new StubRes();
await callInScope(handler, req(), res2 as unknown as Response);
res2.emit('finish');
settleWork();
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
});
it('does not attach release listeners when the driver declares no concurrent config', async () => {
// With no spec there is nothing to release, so the gate must not
// wire `finish`/`close` release listeners. The client-disconnect
@@ -501,29 +501,6 @@ describe('DriverController.#handleCall (via captured router)', () => {
expect(body.service.name).toBe('puter-kvstore');
});
it('treats null `args` as no arguments', async () => {
const actor = await makeUserActor();
await server.stores.permission.setFlatUserPerm(
actor.user!.id!,
'service:puter-subdomains:ii:puter-subdomains',
{
permission: 'service:puter-subdomains:ii:puter-subdomains',
deleted: false,
issuer_user_id: actor.user!.id!,
} as never,
);
const res = makeRes();
const req = makeReq(
{ interface: 'puter-subdomains', method: 'select', args: null },
actor,
);
await runWithContext({ actor }, () =>
routes['POST /call'](req, res as unknown as Response, () => {}),
);
expect((res.body as { success: boolean }).success).toBe(true);
});
it('pipes a stream-shaped result instead of JSON', async () => {
// Use an ephemeral driver registered onto the controller's bag.
const streamPayload = Buffer.from('hello-world');
@@ -759,42 +736,6 @@ describe('DriverController driver-method lifecycle events', () => {
rejectReason: 'blocked in test',
});
});
it('answers 429 when a before listener vetoes with rejectStatus 429', async () => {
const actor = await makeUserActor();
await grantKvPerm(actor);
const key = `throttle-${uuidv4()}`;
server.clients.event.on(
'driver.puter-kvstore.set.before',
(_k, data) => {
if ((data.args as { key?: string })?.key === key) {
data.allow = false;
data.rejectStatus = 429;
}
},
);
const req = makeReq(
{
interface: 'puter-kvstore',
method: 'set',
args: { key, value: 'v' },
},
actor,
);
await expect(
runWithContext({ actor }, () =>
routes['POST /call'](
req,
makeRes() as unknown as Response,
() => {},
),
),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'too_many_requests',
});
});
});
describe('DriverController.#handleListInterfaces', () => {
@@ -31,7 +31,6 @@ import {
checkDriverRateLimit,
} from '../../core/http/middleware/rateLimit.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import type { LifecycleRejectStatus } from '../../clients/event/types.js';
import type { DriverMeta } from '../../drivers/meta.js';
import {
isCreditExhaustion,
@@ -51,7 +50,6 @@ import { PermissionUtil } from '../../services/permission/permissionUtil.js';
import type { WithLifecycle } from '../../types';
import { withSpan } from '../../util/span.js';
import { PuterController } from '../types.js';
import { CallSlot } from './callSlot.js';
type DriverInstance = WithLifecycle & Record<string, unknown>;
@@ -261,25 +259,12 @@ export class DriverController extends PuterController {
// -- Handlers ----------------------------------------------------
#handleCall = async (req: Request, res: Response): Promise<void> => {
const slot = new CallSlot();
const call = this.#call(req, res, slot);
slot.keep(call);
await call;
};
#call = async (
req: Request,
res: Response,
slot: CallSlot,
): Promise<void> => {
const {
interface: ifaceName,
method,
driver: driverName,
args: rawArgs,
args = {},
} = (req.body ?? {}) as Record<string, unknown>;
// Drivers read fields off `args`, so a null one counts as absent.
const args = rawArgs ?? {};
if (!ifaceName || typeof ifaceName !== 'string') {
throw new HttpError(400, 'Missing or invalid `interface`', {
@@ -337,8 +322,7 @@ export class DriverController extends PuterController {
if (req.actor) {
const permService = this.services.permission as unknown as
| PermissionService
| undefined;
PermissionService | undefined;
if (permService) {
// Build via PermissionUtil.join so any `:` in a driver or
// interface name is escaped — raw interpolation would let a
@@ -411,9 +395,10 @@ export class DriverController extends PuterController {
// Per-method rate-limit and concurrent specs both live on the
// driver's resolved meta. Rate-limit is single-shot; concurrent
// acquires a slot held until the response is done and the call's
// work has settled, so streamed responses hold it until the stream
// drains and work that continues after a disconnect still counts.
// acquires a slot that must be released when the response is done
// — we hook `res.finish` / `res.close` for that so streamed
// responses hold their slot until the stream drains, and aborted
// requests still give the slot back.
const rateLimitSpec = resolvePerMethod(driverMeta?.rateLimit, method);
if (
!(await checkDriverRateLimit(req, ifaceName, method, rateLimitSpec))
@@ -445,8 +430,18 @@ export class DriverController extends PuterController {
legacyCode: 'too_many_requests',
});
}
slot.attach(handle, res);
Context.set('keepDriverSlot', (work) => slot.keep(work));
let released = false;
const release = () => {
if (released) return;
released = true;
void handle.release();
};
// If the handler throws before responding, the express error
// handler will eventually send a response — `finish` fires then,
// so we still release. `close` covers client aborts.
res.once('finish', release);
res.once('close', release);
if (res.destroyed || res.writableFinished) release();
}
// Stash the requested driver name in Context so multi-provider
@@ -479,7 +474,6 @@ export class DriverController extends PuterController {
args,
allow: true as boolean,
rejectReason: undefined as string | undefined,
rejectStatus: undefined as LifecycleRejectStatus | undefined,
};
await this.clients.event?.emitAndWait(
`driver.${ifaceName}.${method}.before`,
@@ -501,12 +495,11 @@ export class DriverController extends PuterController {
},
{},
);
const throttled = beforeEvent.rejectStatus === 429;
throw new HttpError(
throttled ? 429 : 403,
403,
beforeEvent.rejectReason ??
`Blocked by policy: ${ifaceName}:${method}`,
{ legacyCode: throttled ? 'too_many_requests' : 'forbidden' },
{ legacyCode: 'forbidden' },
);
}
@@ -1,73 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { EventEmitter } from 'node:events';
import type { Response } from 'express';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CallSlot } from './callSlot.js';
const handle = () => ({
release: vi.fn(() => Promise.resolve()),
renew: vi.fn(() => Promise.resolve()),
});
const deferred = () => {
let resolve = () => {};
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
};
afterEach(() => {
vi.useRealTimers();
});
describe('CallSlot', () => {
it('renews a slot held by a long call and stops once it is released', async () => {
vi.useFakeTimers();
const slot = new CallSlot();
const slotHandle = handle();
const res = new EventEmitter();
const call = deferred();
slot.keep(call.promise);
slot.attach(slotHandle, res as unknown as Response);
res.emit('close');
await vi.advanceTimersByTimeAsync(25 * 60 * 1000);
expect(slotHandle.renew).toHaveBeenCalledTimes(2);
expect(slotHandle.release).not.toHaveBeenCalled();
call.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(slotHandle.release).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(25 * 60 * 1000);
expect(slotHandle.renew).toHaveBeenCalledTimes(2);
});
it('releases once the response is done if no work is outstanding', async () => {
const slot = new CallSlot();
const slotHandle = handle();
const res = new EventEmitter();
slot.attach(slotHandle, res as unknown as Response);
res.emit('finish');
expect(slotHandle.release).toHaveBeenCalledOnce();
expect(slotHandle.renew).not.toHaveBeenCalled();
});
});
@@ -1,80 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Response } from 'express';
// Under the concurrency store's one-hour orphan TTL.
const SLOT_RENEW_MS = 10 * 60 * 1000;
interface SlotHandle {
release(): Promise<void>;
renew?(): Promise<void>;
}
/**
* A driver call's concurrency slot, held until the response is done and the
* call's work has settled, including work a driver carries on after the caller
* hung up or was answered.
*/
export class CallSlot {
#handle?: SlotHandle;
#work = new Set<Promise<unknown>>();
#responded = false;
#released = false;
#renew?: NodeJS.Timeout;
attach(handle: SlotHandle, res: Response): void {
this.#handle = handle;
const responded = () => {
this.#responded = true;
this.#maybeRelease();
};
res.once('finish', responded);
res.once('close', responded);
if (res.destroyed || res.writableFinished) responded();
if (!this.#released && this.#work.size > 0) this.#startRenewing();
}
keep(work: Promise<unknown>): void {
if (this.#released) return;
this.#work.add(work);
if (this.#handle) this.#startRenewing();
const settled = () => {
this.#work.delete(work);
this.#maybeRelease();
};
work.then(settled, settled);
}
#startRenewing(): void {
if (this.#renew) return;
this.#renew = setInterval(() => {
void this.#handle?.renew?.();
}, SLOT_RENEW_MS);
this.#renew.unref?.();
}
#maybeRelease(): void {
if (this.#released || !this.#handle) return;
if (!this.#responded || this.#work.size > 0) return;
this.#released = true;
clearInterval(this.#renew);
void this.#handle.release();
}
}
@@ -25,11 +25,6 @@ import { bodyRecord } from '../../core/http/requestBody.js';
import { DURABLE_LIST_LIMIT_CAP } from '../../stores/events/DurableSubscriptionStore.js';
import { EVENTS_WORKERS_LIST_LIMIT_CAP } from '../../stores/events/EventHandlerStore.js';
import { KV_HANDLE_LIST_LIMIT_CAP } from '../../stores/events/KvShareHandleStore.js';
import {
SCHEDULE_LIST_LIMIT_CAP,
schedulesUnavailable,
type ScheduleServiceContract,
} from '../../core/schedules.js';
import { normalizeLimit } from '../../util/pagination.js';
import { PuterController } from '../types.js';
import { EVENTS_WORKER_PREFIX } from '../../services/events/workerRuntime.js';
@@ -64,8 +59,6 @@ const EVENTS_SCRIPT_REGEX = new RegExp(`^${EVENTS_WORKER_PREFIX}[a-f0-9]{32}$`);
/** Matches the edge dispatcher's own validation of the app uid it forwards. */
const APP_UID_REGEX = /^app-[A-Za-z0-9_-]{1,64}$/;
/** A user uuid. */
const HOLDER_REGEX = /^[A-Za-z0-9-]{1,64}$/;
@Controller('/events')
export class EventsController extends PuterController {
@@ -111,31 +104,12 @@ export class EventsController extends PuterController {
});
}
/**
* POST /events/subscriptions/update — replace one subscription's `context`
* in place. An id the caller does not hold reads as absent.
*/
@Post('/subscriptions/update', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
})
async update(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const { sub } = await this.services.events.updateDurable(
actor,
bodyRecord(req),
);
res.json(sub);
}
/**
* GET /events/fetch — what the caller missed, one page at a time.
*
* Stateless: no subscription, no stored position, nothing written. The
* cursor comes back to the caller and goes out again as `cursor` (or
* `after`, the name it first shipped under). `from=end` with no cursor
* starts at the newest row instead of the oldest.
* `after`, the name it first shipped under).
*/
@Get('/fetch', {
subdomain: 'api',
@@ -146,16 +120,9 @@ export class EventsController extends PuterController {
async fetch(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const query = (req.query ?? {}) as Record<string, unknown>;
const from = query.from as 'start' | 'end' | undefined;
if (from !== undefined && from !== 'start' && from !== 'end') {
throw new HttpError(400, '`from` must be `start` or `end`', {
legacyCode: 'bad_request',
});
}
const page = await this.services.events.fetchMissed(actor, {
subject: typeof query.subject === 'string' ? query.subject : '',
from,
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
after: typeof query.after === 'string' ? query.after : undefined,
limit: normalizeLimit(query.limit, {
@@ -184,105 +151,11 @@ export class EventsController extends PuterController {
res.json({});
}
// -- Schedules ---------------------------------------------------
//
// An app's handler run on a timer, served by `services.schedules` when a
// server has it and answered `events_disabled` when it doesn't. Creating
// one needs an app context; listing, reading and removing are scoped the
// way the durable verbs are. The create and remove verbs spend the
// subscribe and unsubscribe budgets in the service, shared with those
// verbs.
#schedules(): ScheduleServiceContract {
const schedules = this.services.schedules;
if (!schedules) throw schedulesUnavailable();
return schedules;
}
/** POST /events/schedules — create a schedule for the calling app. */
@Post('/schedules', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
})
async createSchedule(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
res.json(await this.#schedules().createFor(actor, bodyRecord(req)));
}
/** GET /events/schedules — the caller's schedules, one page at a time. */
@Get('/schedules', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
rateLimit: EVENTS_LIST_LIMIT,
})
async listSchedules(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const query = (req.query ?? {}) as Record<string, unknown>;
const page = await this.#schedules().list(actor, {
limit: normalizeLimit(query.limit, {
cap: SCHEDULE_LIST_LIMIT_CAP,
}),
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
includeTotal: query.includeTotal === 'true',
});
res.json({
items: page.items,
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total !== undefined ? { total: page.total } : {}),
});
}
@Post('/schedules/update', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
})
async updateSchedule(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const { scheduleId, changes } = bodyRecord(req);
res.json(await this.#schedules().update(actor, scheduleId, changes));
}
/**
* POST /events/schedules/remove — end one schedule. An id the caller does
* not hold reads as absent.
*/
@Post('/schedules/remove', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
})
async removeSchedule(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
await this.#schedules().remove(actor, bodyRecord(req).scheduleId);
res.json({});
}
/**
* GET /events/schedules/:scheduleId — one schedule, ended or not. An id the
* caller does not hold reads as absent.
*/
@Get('/schedules/:scheduleId', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
rateLimit: EVENTS_LIST_LIMIT,
})
async getSchedule(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
res.json(await this.#schedules().get(actor, req.params.scheduleId));
}
// -- Cross-user key-value handles --------------------------------
/**
* POST /events/kv-handles — hand another user a region of this account's
* key-value data, `watch` unless the body asks for more. The older name for
* `POST /kv/shares`, keeping its own body and response shapes.
* POST /events/kv-handles — hand another user a watchable region of this
* account's key-value data.
*
* An account session, or an app session holding a `manage:` delegation on
* the region — the service tells those apart and gates each rather than the
@@ -294,32 +167,12 @@ export class EventsController extends PuterController {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
requireVerified: true,
})
async mintKvHandle(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const body = bodyRecord(req);
const share = await this.services.kvShare.mintKvHandle(
actor,
{
prefix: body.prefix,
recipient: {
username:
typeof body.granteeUsername === 'string'
? body.granteeUsername
: undefined,
uid:
typeof body.granteeUid === 'string'
? body.granteeUid
: undefined,
},
access: body.access,
appUid: body.appUid,
},
// Callers of this route mint again to get the same handle back.
{ defaultAccess: 'watch', keepLevelUnlessNamed: true },
res.json(
await this.services.events.mintKvHandle(actor, bodyRecord(req)),
);
res.json({ handle: share.handle, prefix: share.prefix });
}
/**
@@ -336,7 +189,7 @@ export class EventsController extends PuterController {
const actor = this.#requireActor(req);
const query = (req.query ?? {}) as Record<string, unknown>;
const page = await this.services.kvShare.listKvHandles(actor, {
const page = await this.services.events.listKvHandles(actor, {
limit: normalizeLimit(query.limit, {
cap: KV_HANDLE_LIST_LIMIT_CAP,
}),
@@ -345,10 +198,7 @@ export class EventsController extends PuterController {
});
res.json({
items: page.items.map(({ holderUsername, ...row }) => ({
...row,
granteeUsername: holderUsername,
})),
items: page.items,
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total !== undefined ? { total: page.total } : {}),
});
@@ -367,10 +217,7 @@ export class EventsController extends PuterController {
async revokeKvHandle(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
res.json(
await this.services.kvShare.revokeKvHandle(
actor,
req.params.handle,
),
await this.services.events.revokeKvHandle(actor, req.params.handle),
);
}
@@ -423,7 +270,6 @@ export class EventsController extends PuterController {
handlers: await this.services.events.listHandlers(actor, {
appUid:
typeof query.appUid === 'string' ? query.appUid : undefined,
scope: query.scope,
}),
});
}
@@ -522,15 +368,6 @@ export class EventsController extends PuterController {
throw new HttpError(400, 'Missing or invalid `script`/`appUid`', {
legacyCode: 'bad_request',
});
// Absent or empty is the app's own scope.
const holder = body.holder === undefined ? '' : body.holder;
if (
typeof holder !== 'string' ||
!(holder === '' || HOLDER_REGEX.test(holder))
)
throw new HttpError(400, 'Invalid `holder`', {
legacyCode: 'bad_request',
});
const deployer = this.#eventsWorkerDeployer();
if (!deployer.enabled) {
@@ -538,25 +375,15 @@ export class EventsController extends PuterController {
return;
}
const scope = await deployer.scopeFor(appUid, holder);
if (!scope) {
res.status(404).json({ deployed: false, reason: 'no-holder' });
return;
}
const outcome = await deployer.ensure(scope, script);
const outcome = await deployer.ensure(appUid, script);
if (outcome === 'deployed') {
res.json({ deployed: true });
return;
}
// `failed`, `throttled` and `busy` are worth another attempt: the rest
// say this script is not what the app's handlers currently are.
// `failed` and `throttled` are worth another attempt: the rest say
// this script is not what the app's handlers currently are.
res.status(
outcome === 'failed' ||
outcome === 'throttled' ||
outcome === 'busy'
? 502
: 404,
outcome === 'failed' || outcome === 'throttled' ? 502 : 404,
).json({
deployed: false,
reason: outcome,
@@ -577,22 +404,14 @@ export class EventsController extends PuterController {
override onServerStart(): void {
const deployer = this.#eventsWorkerDeployer();
if (!deployer.enabled) return;
this.services.events.useEagerDeploy((scope, script) =>
deployer.deployEagerly(scope, script),
);
if (this.config.workers?.localServer) {
this.services.events.useWorkerTransport(
new LocalEventsInvokeTransport(this.services, deployer),
);
return;
}
this.services.events.useWorkerMissHandler(
async (appUid, script, options) => {
const scope = await deployer.scopeFor(appUid, options.holder);
return scope
? deployer.ensure(scope, script, options)
: 'no-holder';
},
this.services.events.useWorkerMissHandler((appUid, script) =>
deployer.ensure(appUid, script),
);
}
+11 -73
View File
@@ -34,46 +34,23 @@ import {
EVENTS_DURABLE_SUBSCRIPTIONS_MAX,
EVENTS_DURABLE_SUBSCRIPTIONS_PER_APP,
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
EVENTS_HANDLER_PUBLISH_LIMIT,
EVENTS_HANDLER_SOURCE_MAX_BYTES,
EVENTS_HANDLERS_PER_APP,
EVENTS_KV_HANDLES_PER_APP,
EVENTS_KV_HANDLES_PER_USER,
EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT,
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT,
EVENTS_SINGLE_DELIVERY_LIMIT,
EVENTS_USER_HANDLERS_PER_SCOPE,
EVENTS_USER_SCOPE_SOURCE_MAX_BYTES,
EVENTS_USER_WORKER_DEPLOYS_PER_HOUR,
EVENTS_WORKER_DEPLOYS_PER_HOUR,
EVENTS_WORKER_INVOCATION_LIMIT,
EVENTS_WORKER_SOURCE_MAX_BYTES,
RENTED_UNITS_PER_USER,
limitFor,
tiered,
type TieredLimit,
} from './limits.js';
const tiers: Array<[string, TieredLimit]> = [
[
'durable subscriptions per account',
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
],
['durable subscriptions per account', EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER],
['durable subscriptions per app', EVENTS_DURABLE_SUBSCRIPTIONS_PER_APP],
['live share handles per account', EVENTS_KV_HANDLES_PER_USER],
['live share handles per app', EVENTS_KV_HANDLES_PER_APP],
];
describe('tiered', () => {
it('gives paid plans the base, and the free and temporary tiers their own', () => {
const limit = tiered(30, 20, 10);
expect(limitFor(limit, null)).toBe(30);
expect(limitFor(limit, 'some_plan')).toBe(30);
expect(limitFor(limit, DEFAULT_FREE_SUBSCRIPTION)).toBe(20);
expect(limitFor(limit, DEFAULT_TEMP_SUBSCRIPTION)).toBe(10);
});
});
describe('the tiered subscription quotas', () => {
it.each(tiers)('%s never lets a free tier exceed paid', (_name, tier) => {
for (const n of Object.values(tier.bySubscription))
@@ -128,9 +105,9 @@ describe('the tiered subscription quotas', () => {
});
it('holds an unrecognised plan to the paid base', () => {
expect(
limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, 'some_plan'),
).toBe(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit);
expect(limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, 'some_plan')).toBe(
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit,
);
expect(limitFor(EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER, null)).toBe(
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER.limit,
);
@@ -154,22 +131,22 @@ describe('the delivery budgets', () => {
expect(
limitFor(EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT, plan),
).toBe(matched);
expect(limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan)).toBe(
evaluated,
);
expect(
limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan),
).toBe(evaluated);
expect(evaluated).toBeGreaterThanOrEqual(matched);
}
});
it('pins the share-handle quotas by plan', () => {
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(25_000);
expect(EVENTS_KV_HANDLES_PER_APP.limit).toBe(10_000);
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(512);
expect(EVENTS_KV_HANDLES_PER_APP.limit).toBe(512);
expect(
limitFor(EVENTS_KV_HANDLES_PER_USER, DEFAULT_FREE_SUBSCRIPTION),
).toBe(5_000);
).toBe(200);
expect(
limitFor(EVENTS_KV_HANDLES_PER_APP, DEFAULT_FREE_SUBSCRIPTION),
).toBe(2_000);
).toBe(128);
});
it('keeps `single` well under broadcast — each one costs far more', () => {
@@ -192,42 +169,3 @@ describe('the delivery budgets', () => {
}
});
});
describe('the handler limits', () => {
it('pins the app-scope numbers the quotas page lists', () => {
expect(EVENTS_HANDLERS_PER_APP).toBe(100);
expect(EVENTS_HANDLER_SOURCE_MAX_BYTES).toBe(64 * 1024);
expect(EVENTS_WORKER_SOURCE_MAX_BYTES).toBe(5 * 1024 * 1024);
expect(EVENTS_WORKER_DEPLOYS_PER_HOUR).toBe(30);
expect(EVENTS_HANDLER_PUBLISH_LIMIT.limit).toBe(60);
expect(EVENTS_HANDLER_PUBLISH_LIMIT.window).toBe(60_000);
});
it('pins the user-scope numbers the quotas page lists', () => {
expect(EVENTS_USER_HANDLERS_PER_SCOPE).toBe(20);
expect(EVENTS_USER_SCOPE_SOURCE_MAX_BYTES).toBe(1024 * 1024);
expect(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR).toBe(10);
});
it('keeps a user scope tighter than an app scope', () => {
expect(EVENTS_USER_HANDLERS_PER_SCOPE).toBeLessThan(
EVENTS_HANDLERS_PER_APP,
);
expect(EVENTS_USER_SCOPE_SOURCE_MAX_BYTES).toBeLessThan(
EVENTS_WORKER_SOURCE_MAX_BYTES,
);
expect(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR).toBeLessThan(
EVENTS_WORKER_DEPLOYS_PER_HOUR,
);
});
it('pins the rented-unit cap by plan', () => {
expect(RENTED_UNITS_PER_USER.limit).toBe(100);
expect(limitFor(RENTED_UNITS_PER_USER, DEFAULT_FREE_SUBSCRIPTION)).toBe(
16,
);
expect(limitFor(RENTED_UNITS_PER_USER, DEFAULT_TEMP_SUBSCRIPTION)).toBe(
16,
);
});
});
+27 -122
View File
@@ -53,12 +53,7 @@ export interface TieredLimit {
bySubscription: Record<string, number>;
}
/** Paid, free and temporary caps as a {@link TieredLimit}. Called by extensions. */
export const tiered = (
paid: number,
free: number,
temp: number,
): TieredLimit => ({
const tiered = (paid: number, free: number, temp: number): TieredLimit => ({
limit: paid,
bySubscription: {
[DEFAULT_FREE_SUBSCRIPTION]: free,
@@ -98,18 +93,6 @@ export interface TieredQuota {
*/
export const EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET = 50;
/**
* Live subscriptions one user may hold through a share handle someone else
* minted for them, across all of their connections.
*
* Those rows sit in the owner's keyspace, where each one costs a filter
* evaluation on the owner's writes. The count also takes in connections that
* dropped and haven't been reaped yet, so the cap sits far above what
* reconnects leave behind. The owner's own rows through the handle don't
* count.
*/
export const EVENTS_SESSION_SUBSCRIPTIONS_PER_KV_HANDLE = 1000;
/**
* Durable subscriptions one account may hold, across every app.
*
@@ -142,13 +125,6 @@ export const EVENTS_DURABLE_SUBSCRIPTIONS_MAX =
*/
export const SUSPENDED_ROW_TTL_DAYS = 30;
/**
* Subscriptions stopped by a revoked grant that one account keeps for `list`.
* They hold no quota slot, so past this the oldest are deleted when the account
* subscribes again, keeping its rows bounded.
*/
export const EVENTS_REVOKED_SUBSCRIPTIONS_KEPT = 500;
/**
* Subscribe calls per minute, per user.
*
@@ -179,13 +155,6 @@ export const EVENTS_LIST_LIMIT = userWindow('events:list', 120);
*/
export const EVENTS_ACK_LIMIT = userWindow('events:ack', 600);
/**
* How long a connected client holds a queued delivery before the events worker
* may take it. A client that has no handler for it, or whose handler fails,
* declines it at once; this only bounds one that never answers.
*/
export const EVENTS_SOCKET_LEASE_MS = 15_000;
/**
* Share-handle mint + revoke calls per minute, per user.
*
@@ -194,68 +163,23 @@ export const EVENTS_SOCKET_LEASE_MS = 15_000;
*/
export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60);
/**
* `kv.share.received` notices per hour, per owner and holder. A share past it
* is still made, without a notice.
*/
export const KV_SHARE_NOTICE_LIMIT = userWindow(
'kvShare:notices',
5,
60 * 60_000,
);
/**
* Live share handles one account may hold out at a time, across every app.
*
* A backstop above the per-app cap: every count reads a covering index, so the
* bound is on standing grants, not query cost. A temporary account holds none:
* the grant outlives the session that made it, and no account is left to take
* it back.
* Each is a standing grant on part of the account's data, and revoking marks
* rather than deletes, so without a ceiling the rate limit alone lets the rows
* grow forever. A temporary account holds none: the grant outlives the session
* that made it, and no account is left to take it back.
*/
export const EVENTS_KV_HANDLES_PER_USER = tiered(25_000, 5_000, 0);
export const EVENTS_KV_HANDLES_PER_USER = tiered(512, 200, 0);
/**
* Live share handles one app may hold out for one account, so one namespace
* cannot consume the whole account budget.
* Live share handles one app may hold out for one account.
*
* Separately bounded from the account cap so one namespace cannot consume the
* whole budget on free accounts.
*/
export const EVENTS_KV_HANDLES_PER_APP = tiered(10_000, 2_000, 0);
/**
* Live share handles one owner may hold out to one holder, on every plan.
* Bounds the rows a revoke or unshare walks to find the narrower handles inside
* one.
*/
export const EVENTS_KV_HANDLES_PER_HOLDER = 256;
/**
* Recipients one account may share with, per minute and per hour. A call spends
* one per recipient that doesn't already hold the share, on top of the per-call
* mint budget.
*/
export const KV_SHARE_GRANT_LIMITS = [
userWindow('kvShare:grants:minute', 300),
userWindow('kvShare:grants:hour', 2_000, 60 * 60_000),
] as const;
/**
* Recipients one account may name in share calls per minute, existing shares
* included. Each one is looked up before the call knows whether it changes
* anything, so a client that shares again on every load is bounded here.
*/
export const KV_SHARE_LOOKUP_LIMIT = userWindow('kvShare:lookups', 1_200);
/** Recipients one `POST /kv/shares` call may name. */
export const KV_SHARE_BATCH_MAX = 50;
/**
* Notifications one party of a share may send the other through share handles,
* per minute and per day, in one app. Keeps one share from spending the
* recipient's whole notification allowance.
*/
export const KV_SHARE_NOTIFY_LIMITS = [
userWindow('kvShare:notify:minute', 30),
userWindow('kvShare:notify:day', 500, 24 * 60 * 60_000),
] as const;
export const EVENTS_KV_HANDLES_PER_APP = tiered(512, 128, 0);
// -- Handler surface -------------------------------------------------
@@ -269,12 +193,6 @@ export const KV_SHARE_NOTIFY_LIMITS = [
*/
export const EVENTS_HANDLERS_PER_APP = 100;
/**
* Handlers one user may hold in their own scope of one app. Each is a script of
* its own, so the cap is tighter than the app's.
*/
export const EVENTS_USER_HANDLERS_PER_SCOPE = 20;
/** Longest a serialized handler may be. */
export const EVENTS_HANDLER_SOURCE_MAX_BYTES = 64 * 1024;
@@ -286,9 +204,6 @@ export const EVENTS_HANDLER_SOURCE_MAX_BYTES = 64 * 1024;
*/
export const EVENTS_WORKER_SOURCE_MAX_BYTES = 5 * 1024 * 1024;
/** Source one user scope may hold, all its handlers combined. */
export const EVENTS_USER_SCOPE_SOURCE_MAX_BYTES = 1024 * 1024;
/**
* Deploys one app's events worker may trigger per hour.
*
@@ -298,16 +213,6 @@ export const EVENTS_USER_SCOPE_SOURCE_MAX_BYTES = 1024 * 1024;
*/
export const EVENTS_WORKER_DEPLOYS_PER_HOUR = 30;
/** Deploys one user scope may trigger per hour. */
export const EVENTS_USER_WORKER_DEPLOYS_PER_HOUR = 10;
/**
* Units one user may rent at once: ordinary workers, user scopes, and the
* events workers of apps they own. Only creating a unit is refused; holdings
* over the cap are kept.
*/
export const RENTED_UNITS_PER_USER = tiered(100, 16, 16);
/**
* Handlers one `publishAll` may carry. A build step publishes its whole set in
* one call, and the set is capped by what an app may hold anyway.
@@ -372,11 +277,8 @@ export const EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT = tiered(512, 128, 128);
*/
export const EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT = tiered(2048, 512, 512);
/**
* Deliveries of one key-value change that carry its value inline, in the order
* the fan-out serves them. Later ones carry the key alone.
*/
export const EVENTS_KV_VALUE_DELIVERIES_PER_EVENT = 128;
/** Omit all inline KV values when the matched audience exceeds this count. */
export const EVENTS_KV_VALUE_OMIT_MATCHED_SUBSCRIPTIONS = 128;
/**
* Largest inline KV value in serialized bytes. Larger values are omitted;
@@ -468,20 +370,16 @@ export const EVENTS_REGION_PENDING_CEILING = 1_000_000;
// a handler that answers "no" is not. Retrying on a fixed cadence turns one
// broken deploy into a permanent load on whatever is failing, so the wait
// doubles per attempt up to a ceiling, and a run of failures stops the
// subscription rather than retrying it forever. A run Puter could not start or
// finish backs off the same way but is not the handler's failure, so it never
// counts toward that run.
// subscription rather than retrying it forever.
// Wait before the first retry of a delivery a handler could not take, the
// longest wait, and the doubling between them. Shared with system jobs.
export {
deliveryBackoffMs,
EVENTS_RETRY_BASE_MS,
EVENTS_RETRY_MAX_MS,
} from '../../util/retryBackoff.js';
/** Wait before the first retry of a delivery a handler could not take. */
export const EVENTS_RETRY_BASE_MS = 2_000;
/** Longest wait between retries, however many have failed. */
export const EVENTS_RETRY_MAX_MS = 5 * 60 * 1000;
/**
* Handler failures in a row before a subscription is suspended. Counted per
* Failures in a row before a subscription is suspended. Counted per
* subscription and reset by the first delivery a handler takes, so an
* occasional failure never accumulates into one.
*/
@@ -494,6 +392,13 @@ export const EVENTS_CONSECUTIVE_FAILURES = 5;
*/
export const EVENTS_FAILURE_COUNTER_TTL_MS = 60 * 60 * 1000;
/** How long to hold a delivery whose handler has failed `attempts` times. */
export const deliveryBackoffMs = (attempts: number): number =>
Math.min(
EVENTS_RETRY_MAX_MS,
EVENTS_RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1),
);
// -- Suspended backlog -----------------------------------------------
//
// A suspended subscription stops metering but keeps what it is owed, and that
@@ -1,289 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* The schedule routes are thin delegates: without `services.schedules` every
* one answers 503 `events_disabled`; with it, each hands its request over and
* answers what it returns.
*/
import type { Request, Response } from 'express';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { makeActor, type Actor } from '../../core/actor.js';
import { isHttpError } from '../../core/http/HttpError.js';
import type {
ScheduleServiceContract,
ScheduleView,
} from '../../core/schedules.js';
import type { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import type { BroadcastController } from '../broadcast/BroadcastController.js';
import type { EventsController } from './EventsController.js';
let server: PuterServer;
let events: EventsController;
let broadcast: BroadcastController;
let actor: Actor;
let saved: unknown;
const SCHEDULE_ID = '0b3a3c9e-9f61-4f0e-8d2a-2f1b6f8f2c11';
const view = {
scheduleId: SCHEDULE_ID,
kind: 'recurring',
appUid: 'app-x',
} as ScheduleView;
const slot = () => server.services as unknown as { schedules?: unknown };
/** Put `schedules` in the slot, or leave the slot empty. */
const setSchedules = (schedules: unknown): void => {
if (schedules === undefined) delete slot().schedules;
else slot().schedules = schedules;
};
const fakeSchedules = () =>
({
onSubscriptionsChanged: vi.fn(async () => {}),
createFor: vi.fn(async () => view),
list: vi.fn(async () => ({
items: [view],
cursor: 'next',
total: 3,
})),
get: vi.fn(async () => view),
update: vi.fn(async () => view),
remove: vi.fn(async () => {}),
receivePeerChanges: vi.fn(async () => ({ applied: 2 })),
prepareSendAt: vi.fn(),
createDeferredAppMessage: vi.fn(),
}) satisfies ScheduleServiceContract;
const req = (init: {
body?: unknown;
query?: Record<string, unknown>;
params?: Record<string, string>;
}): Request =>
({
actor,
body: init.body ?? {},
query: init.query ?? {},
params: init.params ?? {},
headers: {},
rawBody: Buffer.from(JSON.stringify(init.body ?? {})),
}) as unknown as Request;
const res = () => {
const sent: { status: number; body: unknown } = {
status: 200,
body: undefined,
};
const response = {
json: vi.fn((body: unknown) => {
sent.body = body;
return response;
}),
status: vi.fn((code: number) => {
sent.status = code;
return response;
}),
setHeader: vi.fn(() => response),
};
return { response: response as unknown as Response, sent };
};
/** Each route, called as the router would. */
const routes: Array<[string, (response: Response) => Promise<void>]> = [
[
'POST /events/schedules',
(r) => events.createSchedule(req({ body: { every: 60_000 } }), r),
],
[
'GET /events/schedules',
(r) => events.listSchedules(req({ query: { limit: '5' } }), r),
],
[
'GET /events/schedules/:scheduleId',
(r) =>
events.getSchedule(req({ params: { scheduleId: SCHEDULE_ID } }), r),
],
[
'POST /events/schedules/update',
(r) => events.updateSchedule(req({ body: { scheduleId: SCHEDULE_ID, changes: { every: '2h' } } }), r),
],
[
'POST /events/schedules/remove',
(r) =>
events.removeSchedule(
req({ body: { scheduleId: SCHEDULE_ID } }),
r,
),
],
[
'POST /broadcast/events/schedules',
(r) =>
broadcast.schedules(
req({ body: { namespace: 'n', changes: [] } }),
r,
),
],
];
beforeAll(async () => {
server = await setupTestServer();
events = server.controllers.events as unknown as EventsController;
broadcast = server.controllers.broadcast as unknown as BroadcastController;
actor = makeActor({
user: { id: 1, uuid: 'u-1', username: 'someone' },
} as never);
saved = slot().schedules;
vi.spyOn(
server.services.broadcast,
'verifySignedRequest',
).mockResolvedValue({ ok: true, peerId: 'peer' } as never);
});
afterEach(() => {
setSchedules(saved);
});
afterAll(async () => {
vi.restoreAllMocks();
await server?.shutdown();
});
describe('without services.schedules', () => {
it.each(routes)('%s answers 503 events_disabled', async (_name, call) => {
setSchedules(undefined);
const { response } = res();
let thrown: unknown;
try {
await call(response);
} catch (err) {
thrown = err;
}
expect(isHttpError(thrown)).toBe(true);
expect(thrown).toMatchObject({
statusCode: 503,
legacyCode: 'events_disabled',
// The designed answer, so it never pages.
noAlarm: true,
});
});
it('refuses an unsigned peer batch before saying schedules are off', async () => {
setSchedules(undefined);
vi.spyOn(
server.services.broadcast,
'verifySignedRequest',
).mockResolvedValueOnce({
ok: false,
status: 401,
message: 'bad signature',
} as never);
const { response, sent } = res();
await broadcast.schedules(
req({ body: { namespace: 'n', changes: [] } }),
response,
);
expect(sent.status).toBe(401);
});
});
describe('with services.schedules', () => {
it('creates through createFor and answers its view', async () => {
const schedules = fakeSchedules();
setSchedules(schedules);
const { response, sent } = res();
await events.createSchedule(req({ body: { every: 60_000 } }), response);
expect(schedules.createFor).toHaveBeenCalledWith(actor, {
every: 60_000,
});
expect(sent.body).toEqual(view);
});
it('lists with the limit capped, the cursor and includeTotal', async () => {
const schedules = fakeSchedules();
setSchedules(schedules);
const { response, sent } = res();
await events.listSchedules(
req({
query: { limit: '5000', cursor: 'c', includeTotal: 'true' },
}),
response,
);
expect(schedules.list).toHaveBeenCalledWith(actor, {
limit: 200,
cursor: 'c',
includeTotal: true,
});
expect(sent.body).toEqual({ items: [view], cursor: 'next', total: 3 });
});
it('reads one through get', async () => {
const schedules = fakeSchedules();
setSchedules(schedules);
const { response, sent } = res();
await events.getSchedule(
req({ params: { scheduleId: SCHEDULE_ID } }),
response,
);
expect(schedules.get).toHaveBeenCalledWith(actor, SCHEDULE_ID);
expect(sent.body).toEqual(view);
});
it('updates through update and answers its view', async () => {
const schedules = fakeSchedules();
setSchedules(schedules);
const { response, sent } = res();
const changes = { every: '2h' };
await events.updateSchedule(req({ body: { scheduleId: SCHEDULE_ID, changes } }), response);
expect(schedules.update).toHaveBeenCalledWith(actor, SCHEDULE_ID, changes);
expect(sent.body).toEqual(view);
});
it('removes through remove and answers {}', async () => {
const schedules = fakeSchedules();
setSchedules(schedules);
const { response, sent } = res();
await events.removeSchedule(
req({ body: { scheduleId: SCHEDULE_ID } }),
response,
);
expect(schedules.remove).toHaveBeenCalledWith(actor, SCHEDULE_ID);
expect(sent.body).toEqual({});
});
it('hands a signed peer batch to receivePeerChanges', async () => {
const schedules = fakeSchedules();
setSchedules(schedules);
const { response, sent } = res();
const body = { namespace: 'n', changes: [] };
await broadcast.schedules(req({ body }), response);
expect(schedules.receivePeerChanges).toHaveBeenCalledWith(body);
expect(sent).toEqual({ status: 200, body: { ok: true, applied: 2 } });
});
});
@@ -1,77 +0,0 @@
import type { Request, Response } from 'express';
import { describe, expect, it } from 'vitest';
import { makeActor } from '../../core/actor.js';
import type {
ScheduleServiceContract,
ScheduleView,
} from '../../core/schedules.js';
import { EventsController } from './EventsController.js';
const actor = makeActor({
user: { id: 1, uuid: 'user-1', username: 'user1' } as never,
});
const request = (body: unknown) => ({ actor, body }) as Request;
const controller = (schedules?: Pick<ScheduleServiceContract, 'update'>) =>
new EventsController(
{} as never,
{} as never,
{} as never,
{ schedules } as never,
{} as never,
);
describe('update schedule route contract', () => {
it('answers events_disabled when schedules are unavailable', async () => {
await expect(
controller().updateSchedule(
request({ scheduleId: 'id', changes: {} }),
{} as Response,
),
).rejects.toMatchObject({
legacyCode: 'events_disabled',
statusCode: 503,
noAlarm: true,
});
});
it('passes the actor, id and changes through and returns the updated view', async () => {
const view = {
scheduleId: 'id',
kind: 'recurring',
state: 'suspended',
} as ScheduleView;
const changes = { cron: '0 9 * * *', tz: 'UTC' };
const calls: unknown[][] = [];
const events = controller({
update: async (...args) => {
calls.push(args);
return view;
},
});
let sent: unknown;
await events.updateSchedule(request({ scheduleId: 'id', changes }), {
json: (value: unknown) => {
sent = value;
},
} as Response);
expect(calls).toEqual([[actor, 'id', changes]]);
expect(sent).toBe(view);
});
it('preserves validation and sending errors from the implementation', async () => {
const error = {
code: 'events_schedule_sending',
message: 'Already sending',
};
await expect(
controller({
update: async () => {
throw error;
},
}).updateSchedule(
request({ scheduleId: 'id', changes: {} }),
{} as Response,
),
).rejects.toBe(error);
});
});
@@ -25,35 +25,9 @@
import MockRedis from 'ioredis-mock';
import { describe, expect, it } from 'vitest';
import type { fetch as undiciFetch } from 'undici';
import {
DispatcherInvokeTransport,
EVENTS_DEPLOYED_HEADER,
EVENTS_DISPATCH_ERROR_HEADER,
EVENTS_HANDLED_HEADER,
EventsWorkerInvokerClient,
type EventsInvokeCall,
} from '../../clients/events/EventsWorkerInvokerClient.js';
import { INTERNAL_DEPLOY_TARGET } from '../../drivers/workers/WorkerDriver.js';
import {
EVENTS_USER_WORKER_DEPLOYS_PER_HOUR,
EVENTS_WORKER_DEPLOYS_PER_HOUR,
} from './limits.js';
import {
EventsWorkerDeployer,
LocalEventsInvokeTransport,
type EventsDeployOptions,
type EventsDeployOutcome,
} from './workerDeploy.js';
import {
eventsWorkerScript,
userHandlerScript,
} from '../../services/events/workerRuntime.js';
import {
appHandlerScope,
hashContent,
type HandlerScope,
} from '../../stores/events/EventHandlerStore.js';
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
import { EventsWorkerDeployer } from './workerDeploy.js';
import { eventsWorkerScript } from '../../services/events/workerRuntime.js';
import { handlerSetHash } from '../../services/events/workerSource.js';
import type { IConfig } from '../../types.js';
@@ -72,22 +46,12 @@ interface Owner {
type Layers = ConstructorParameters<typeof EventsWorkerDeployer>[0];
/** Stub layers whose only real dependency is a fresh redis mock. */
const makeLayers = (
owners: Record<string, Owner | null>,
events: Record<string, unknown> = {},
) => {
const makeLayers = (owners: Record<string, Owner | null>) => {
const redis = new MockRedis.Cluster(['redis://localhost:7001']);
const created: string[] = [];
const targets: Array<Record<string, unknown>> = [];
/** Apps whose handler sources were read, once per read. */
const sourceReads: string[] = [];
const layers = {
config: {
events: {
workerRuntime: true,
internalSecret: 'sekret',
...events,
},
events: { workerRuntime: true, internalSecret: 'sekret' },
// `#namespace()` needs a local-worker shape to resolve without
// throwing — this stub never reaches a real Cloudflare or
// Miniflare deploy either way.
@@ -96,20 +60,16 @@ const makeLayers = (
clients: { redis },
stores: {
eventHandler: {
setForApp: async () => [{ name: 'h', sourceHash: SOURCE_HASH }],
allForApp: async ({ appUid }: { appUid: string }) => {
sourceReads.push(appUid);
return [
{
appUid,
name: 'h',
source: 'async () => {}',
sourceHash: SOURCE_HASH,
createdAt: 0,
updatedAt: 0,
},
];
},
allForApp: async (appUid: string) => [
{
appUid,
name: 'h',
source: 'async () => {}',
sourceHash: SOURCE_HASH,
createdAt: 0,
updatedAt: 0,
},
],
},
app: {
getByUid: async (appUid: string) => {
@@ -120,18 +80,6 @@ const makeLayers = (
},
},
user: {
getByUuid: async (uuid: string) => {
const owner = Object.values(owners).find(
(o) => `uuid-${o?.ownerUserId}` === uuid,
);
if (!owner) return null;
return {
id: owner.ownerUserId,
uuid,
username: `user-${owner.ownerUserId}`,
suspended: owner.suspended ?? false,
};
},
getById: async (id: number) => {
const owner = Object.values(owners).find(
(o) => o?.ownerUserId === id,
@@ -149,24 +97,14 @@ const makeLayers = (
services: {},
drivers: {
workers: {
create: async (args: {
workerName: string;
[INTERNAL_DEPLOY_TARGET]?: Record<string, unknown>;
}) => {
create: async (args: { workerName: string }) => {
created.push(args.workerName);
targets.push(args[INTERNAL_DEPLOY_TARGET] ?? {});
return { success: true, errors: [] };
},
},
},
};
return {
layers: layers as unknown as Layers,
created,
targets,
redis,
sourceReads,
};
return { layers: layers as unknown as Layers, created, redis };
};
describe('EventsWorkerDeployer.ensure', () => {
@@ -182,17 +120,15 @@ describe('EventsWorkerDeployer.ensure', () => {
const deployer = new EventsWorkerDeployer(layers);
const [a, b] = await Promise.all([
deployer.ensure(appHandlerScope('app-a'), SCRIPT),
deployer.ensure(appHandlerScope('app-b'), SCRIPT),
deployer.ensure('app-a', SCRIPT),
deployer.ensure('app-b', SCRIPT),
]);
expect(a).toBe('deployed');
expect(b).toBe('no-owner');
});
it('refuses once the app has deployed past the hourly cap, before reading or building any source', async () => {
const { layers, redis, sourceReads, created } = makeLayers({
'app-c': { ownerUserId: 3 },
});
it('refuses once the app has deployed past the hourly cap', async () => {
const { layers, redis } = makeLayers({ 'app-c': { ownerUserId: 3 } });
const deployer = new EventsWorkerDeployer(layers);
const hour = new Date().toISOString().slice(0, 13);
await redis.set(
@@ -200,697 +136,12 @@ describe('EventsWorkerDeployer.ensure', () => {
String(EVENTS_WORKER_DEPLOYS_PER_HOUR),
);
expect(await deployer.ensure(appHandlerScope('app-c'), SCRIPT)).toBe(
'throttled',
);
expect(sourceReads).toEqual([]);
expect(created).toEqual([]);
});
it('answers a stale script without spending the hourly cap', async () => {
const { layers, redis, sourceReads } = makeLayers({
'app-e': { ownerUserId: 5 },
});
const deployer = new EventsWorkerDeployer(layers);
const stale = eventsWorkerScript(
handlerSetHash([{ name: 'h', sourceHash: 'b'.repeat(64) }]),
'',
);
expect(await deployer.ensure(appHandlerScope('app-e'), stale)).toBe(
'stale',
);
expect(sourceReads).toEqual([]);
const hour = new Date().toISOString().slice(0, 13);
expect(await redis.get(`ev:deploys:{app-e}:${hour}`)).toBeNull();
});
it('passes events.workerCpuMs to the deploy target', async () => {
const { layers, targets } = makeLayers(
{ 'app-f': { ownerUserId: 6 } },
{ workerCpuMs: 50 },
);
const deployer = new EventsWorkerDeployer(layers);
expect(await deployer.ensure(appHandlerScope('app-f'), SCRIPT)).toBe(
'deployed',
);
expect(targets[0]!.cpuMs).toBe(50);
});
it('passes no cpuMs when events.workerCpuMs is unset', async () => {
const { layers, targets } = makeLayers({ 'app-g': { ownerUserId: 7 } });
const deployer = new EventsWorkerDeployer(layers);
expect(await deployer.ensure(appHandlerScope('app-g'), SCRIPT)).toBe(
'deployed',
);
expect(targets[0]!.cpuMs).toBeUndefined();
expect(await deployer.ensure('app-c', SCRIPT)).toBe('throttled');
});
it('deploys normally under the cap', async () => {
const { layers } = makeLayers({ 'app-d': { ownerUserId: 4 } });
const deployer = new EventsWorkerDeployer(layers);
expect(await deployer.ensure(appHandlerScope('app-d'), SCRIPT)).toBe(
'deployed',
);
});
});
/** A server's schedules with a deploy gate that records and can refuse. */
const withGate = (
layers: Layers,
refuse: (capped: boolean) => boolean = () => false,
) => {
const gated: Array<{ script: string; capped: boolean }> = [];
const gate = {
gateDeploy: async (
script: string,
options: { capped: boolean },
deploy: () => Promise<EventsDeployOutcome>,
): Promise<EventsDeployOutcome> => {
gated.push({ script, capped: options.capped });
return refuse(options.capped) ? 'busy' : deploy();
},
};
const deployer = new EventsWorkerDeployer({
...layers,
services: { ...layers.services, schedules: gate },
} as unknown as Layers);
return { deployer, gated };
};
describe('the schedules` deploy gate', () => {
it('is skipped without schedules, or with schedules that have no gate', async () => {
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
await expect(
new EventsWorkerDeployer(layers).ensure(
appHandlerScope('app-x'),
SCRIPT,
{
lane: 'schedule',
},
),
).resolves.toBe('deployed');
const gateless = new EventsWorkerDeployer({
...layers,
services: { ...layers.services, schedules: {} },
} as unknown as Layers);
await expect(
gateless.ensure(appHandlerScope('app-x'), SCRIPT, {
lane: 'schedule',
}),
).resolves.toBe('deployed');
expect(created).toEqual([SCRIPT, SCRIPT]);
});
it('takes every deploy, capped only for a schedule run', async () => {
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
const { deployer, gated } = withGate(layers);
await expect(
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
).resolves.toBe('deployed');
await expect(
deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
lane: 'schedule',
}),
).resolves.toBe('deployed');
expect(gated).toEqual([
{ script: SCRIPT, capped: false },
{ script: SCRIPT, capped: true },
]);
expect(created).toEqual([SCRIPT, SCRIPT]);
});
it('answers a refused schedule run busy, and a delivery still deploys', async () => {
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
const { deployer } = withGate(layers, (capped) => capped);
await expect(
deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
lane: 'schedule',
}),
).resolves.toBe('busy');
await expect(
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
).resolves.toBe('deployed');
expect(created).toEqual([SCRIPT]);
});
it('lets deliveries that join a schedule run`s refused deploy deploy once', async () => {
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
const { deployer, gated } = withGate(layers, (capped) => capped);
const scheduled = deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
lane: 'schedule',
});
const deliveries = [
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
deployer.ensure(appHandlerScope('app-x'), SCRIPT),
];
await expect(scheduled).resolves.toBe('busy');
await expect(Promise.all(deliveries)).resolves.toEqual([
'deployed',
'deployed',
]);
expect(created).toEqual([SCRIPT]);
expect(gated.map((g) => g.capped)).toEqual([true, false]);
});
it('lets a schedule run join a delivery`s deploy in flight', async () => {
const { layers, created } = makeLayers({ 'app-x': { ownerUserId: 1 } });
const { deployer, gated } = withGate(layers, (capped) => capped);
const delivery = deployer.ensure(appHandlerScope('app-x'), SCRIPT);
const scheduled = deployer.ensure(appHandlerScope('app-x'), SCRIPT, {
lane: 'schedule',
});
await expect(Promise.all([delivery, scheduled])).resolves.toEqual([
'deployed',
'deployed',
]);
expect(gated).toEqual([{ script: SCRIPT, capped: false }]);
expect(created).toEqual([SCRIPT]);
});
});
describe('LocalEventsInvokeTransport', () => {
it('renders each request as it goes out, after a cold deploy', async () => {
const bodies: string[] = [];
let resident = false;
const transport = new LocalEventsInvokeTransport(
{
localworkerservice: {
dispatchEventsWorker: async (request: Request) => {
bodies.push(await request.text());
if (!resident) return null;
return new Response(null, {
status: 200,
headers: { 'x-puter-events-handled': '1' },
});
},
},
} as never,
{
ensure: async () => {
resident = true;
return 'deployed';
},
scopeFor: async (appUid: string) => appHandlerScope(appUid),
} as unknown as EventsWorkerDeployer,
);
let renders = 0;
const call: EventsInvokeCall = {
script: SCRIPT,
appUid: 'app-x',
key: 'k',
body: '{"n":0}',
timeoutMs: 5000,
render: () => `{"n":${++renders}}`,
};
await expect(transport.send(call)).resolves.toMatchObject({
status: 200,
});
expect(bodies).toEqual(['{"n":1}', '{"n":2}']);
});
});
describe('deploy-on-miss of an evicted script', () => {
it('redeploys the evicted script, then resends the run with the deployed marker', async () => {
const { layers, created } = makeLayers({ 'app-m': { ownerUserId: 8 } });
// The namespace only has the script once the deploy lands: before
// that the dispatcher answers a miss.
let resident = false;
layers.drivers.workers.create = (async (args: {
workerName: string;
}) => {
created.push(args.workerName);
resident = true;
return { success: true, errors: [] };
}) as never;
const deployer = new EventsWorkerDeployer(layers);
const sent: boolean[] = [];
const fetchImpl = (async (_url: string, init?: RequestInit) => {
const headers = (init?.headers ?? {}) as Record<string, string>;
sent.push(headers[EVENTS_DEPLOYED_HEADER] === '1');
if (resident)
return new Response(null, {
status: 200,
headers: { [EVENTS_HANDLED_HEADER]: '1' },
});
return new Response(null, {
status: 404,
headers: { [EVENTS_DISPATCH_ERROR_HEADER]: 'missing' },
});
}) as unknown as typeof undiciFetch;
const client = new EventsWorkerInvokerClient({
events: { workerRuntime: true },
} as IConfig);
client.setTransport(
new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl,
}),
);
client.setMissHandler((appUid, script, options) =>
deployer.ensure(appHandlerScope(appUid), script, options),
);
const result = await client.invoke({
script: SCRIPT,
appUid: 'app-m',
handler: 'h',
token: 't',
key: 'k',
event: {},
ctx: {},
});
expect(result).toEqual({ outcome: 'settled', status: 200 });
expect(created).toEqual([SCRIPT]);
// One miss, then the resend carrying the deployed marker.
expect(sent).toEqual([false, true]);
});
});
// The redis mock is shared across tests, so each one uses its own app and code.
let fresh = 0;
const nextApp = () => `app-u${++fresh}`;
const userScope = (holder: number, appUid: string) => ({ appUid, holder });
const sourceN = (label: string) =>
`async () => { await ${label}${++fresh}(); }`;
const scriptOf = (name: string, source: string) =>
userHandlerScript({ name, sourceHash: hashContent(source) }, '');
type Sets = Record<number, Array<{ name: string; source: string }>>;
/** Layers whose handler sets are per holder (0 is the app's own). */
const withSets = (layers: Layers, sets: Sets) => {
const handlersOf = (scope: { appUid: string; holder: number }) =>
(sets[scope.holder] ?? []).map((h) => ({
appUid: scope.appUid,
name: h.name,
source: h.source,
sourceHash: hashContent(h.source),
createdAt: 0,
updatedAt: 0,
}));
const store = layers.stores.eventHandler as unknown as Record<
string,
unknown
>;
store.setForApp = async (scope: { appUid: string; holder: number }) =>
handlersOf(scope).map(({ name, sourceHash }) => ({ name, sourceHash }));
store.allForApp = async (scope: { appUid: string; holder: number }) =>
handlersOf(scope);
};
const holders = (...ids: number[]) =>
Object.fromEntries(
ids.map((id) => [`holder-${id}`, { ownerUserId: id }]),
) as Record<string, Owner>;
describe('EventsWorkerDeployer in a user scope', () => {
it('deploys the one handler whose script was asked for, as the holder', async () => {
const app = nextApp();
const a = sourceN('a');
const b = sourceN('b');
const { layers, created, targets } = makeLayers(holders(10));
withSets(layers, {
10: [
{ name: 'a', source: a },
{ name: 'b', source: b },
],
});
const deployer = new EventsWorkerDeployer(layers);
const script = scriptOf('b', b);
expect(await deployer.ensure(userScope(10, app), script)).toBe(
'deployed',
);
expect(created).toEqual([script]);
const target = targets[0]!;
expect(target.tags).toEqual([`events-app:${app}`, 'user:uuid-10']);
// One handler's script: the other one's code is not in it.
expect(target.source).toContain(b);
expect(target.source).not.toContain(a);
});
it('answers a script no handler of the scope matches as stale, before the throttle', async () => {
const app = nextApp();
const { layers, redis, created } = makeLayers(holders(10));
withSets(layers, { 10: [{ name: 'a', source: sourceN('a') }] });
const deployer = new EventsWorkerDeployer(layers);
expect(
await deployer.ensure(
userScope(10, app),
scriptOf('b', sourceN('b')),
),
).toBe('stale');
expect(created).toEqual([]);
const hour = new Date().toISOString().slice(0, 13);
expect(await redis.get(`ev:deploys:{${app}}:10:${hour}`)).toBeNull();
});
it('answers a scope with no handlers as no-handlers', async () => {
const { layers } = makeLayers(holders(10));
withSets(layers, {});
const deployer = new EventsWorkerDeployer(layers);
expect(
await deployer.ensure(
userScope(10, nextApp()),
scriptOf('a', sourceN('a')),
),
).toBe('no-handlers');
});
it('does not deploy for a holder that is suspended or gone', async () => {
const source = sourceN('a');
const { layers, created } = makeLayers({
'holder-10': { ownerUserId: 10, suspended: true },
});
withSets(layers, {
10: [{ name: 'a', source }],
11: [{ name: 'a', source }],
});
const deployer = new EventsWorkerDeployer(layers);
const script = scriptOf('a', source);
const app = nextApp();
expect(await deployer.ensure(userScope(10, app), script)).toBe(
'no-owner',
);
expect(await deployer.ensure(userScope(11, app), script)).toBe(
'no-owner',
);
expect(created).toEqual([]);
});
it('gives each scope its own hourly cap, apart from the app scope', async () => {
const app = nextApp();
const source = sourceN('a');
const { layers, created } = makeLayers({
...holders(10, 11),
[app]: { ownerUserId: 12 },
});
withSets(layers, {
0: [{ name: 'a', source }],
10: [{ name: 'a', source }],
11: [{ name: 'a', source }],
});
const deployer = new EventsWorkerDeployer(layers);
const script = scriptOf('a', source);
for (let i = 0; i < EVENTS_USER_WORKER_DEPLOYS_PER_HOUR; i++)
expect(await deployer.ensure(userScope(10, app), script)).toBe(
'deployed',
);
expect(await deployer.ensure(userScope(10, app), script)).toBe(
'throttled',
);
expect(created).toHaveLength(EVENTS_USER_WORKER_DEPLOYS_PER_HOUR);
// Another holder of the same app, and the app's own scope, still go.
expect(await deployer.ensure(userScope(11, app), script)).toBe(
'deployed',
);
expect(await deployer.ensure(appHandlerScope(app), script)).toBe(
'deployed',
);
});
it('collapses concurrent deploys of one script across holders into one upload', async () => {
const app = nextApp();
const source = sourceN('a');
const { layers, created } = makeLayers(holders(10, 11));
withSets(layers, {
10: [{ name: 'a', source }],
11: [{ name: 'a', source }],
});
const deployer = new EventsWorkerDeployer(layers);
const script = scriptOf('a', source);
await Promise.all([
deployer.ensure(userScope(10, app), script),
deployer.ensure(userScope(10, app), script),
deployer.ensure(userScope(11, app), script),
deployer.ensure(userScope(11, nextApp()), script),
]);
expect(created).toHaveLength(1);
});
it('names a script for identical code the same under any holder and app', async () => {
const source = sourceN('a');
const { layers, created } = makeLayers(holders(10, 11));
withSets(layers, {
10: [{ name: '@x', source }],
11: [{ name: '@x', source }],
});
const deployer = new EventsWorkerDeployer(layers);
const script = scriptOf('@x', source);
expect(await deployer.ensure(userScope(10, nextApp()), script)).toBe(
'deployed',
);
expect(await deployer.ensure(userScope(11, nextApp()), script)).toBe(
'deployed',
);
expect(created).toEqual([script, script]);
});
});
describe('the deployed marker', () => {
const setup = () => {
const source = sourceN('a');
const made = makeLayers(holders(10, 11));
withSets(made.layers, {
10: [{ name: 'a', source }],
11: [{ name: 'a', source }],
});
return {
...made,
deployer: new EventsWorkerDeployer(made.layers),
script: scriptOf('a', source),
app: nextApp(),
};
};
it('is left by a user-scope deploy with a ttl under a day', async () => {
const { deployer, redis, script, app } = setup();
await deployer.ensure(userScope(10, app), script);
const ttl = await redis.ttl(`ev:deployed:${script}`);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThan(24 * 60 * 60);
});
it('lets an eager deploy skip the upload another holder already made', async () => {
const { deployer, created, script, app } = setup();
expect(await deployer.deployEagerly(userScope(10, app), script)).toBe(
'deployed',
);
expect(await deployer.deployEagerly(userScope(11, app), script)).toBe(
'deployed',
);
expect(created).toEqual([script]);
});
it('spends no throttle on a skipped eager deploy', async () => {
const { deployer, redis, script, app } = setup();
await deployer.deployEagerly(userScope(10, app), script);
await deployer.deployEagerly(userScope(11, app), script);
const hour = new Date().toISOString().slice(0, 13);
expect(await redis.get(`ev:deploys:{${app}}:11:${hour}`)).toBeNull();
});
it('never skips a deploy-on-miss', async () => {
const { deployer, created, script, app } = setup();
await deployer.deployEagerly(userScope(10, app), script);
expect(await deployer.ensure(userScope(11, app), script)).toBe(
'deployed',
);
expect(created).toEqual([script, script]);
});
it('is not left by an app-scope deploy', async () => {
const app = nextApp();
const source = sourceN('a');
const { layers, redis, created } = makeLayers({
[app]: { ownerUserId: 4 },
});
withSets(layers, { 0: [{ name: 'a', source }] });
const deployer = new EventsWorkerDeployer(layers);
const script = scriptOf('a', source);
await deployer.ensure(appHandlerScope(app), script);
expect(created).toEqual([script]);
expect(await redis.get(`ev:deployed:${script}`)).toBeNull();
});
it('still deploys eagerly when the marker cannot be read', async () => {
const { deployer, redis, created, script, app } = setup();
await deployer.deployEagerly(userScope(10, app), script);
const get = redis.get.bind(redis);
redis.get = (async (key: string) => {
if (key.startsWith('ev:deployed:')) throw new Error('down');
return get(key);
}) as never;
expect(await deployer.deployEagerly(userScope(11, app), script)).toBe(
'deployed',
);
expect(created).toEqual([script, script]);
});
});
describe('scopeFor', () => {
it('reads no holder as the app scope, and resolves a uuid to the user scope', async () => {
const { layers } = makeLayers(holders(10));
const deployer = new EventsWorkerDeployer(layers);
expect(await deployer.scopeFor('app-u')).toEqual({
appUid: 'app-u',
holder: 0,
});
expect(await deployer.scopeFor('app-u', '')).toEqual({
appUid: 'app-u',
holder: 0,
});
expect(await deployer.scopeFor('app-u', 'uuid-10')).toEqual({
appUid: 'app-u',
holder: 10,
});
expect(await deployer.scopeFor('app-u', 'uuid-99')).toBeNull();
});
});
describe('LocalEventsInvokeTransport in a user scope', () => {
it('deploys the holder`s script on a miss and refuses an unknown holder', async () => {
const source = sourceN('a');
const { layers, created } = makeLayers(holders(10));
withSets(layers, { 10: [{ name: 'a', source }] });
const deployer = new EventsWorkerDeployer(layers);
let resident = false;
const transport = new LocalEventsInvokeTransport(
{
localworkerservice: {
dispatchEventsWorker: async () => {
if (!resident) return null;
return new Response(null, {
status: 200,
headers: { 'x-puter-events-handled': '1' },
});
},
},
} as never,
{
ensure: async (
scope: HandlerScope,
script: string,
options: EventsDeployOptions,
) => {
const outcome = await deployer.ensure(
scope,
script,
options,
);
resident = outcome === 'deployed';
return outcome;
},
scopeFor: (appUid: string, holder?: string) =>
deployer.scopeFor(appUid, holder),
} as unknown as EventsWorkerDeployer,
);
const script = scriptOf('a', source);
const call: EventsInvokeCall = {
script,
appUid: nextApp(),
holder: 'uuid-10',
key: 'k',
body: '{}',
timeoutMs: 5000,
};
await expect(transport.send(call)).resolves.toMatchObject({
status: 200,
});
expect(created).toEqual([script]);
resident = false;
await expect(
transport.send({ ...call, holder: 'uuid-99' }),
).resolves.toMatchObject({ status: null, error: 'deploy: no-holder' });
expect(created).toEqual([script]);
});
});
describe('concurrent eager deploys of one script', () => {
const setup = () => {
const source = sourceN('a');
const made = makeLayers(holders(10, 11));
withSets(made.layers, {
10: [{ name: 'a', source }],
11: [{ name: 'a', source }],
});
return {
...made,
script: scriptOf('a', source),
app: nextApp(),
};
};
it('upload once when two holders publish the same new code together', async () => {
const { layers, created, script, app } = setup();
const deployer = new EventsWorkerDeployer(layers);
const outcomes = await Promise.all([
deployer.deployEagerly(userScope(10, app), script),
deployer.deployEagerly(userScope(11, app), script),
]);
expect(outcomes).toEqual(['deployed', 'deployed']);
expect(created).toEqual([script]);
});
it('skip the upload when the marker appears while they wait on the gate', async () => {
const { layers, redis, created, script, app } = setup();
// Another node's deploy of the script finishes before the gate opens.
const gated = new EventsWorkerDeployer({
...layers,
services: {
schedules: {
gateDeploy: async (
_script: string,
_options: { capped: boolean },
deploy: () => Promise<EventsDeployOutcome>,
) => {
await redis.set(`ev:deployed:${script}`, '1');
return deploy();
},
},
},
} as unknown as Layers);
expect(
await gated.ensure(userScope(10, app), script, { eager: true }),
).toBe('deployed');
expect(created).toEqual([]);
// A miss still uploads, whatever the marker says.
expect(await gated.ensure(userScope(11, app), script)).toBe('deployed');
expect(created).toEqual([script]);
});
it('make a miss that joins an eager deploy deploy after it', async () => {
const { layers, redis, created, script, app } = setup();
const deployer = new EventsWorkerDeployer(layers);
await redis.set(`ev:deployed:${script}`, '1');
const eager = deployer.ensure(userScope(10, app), script, {
eager: true,
});
const miss = deployer.ensure(userScope(11, app), script);
expect(await eager).toBe('deployed');
expect(await miss).toBe('deployed');
// The eager one skipped on the marker; the miss uploaded.
expect(created).toEqual([script]);
expect(await deployer.ensure('app-d', SCRIPT)).toBe('deployed');
});
});
+66 -304
View File
@@ -26,100 +26,50 @@ import {
INTERNAL_ADMISSION_BYPASS,
INTERNAL_DEPLOY_TARGET,
} from '../../drivers/workers/WorkerDriver.js';
import type {
EventsInvokeAnswer,
EventsInvokeCall,
InvokeLane,
} from '../../clients/events/EventsWorkerInvokerClient.js';
import type { EventsInvokeCall } from '../../clients/events/EventsWorkerInvokerClient.js';
import {
EVENTS_HANDLED_HEADER,
EVENTS_INVOKE_PATH,
answerFrom,
failedAnswer,
} from '../../clients/events/EventsWorkerInvokerClient.js';
import type { puterServices } from '../../services/index.js';
import type { IExtensionServiceInstances } from '../../services/types.js';
import {
eventsInvokeKey,
eventsWorkerScope,
eventsWorkerScript,
userHandlerScript,
} from '../../services/events/workerRuntime.js';
import {
generateEventsWorkerSource,
handlerSetHash,
} from '../../services/events/workerSource.js';
import {
appHandlerScope,
type HandlerScope,
} from '../../stores/events/EventHandlerStore.js';
import { generateEventsWorkerSource } from '../../services/events/workerSource.js';
import type { puterStores } from '../../stores/index.js';
import type { IConfig, LayerInstances } from '../../types.js';
import {
EVENTS_USER_WORKER_DEPLOYS_PER_HOUR,
EVENTS_WORKER_DEPLOYS_PER_HOUR,
} from './limits.js';
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
/**
* Deploys an app's events worker from its published handlers.
*
* Deployed the first time a delivery needs it and again on eviction — a publish
* only writes rows (a user-scope publish also deploys eagerly). An app's scope
* is one script for the whole set; a user scope is one script per handler,
* deployed as the holder. Scripts carry no worker token and no `subdomains`
* row: not one of the owner's workers, not addressable from the internet, not
* listed or deleted as if it were. A republished set leaves the old script
* behind rather than deleting it; redeploying a still-live one is always safe.
* only writes rows. Scripts carry no worker token and no `subdomains` row: not
* one of the owner's workers, not addressable from the internet, not listed or
* deleted as if it were. A republished set leaves the old script behind rather
* than deleting it; redeploying a still-live one is always safe.
*/
/** Why a deploy could not happen, for the callers that answer differently. */
export type EventsDeployOutcome =
| 'deployed'
| 'stale'
| 'no-handlers'
| 'no-owner'
| 'throttled'
| 'failed'
| 'busy';
/** Whose call asked for the deploy: a schedule run's is held to a share. */
export interface EventsDeployOptions {
lane?: InvokeLane;
/**
* A user-scope publish's deploy, which skips the upload when the script's
* deployed marker is already set. A deploy-on-miss is never eager.
*/
eager?: boolean;
}
'deployed' | 'stale' | 'no-handlers' | 'no-owner' | 'throttled' | 'failed';
interface DeployLayers {
config: IConfig;
clients: LayerInstances<typeof puterClients>;
stores: LayerInstances<typeof puterStores>;
services: LayerInstances<typeof puterServices> &
Pick<IExtensionServiceInstances, 'schedules'>;
services: LayerInstances<typeof puterServices>;
drivers: LayerInstances<typeof puterDrivers>;
}
/**
* `ev:deploys:{<appUid>}[:<holder>]:<hour>` bucket TTL — comfortably past the
* hour it counts.
* `ev:deploys:{<appUid>}:<hour>` bucket TTL — comfortably past the hour it
* counts.
*/
const DEPLOY_THROTTLE_TTL_SECONDS = 2 * 60 * 60;
/** How long `ev:deployed:<script>` says a script was uploaded. Under a day. */
const DEPLOYED_MARKER_TTL_SECONDS = 20 * 60 * 60;
const deployedMarkerKey = (script: string): string => `ev:deployed:${script}`;
const isUserScope = (scope: HandlerScope): boolean => scope.holder !== 0;
interface InFlightDeploy {
/** A schedule run's, which the region's deploy slots may refuse. */
capped: boolean;
eager: boolean;
promise: Promise<EventsDeployOutcome>;
}
interface DeployResult {
success?: boolean;
errors?: unknown[];
@@ -128,7 +78,7 @@ interface DeployResult {
export class EventsWorkerDeployer {
readonly #layers: DeployLayers;
/** Collapses the burst of misses one cold app produces into one deploy. */
readonly #inFlight = new Map<string, InFlightDeploy>();
readonly #inFlight = new Map<string, Promise<EventsDeployOutcome>>();
constructor(layers: DeployLayers) {
this.#layers = layers;
@@ -143,187 +93,39 @@ export class EventsWorkerDeployer {
}
/**
* Deploy `script`, which must be what the scope's handlers currently hash
* to: the whole set for an app, one handler for a user scope. A request for
* anything else is `stale` — the handlers moved on, and the next delivery
* resolves the new script rather than this one being revived. Always
* uploads; a deploy-on-miss means whatever said it was there is wrong.
* Deploy `script`, which must be what the app's handlers currently hash to.
* A request for anything else is `stale` — the set moved on, and the next
* delivery resolves the new script rather than this one being revived.
*/
ensure(
scope: HandlerScope,
script: string,
options: EventsDeployOptions = {},
): Promise<EventsDeployOutcome> {
// A user scope's script is the same bytes whoever holds it, so
// concurrent deploys of it from any holder or app are one.
const key = isUserScope(scope)
? `user|${script}`
: `${scope.appUid}|${scope.holder}|${script}`;
const capped = options.lane === 'schedule';
ensure(appUid: string, script: string): Promise<EventsDeployOutcome> {
const key = `${appUid}|${script}`;
const existing = this.#inFlight.get(key);
if (!existing) return this.#start(key, scope, script, options);
// An eager deploy may have skipped the upload, which a miss must not
// take for done.
if (existing.eager && !options.eager)
return this.#afterEager(
existing.promise,
key,
scope,
script,
options,
);
if (capped || !existing.capped) return existing.promise;
return this.#afterCapped(existing.promise, key, scope, script, options);
}
/**
* {@link ensure} for the deploy a user-scope publish starts, which skips the
* upload when another holder's deploy of the same script left its marker.
* Only this path reads the marker.
*/
async deployEagerly(
scope: HandlerScope,
script: string,
): Promise<EventsDeployOutcome> {
if (await this.#markedDeployed(script)) return 'deployed';
return this.ensure(scope, script, { eager: true });
}
/**
* The scope a script request names: the app's own, or the user scope of
* `holder` (a user uuid). Null for a holder that does not exist.
*/
async scopeFor(
appUid: string,
holder?: string | null,
): Promise<HandlerScope | null> {
if (!holder) return appHandlerScope(appUid);
const user = await this.#layers.stores.user.getByUuid(holder);
if (!user?.id) return null;
return { appUid, holder: Number(user.id) };
}
#start(
key: string,
scope: HandlerScope,
script: string,
options: EventsDeployOptions,
): Promise<EventsDeployOutcome> {
const entry: InFlightDeploy = {
capped: options.lane === 'schedule',
eager: options.eager === true,
promise: this.#gated(scope, script, options).finally(() => {
if (this.#inFlight.get(key) === entry)
this.#inFlight.delete(key);
}),
};
this.#inFlight.set(key, entry);
return entry.promise;
}
/** A miss that found an eager deploy in flight deploys after it. */
async #afterEager(
eager: Promise<EventsDeployOutcome>,
key: string,
scope: HandlerScope,
script: string,
options: EventsDeployOptions,
): Promise<EventsDeployOutcome> {
await eager;
const current = this.#inFlight.get(key);
if (current && !current.eager) return current.promise;
return this.#start(key, scope, script, options);
}
/**
* A delivery that joined a schedule run's deploy: `busy` means no deploy
* started, and a delivery is never refused a slot, so it asks again.
*/
async #afterCapped(
capped: Promise<EventsDeployOutcome>,
key: string,
scope: HandlerScope,
script: string,
options: EventsDeployOptions,
): Promise<EventsDeployOutcome> {
const outcome = await capped;
if (outcome !== 'busy') return outcome;
const current = this.#inFlight.get(key);
if (current && !current.capped) return current.promise;
return this.#start(key, scope, script, options);
}
/**
* Every deploy goes through the schedules' deploy gate when a server has
* one: a per-script lock, so a burst of runs of an evicted script deploys
* it once, and a region-wide cap that a schedule run's deploy (`capped`)
* may be refused with `busy`. A delivery's deploy is never refused.
*/
#gated(
scope: HandlerScope,
script: string,
options: EventsDeployOptions,
): Promise<EventsDeployOutcome> {
const deploy = () =>
this.#deploy(scope, script, options.eager === true);
return (
this.#layers.services.schedules?.gateDeploy?.(
script,
{ capped: options.lane === 'schedule' },
deploy,
) ?? deploy()
if (existing) return existing;
const started = this.#deploy(appUid, script).finally(() =>
this.#inFlight.delete(key),
);
this.#inFlight.set(key, started);
return started;
}
async #deploy(
handlerScope: HandlerScope,
appUid: string,
script: string,
eager: boolean,
): Promise<EventsDeployOutcome> {
const { appUid } = handlerScope;
const { stores, drivers } = this.#layers;
const backend = eventsWorkerScope(this.#layers.config);
const userScope = isUserScope(handlerScope);
// Names and hashes are enough to tell a stale request, so the set's
// sources are read and built only once the throttle has let it through.
const set = await stores.eventHandler.setForApp(handlerScope);
if (set.length === 0) return 'no-handlers';
const handlers = await stores.eventHandler.allForApp(appUid);
if (handlers.length === 0) return 'no-handlers';
const generated = generateEventsWorkerSource(handlers);
// Two apps that published byte-identical sets share one script, which
// is sound: the source is the same, and everything per-app arrives with
// the invocation. Whoever deploys first owns it. A user scope's script
// is one handler's, so it is shared the same way across holders.
const wanted = userScope
? set.find((entry) => userHandlerScript(entry, backend) === script)
: undefined;
// the invocation. Whoever deploys first owns it.
if (
userScope
? !wanted
: eventsWorkerScript(handlerSetHash(set), backend) !== script
)
return 'stale';
// Asked again inside the gate, where another deploy of this script may
// have just finished.
if (eager && userScope && (await this.#markedDeployed(script)))
return 'deployed';
if (await this.#tooManyDeploysThisHour(handlerScope))
return 'throttled';
const all = await stores.eventHandler.allForApp(handlerScope);
const handlers = wanted
? all.filter(
(handler) =>
handler.name === wanted.name &&
handler.sourceHash === wanted.sourceHash,
)
: all;
const generated = generateEventsWorkerSource(handlers);
// The set can move between the two reads.
if (
handlers.length === 0 ||
eventsWorkerScript(generated.setHash, backend) !== script
eventsWorkerScript(
generated.setHash,
eventsWorkerScope(this.#layers.config),
) !== script
)
return 'stale';
if (generated.tooLarge) {
@@ -338,7 +140,9 @@ export class EventsWorkerDeployer {
generated.broken.join(', '),
);
const owner = await this.#owner(handlerScope);
if (await this.#tooManyDeploysThisHour(appUid)) return 'throttled';
const owner = await this.#owner(appUid);
if (!owner) return 'no-owner';
const secret = this.#layers.config.events?.internalSecret;
@@ -367,7 +171,6 @@ export class EventsWorkerDeployer {
runtime: 'events',
source: generated.source,
omitOwnerToken: true,
cpuMs: this.#layers.config.events?.workerCpuMs,
// What the script compares an invocation against.
// Derived from the script name, so a redeploy of
// the same set binds the same key.
@@ -398,74 +201,34 @@ export class EventsWorkerDeployer {
);
return 'failed';
}
if (userScope) await this.#markDeployed(script);
return 'deployed';
}
async #markedDeployed(script: string): Promise<boolean> {
try {
return !!(await this.#layers.clients.redis.get(
deployedMarkerKey(script),
));
} catch (err) {
console.warn('[events] deployed marker unreadable', err);
return false;
}
}
async #markDeployed(script: string): Promise<void> {
try {
await this.#layers.clients.redis.set(
deployedMarkerKey(script),
'1',
'EX',
DEPLOYED_MARKER_TTL_SECONDS,
);
} catch (err) {
console.warn('[events] could not mark a script deployed', err);
}
}
/**
* Whether this scope has already deployed its hourly cap this hour. A
* flapping set (publish, remove, republish) provisions upstream on every
* miss, and this is the ceiling on that loop. An app's counter is per app;
* a user scope has its own, so one user cannot spend another's.
* Whether this app has already deployed `EVENTS_WORKER_DEPLOYS_PER_HOUR`
* times this hour. A flapping set (publish, remove, republish) provisions
* upstream on every miss, and this is the ceiling on that loop.
*/
async #tooManyDeploysThisHour(scope: HandlerScope): Promise<boolean> {
async #tooManyDeploysThisHour(appUid: string): Promise<boolean> {
const redis = this.#layers.clients.redis;
const hour = new Date().toISOString().slice(0, 13);
const userScope = isUserScope(scope);
const key = userScope
? `ev:deploys:{${scope.appUid}}:${scope.holder}:${hour}`
: `ev:deploys:{${scope.appUid}}:${hour}`;
const key = `ev:deploys:{${appUid}}:${hour}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, DEPLOY_THROTTLE_TTL_SECONDS);
return (
count >
(userScope
? EVENTS_USER_WORKER_DEPLOYS_PER_HOUR
: EVENTS_WORKER_DEPLOYS_PER_HOUR)
);
return count > EVENTS_WORKER_DEPLOYS_PER_HOUR;
}
/**
* The user a scope's deploy runs as, as an actor: the app's owner, or the
* holder of a user scope.
*/
/** The app owner, as an actor the deploy can run as. */
async #owner(
scope: HandlerScope,
appUid: string,
): Promise<
| (Actor & { user: { id: number; uuid: string; username: string } })
| null
> {
const { stores } = this.#layers;
let ownerUserId = scope.holder;
if (!isUserScope(scope)) {
const app = await stores.app.getByUid(scope.appUid);
ownerUserId = Number(app?.owner_user_id);
if (!app || !Number.isFinite(ownerUserId)) return null;
}
const app = await stores.app.getByUid(appUid);
const ownerUserId = Number(app?.owner_user_id);
if (!app || !Number.isFinite(ownerUserId)) return null;
const user = await stores.user.getById(ownerUserId);
if (!user?.id || !user.uuid || !user.username || user.suspended)
@@ -526,9 +289,11 @@ export class LocalEventsInvokeTransport {
this.#deployer = deployer;
}
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
// Built per attempt, so a send time stamped into the body is taken
// after a cold deploy rather than before it.
async send(call: EventsInvokeCall): Promise<{
status: number | null;
handled?: boolean;
error?: string;
}> {
const request = (): Request =>
new Request(
`http://${call.script}.workers.puter.localhost${EVENTS_INVOKE_PATH}`,
@@ -538,7 +303,7 @@ export class LocalEventsInvokeTransport {
'content-type': 'application/json',
'x-puter-events-key': call.key,
},
body: call.render ? call.render() : call.body,
body: call.body,
},
);
@@ -546,27 +311,22 @@ export class LocalEventsInvokeTransport {
const resident = await this.#dispatch(request(), call.timeoutMs);
if (resident !== null) return resident;
const scope = await this.#deployer.scopeFor(
const outcome = await this.#deployer.ensure(
call.appUid,
call.holder,
call.script,
);
if (!scope) return { status: null, error: 'deploy: no-holder' };
const outcome = await this.#deployer.ensure(scope, call.script, {
lane: call.lane,
});
if (outcome !== 'deployed')
return {
status: null,
error: `deploy: ${outcome}`,
...(outcome === 'busy' ? { deployBusy: true } : {}),
};
return { status: null, error: `deploy: ${outcome}` };
const retried = await this.#dispatch(request(), call.timeoutMs);
if (retried === null)
return { status: null, error: 'script not resident' };
return retried;
} catch (err) {
return failedAnswer(err);
return {
status: null,
error: err instanceof Error ? err.message : String(err),
};
}
}
@@ -577,7 +337,7 @@ export class LocalEventsInvokeTransport {
async #dispatch(
request: Request,
timeoutMs: number,
): Promise<EventsInvokeAnswer | null> {
): Promise<{ status: number; handled: boolean } | null> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<'timeout'>((resolve) => {
timer = setTimeout(() => resolve('timeout'), timeoutMs);
@@ -588,10 +348,12 @@ export class LocalEventsInvokeTransport {
this.#services.localworkerservice.dispatchEventsWorker(request),
timeout,
]);
if (response === 'timeout')
throw new DOMException('invocation timed out', 'TimeoutError');
if (response === 'timeout') throw new Error('invocation timed out');
if (response === null) return null;
return answerFrom(response);
return {
status: response.status,
handled: response.headers.get(EVENTS_HANDLED_HEADER) === '1',
};
} finally {
clearTimeout(timer);
}
@@ -154,26 +154,6 @@ describe('GET /fs/readdir over HTTP', () => {
).toEqual(['a', 'a/b']);
});
it('answers 404 for a directory whose parent chain is broken', async () => {
const { username, token } = env.users.user;
const path = `/${username}/Documents/http-orphan-${Date.now()}`;
const created = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, auth_token: token }),
});
expect(created.status).toBe(200);
const { uid } = (await created.json()) as { uid: string };
await env.server.clients.db.write(
'UPDATE fsentries SET path = NULL, parent_uid = ? WHERE uuid = ?',
['does-not-exist', uid],
);
await env.server.stores.fsEntry.invalidateEntryCacheByUuid(uid);
const response = await fetch(readdirUrl({ uid, auth_token: token }));
expect(response.status).toBe(404);
});
it('mkdir does not return internal ids, storage columns or tokens', async () => {
const { username, token } = env.users.user;
const response = await fetch(new URL('/fs/mkdir', env.apiOrigin), {
@@ -1,80 +0,0 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
describe('recursive readdir with offset', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = async (path: string, token: string, body: unknown) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
it('skips entries before applying the recursive page limit', async () => {
const { username, token } = env.users.user;
const base = `/${username}/Documents/rec-offset`;
for (const path of [base, `${base}/a`, `${base}/b`, `${base}/c`]) {
expect((await post('/fs/mkdir', token, { path })).status).toBe(200);
}
const list = async (body: Record<string, unknown>) => {
const response = await post('/fs/readdir', token, {
path: base,
recursive: true,
...body,
});
expect(response.status).toBe(200);
const page = (await response.json()) as {
items: Array<{ name: string }>;
};
return page.items.map((item) => item.name);
};
expect(await list({ limit: 3 })).toEqual(['a', 'b', 'c']);
// Non-recursive honours offset.
const flat = await post('/fs/readdir', token, {
path: base,
limit: 1,
offset: 2,
});
expect(
((await flat.json()) as Array<{ name: string }>).map((e) => e.name),
).toEqual(['c']);
expect(await list({ limit: 1, offset: 2 })).toEqual(['c']);
expect(await list({ offset: 2 })).toEqual(['c']);
expect(await list({ offset: 20 })).toEqual([]);
expect(await list({ limit: 1, offset: 1, sortOrder: 'desc' })).toEqual([
'b',
]);
const first = await post('/fs/readdir', token, {
path: base,
recursive: true,
limit: 1,
offset: 1,
});
const page = await first.json();
expect(page.items.map((item: { name: string }) => item.name)).toEqual([
'b',
]);
expect(page.cursor).toBeTypeOf('string');
expect(await list({ limit: 1, cursor: page.cursor })).toEqual(['c']);
// A cursor wins over offset, and an invalid offset is ignored, as
// recursive listings did before they honoured offset.
expect(
await list({ limit: 1, cursor: page.cursor, offset: 1 }),
).toEqual(['c']);
expect(await list({ limit: 1, offset: -1 })).toEqual(['a']);
});
});
@@ -1335,7 +1335,6 @@ export class FSController extends PuterController {
parent.path,
{
limit,
offset,
cursor:
typeof body.cursor === 'string'
? body.cursor
@@ -1,114 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { makeActor } from '../../core/actor.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
describe('/auth/check-app-acl', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = (path: string, body: unknown, token: string) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const makeDirAndApp = async () => {
const { username, token } = env.users.user;
const server = env.server;
const user = (await server.stores.user.getByUsername(username))!;
const path = `/${username}/Documents/cacl-${uuidv4()}`;
expect((await post('/fs/mkdir', { path }, token)).status).toBe(200);
const entry = (await server.stores.fsEntry.getEntryByPath(path))!;
const app = await (
server.stores.app.create as unknown as (
f: Record<string, unknown>,
o: { ownerUserId: number },
) => Promise<{ uid: string; id: number }>
)(
{
name: `cacl-${uuidv4()}`,
title: 'Check app acl',
index_url: `https://cacl-${uuidv4()}.test/`,
},
{ ownerUserId: user.id },
);
return { user, path, entry, app };
};
it('allows a granted file without poisoning the app permission cache', async () => {
const { token } = env.users.user;
const server = env.server;
const { user, path, entry, app } = await makeDirAndApp();
const grant = await post(
'/auth/grant-user-app',
{ app_uid: app.uid, permission: `fs:${entry.uuid}:read` },
token,
);
expect(grant.status).toBe(200);
const res = await post(
'/auth/check-app-acl',
{ subject: { path }, app: app.uid, mode: 'read' },
token,
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ allowed: true });
const asApp = makeActor({ user, app: { uid: app.uid, id: app.id } });
const descriptor = () => server.services.acl.fsDescriptor(path);
expect(
await server.services.acl.check(asApp, descriptor(), 'read'),
).toBe(true);
});
it('denies an app with no grant', async () => {
const { path, app } = await makeDirAndApp();
const res = await post(
'/auth/check-app-acl',
{ subject: { path }, app: app.uid, mode: 'read' },
env.users.user.token,
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ allowed: false });
});
it('refuses app and access tokens', async () => {
const { token, apiToken } = env.users.user;
const { path, entry, app } = await makeDirAndApp();
const grant = await post(
'/auth/grant-user-app',
{ app_uid: app.uid, permission: `fs:${entry.uuid}:read` },
token,
);
expect(grant.status).toBe(200);
const minted = await post(
'/auth/get-user-app-token',
{ app_uid: app.uid },
token,
);
expect(minted.status).toBe(200);
const appToken = ((await minted.json()) as { token: string }).token;
for (const credential of [appToken, apiToken]) {
const res = await post(
'/auth/check-app-acl',
{ subject: { path }, app: app.uid, mode: 'read' },
credential,
);
expect(res.status).toBe(403);
}
});
});
@@ -1,204 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = (path: string, body: unknown) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.users.user.token}`,
},
body: JSON.stringify(body),
});
const write = (path: string, content: string) =>
post('/fs/write', {
fileMetadata: { path, size: content.length, contentType: 'text/plain' },
fileContent: content,
});
const read = async (path: string) => {
const url = new URL('/read', env.apiOrigin);
url.searchParams.set('file', path);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${env.users.user.token}` },
});
return response.text();
};
const makeDir = async (name: string) => {
const path = `/${env.users.user.username}/Documents/${name}-${randomUUID().slice(0, 8)}`;
expect((await post('/fs/mkdir', { path })).status).toBe(200);
return path;
};
describe('legacy /copy onto an existing file', () => {
it('replaces the file with overwrite', async () => {
const dir = await makeDir('copy-onto');
expect((await write(`${dir}/a.txt`, 'aaa')).status).toBe(200);
expect((await write(`${dir}/b.txt`, 'bbbbbb')).status).toBe(200);
const copy = await post('/copy', {
source: `${dir}/a.txt`,
destination: `${dir}/b.txt`,
overwrite: true,
});
expect(copy.status).toBe(200);
const [result] = (await copy.json()) as Array<{
copied: { name: string; path: string };
overwritten?: { name: string };
}>;
expect(result!.copied.name).toBe('b.txt');
expect(result!.overwritten?.name).toBe('b.txt');
expect(await read(`${dir}/b.txt`)).toBe('aaa');
expect(await read(`${dir}/a.txt`)).toBe('aaa');
});
it('refuses without overwrite and leaves the file alone', async () => {
const dir = await makeDir('copy-onto-refused');
await write(`${dir}/a.txt`, 'aaa');
await write(`${dir}/b.txt`, 'bbbbbb');
const copy = await post('/copy', {
source: `${dir}/a.txt`,
destination: `${dir}/b.txt`,
});
expect(copy.status).toBe(409);
expect(((await copy.json()) as { code: string }).code).toBe(
'item_with_same_name_exists',
);
expect(await read(`${dir}/b.txt`)).toBe('bbbbbb');
});
it('still copies into a directory destination under the same name', async () => {
const dir = await makeDir('copy-into');
await write(`${dir}/a.txt`, 'aaa');
expect((await post('/fs/mkdir', { path: `${dir}/sub` })).status).toBe(
200,
);
const copy = await post('/copy', {
source: `${dir}/a.txt`,
destination: `${dir}/sub`,
});
expect(copy.status).toBe(200);
expect(await read(`${dir}/sub/a.txt`)).toBe('aaa');
});
it('refuses to overwrite a file with itself', async () => {
const dir = await makeDir('copy-self');
await write(`${dir}/a.txt`, 'aaa');
for (const destination of [`${dir}/a.txt`, dir]) {
const copy = await post('/copy', {
source: `${dir}/a.txt`,
destination,
overwrite: true,
});
expect(copy.status).toBe(400);
}
expect(await read(`${dir}/a.txt`)).toBe('aaa');
});
});
describe('signed folder listing', () => {
const signedListing = async (path: string) => {
const signed = await post('/sign', {
items: [{ path, action: 'read' }],
});
expect(signed.status).toBe(200);
const readUrl = new URL(
(await signed.json()).signatures[0].read_url as string,
);
return fetch(
new URL(`${readUrl.pathname}${readUrl.search}`, env.apiOrigin),
);
};
it('lists every child of a folder larger than one query', async () => {
const dir = await makeDir('big');
const parent = (await env.server.stores.fsEntry.getEntryByPath(dir))!;
const total = 10_050;
const now = Math.floor(Date.now() / 1000);
for (let start = 0; start < total; start += 500) {
const rows: unknown[][] = [];
for (let i = start; i < Math.min(total, start + 500); i++) {
const name = `f${String(i).padStart(5, '0')}.txt`;
rows.push([
randomUUID(),
parent.userId,
parent.id,
parent.uuid,
name,
`${dir}/${name}`,
0,
now,
now,
now,
0,
]);
}
await env.server.clients.db.write(
`INSERT INTO fsentries (uuid, user_id, parent_id, parent_uid, name, path, is_dir, modified, created, accessed, size) VALUES ${rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ')}`,
rows.flat(),
);
}
const response = await signedListing(dir);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toMatch(
/^application\/json/,
);
const children = (await response.json()) as Array<{
fsentry_name: string;
write_url?: string;
}>;
expect(children).toHaveLength(total);
expect(children[0]!.fsentry_name).toBe('f00000.txt');
expect(children.at(-1)!.fsentry_name).toBe('f10049.txt');
expect(new Set(children.map((c) => c.fsentry_name)).size).toBe(total);
expect(children.some((c) => c.write_url)).toBe(false);
}, 120_000);
it('answers a small folder in one body', async () => {
const dir = await makeDir('small');
await write(`${dir}/one.txt`, '1');
const response = await signedListing(dir);
expect(response.status).toBe(200);
expect(response.headers.get('content-length')).not.toBeNull();
const children = (await response.json()) as Array<{
fsentry_name: string;
}>;
expect(children.map((c) => c.fsentry_name)).toEqual(['one.txt']);
});
});
@@ -153,34 +153,6 @@ describe('legacy file downloads over HTTP', () => {
},
);
it.each(routes)(
'GET %s answers a ranged read of an empty file with an empty 200',
async (route) => {
const file = await writeFile(
`empty-${route.replace(/\W/g, '')}.bin`,
Buffer.alloc(0),
);
const ranged = await requestFor(route, file, {
headers: { Range: 'bytes=0-511' },
});
expect(ranged.status).toBe(200);
expect(await ranged.text()).toBe('');
},
);
it('GET /read answers byte_count on an empty file with an empty 200', async () => {
const file = await writeFile('empty-byte-count.bin', Buffer.alloc(0));
for (const query of ['byte_count=512', 'offset=0']) {
const url = new URL(`/read?${query}`, env.apiOrigin);
url.searchParams.set('file', file.path);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${env.users.user.token}` },
});
expect(response.status).toBe(200);
expect(await response.text()).toBe('');
}
});
// Storage hands over one chunk and then goes quiet, as a large object
// does while the client is still reading.
const stallReadsOf = (objectKey: string) => {
@@ -1,160 +0,0 @@
import type { Request, RequestHandler, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import type { LegacyFSController } from './LegacyFSController.js';
const PNG_2X2 =
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEUlEQVR4nGP4z8DwH4QZYAwAR8oH+WdZbrcAAAAASUVORK5CYII=';
let server: PuterServer;
let handler: RequestHandler;
type Driver = {
create: (a: Record<string, unknown>) => Promise<Record<string, unknown>>;
};
beforeAll(async () => {
server = await setupTestServer({
api_base_url: 'http://api.test.local',
static_hosting_domain: 'site.test',
} as unknown as Parameters<typeof setupTestServer>[0]);
await server.services.appIcon.ensureIconsDirectory();
const controller = server.controllers
.legacyFs as unknown as LegacyFSController;
const router = new PuterRouter();
controller.registerRoutes(router);
handler = router.routes.find(
(r) =>
r.path === '/get-launch-apps' &&
String(r.method).toLowerCase() === 'get',
)!.handler as RequestHandler;
}, 60_000);
afterAll(async () => {
await server?.shutdown();
});
const makeActor = async (): Promise<{ actor: Actor; id: number }> => {
const username = `v13-${Math.random().toString(36).slice(2, 10)}`;
const u = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
const r = (await server.stores.user.getById(u.id))!;
return {
id: r.id,
actor: {
user: {
id: r.id,
uuid: r.uuid,
username: r.username,
email: r.email ?? null,
email_confirmed: true,
},
} as unknown as Actor,
};
};
const createViaDriver = async (actor: Actor, icon: string) =>
runWithContext({ actor }, () =>
(server.drivers.apps as unknown as Driver).create({
object: {
name: `v13-${Math.random().toString(36).slice(2, 10)}`,
title: 'V',
index_url: `https://v13-${Math.random().toString(36).slice(2, 10)}.test/`,
icon,
},
}),
);
const launchApps = async (actor: Actor, appUid: string, userId: number) => {
await server.clients.db.write(
'INSERT INTO `app_opens` (`app_uid`, `user_id`, `ts`) VALUES (?, ?, ?)',
[appUid, userId, Math.floor(Date.now() / 1000)],
);
let body: { recent: Array<Record<string, unknown>> } | undefined;
const res = {
json: (v: unknown) => {
body = v as typeof body;
return res;
},
status: () => res,
setHeader: () => res,
} as unknown as Response;
await handler(
{
query: { icon_size: '64' },
headers: {},
body: {},
actor,
} as unknown as Request,
res,
() => {},
);
return body!.recent.find((e) => e.uuid === appUid)!;
};
describe('/get-launch-apps recent icon, verified', () => {
it('a valid data-URL icon written through the driver is migrated to a short URL', async () => {
const { actor, id } = await makeActor();
const app = await createViaDriver(
actor,
`data:image/png;base64,${PNG_2X2}`,
);
await vi.waitFor(
async () => {
const row = await server.stores.app.getByUid(app.uid as string);
expect(String(row?.icon).slice(0, 80)).toMatch(
/^https?:\/\/.*\/app-icon\/app-/,
);
},
{ timeout: 10_000 },
);
const entry = await launchApps(actor, app.uid as string, id);
expect(String(entry.icon).length).toBeLessThan(200);
expect(entry.iconCdnUrl).toMatch(/-64\.png$/);
}, 30_000);
it('returns an icon endpoint when migration cannot decode the stored icon', async () => {
const { actor, id } = await makeActor();
// PNG signature passes the write-path sniff; sharp rejects the rest.
const corrupt = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(1024 * 1024, 0x41),
]).toString('base64');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
let app: Record<string, unknown>;
try {
app = await createViaDriver(
actor,
`data:image/png;base64,${corrupt}`,
);
// Give the background pipeline time to try and fail.
await vi.waitFor(
() =>
expect(
warn.mock.calls.some(
([m]) => m === '[app-icon] icon processing failed',
),
).toBe(true),
{ timeout: 10_000 },
);
} finally {
warn.mockRestore();
}
const row = await server.stores.app.getByUid(app!.uid as string);
expect(String(row?.icon).startsWith('data:image/png')).toBe(true);
const entry = await launchApps(actor, app!.uid as string, id);
expect(entry.icon).toBe(
`http://api.test.local/app-icon/${app!.uid}/64`,
);
}, 30_000);
});
@@ -5111,8 +5111,9 @@ describe('LegacyFSController.updateFsentryThumbnail', () => {
// ── GET /get-launch-apps ────────────────────────────────────────────
//
// `recent` is a launch-metadata producer: it returns `index_url` for
// apps the user has opened before. The field is served whoever reads it,
// so AppDriver's hosted-backing guard is applied here too.
// apps the user has opened before. The taskbar happens to launch these
// by name (so AppDriver's hosted-backing guard applies), but the field
// is served regardless, so the guard is applied here too.
describe('LegacyFSController GET /get-launch-apps', () => {
const hostedUrl = (sub: string) => `https://${sub}.site.puter.localhost/`;
@@ -5292,39 +5293,4 @@ describe('LegacyFSController GET /get-launch-apps', () => {
expect(returned).toEqual(recentUids);
});
it('skips the recent opens for recent=0 and keeps recommended', async () => {
const { actor, userId } = await makeUser();
const app = await makeHostedApp(userId, 'unregistered-skip');
await recordOpen(userId, app.uid);
const call = async (query: Record<string, unknown>) => {
const { res, captured } = makeRes();
await withActor(actor, async () => {
await getLaunchAppsHandler()(
makeReq({ actor, query }),
res,
() => {
throw new Error('handler called next() unexpectedly');
},
);
});
return captured.body as {
recommended: unknown[];
recent: unknown[];
};
};
const full = await call({});
expect(full.recent.length).toBeGreaterThan(0);
const opens = vi.spyOn(server.stores.app, 'getRecentAppOpens');
try {
const skipped = await call({ recent: '0' });
expect(skipped.recent).toEqual([]);
expect(skipped.recommended).toEqual(full.recommended);
expect(opens).not.toHaveBeenCalled();
} finally {
opens.mockRestore();
}
});
});
@@ -26,7 +26,6 @@ import {
assertResolvedActor,
isAccessTokenActor,
isAppActor,
isPlainUserActor,
makeActor,
} from '../../core/actor.js';
import { Context } from '../../core/context.js';
@@ -96,7 +95,6 @@ import {
assertAccess,
assertCanCreate,
assertCanMoveInto,
contextBoundStream,
emitGuiItemEvent,
expandClientPath,
findOverwriteTarget,
@@ -104,7 +102,6 @@ import {
getString,
type GuiItemEventName,
loadLegacyAssociatedApps,
rangeForEntry,
replacedByMove,
resolveReadRange,
resolveV1Selector,
@@ -384,9 +381,7 @@ export class LegacyFSController extends PuterController {
let recent: unknown[] = [];
const userId = req.actor?.user?.id;
// `recent=0`: a caller that only shows `recommended`.
const wantsRecent = req.query.recent !== '0';
if (userId && wantsRecent) {
if (userId) {
const recentUids = await this.stores.app.getRecentAppOpens(
userId,
{ limit: 10 },
@@ -401,9 +396,11 @@ export class LegacyFSController extends PuterController {
.filter(Boolean);
// Don't hand out an index_url whose puter-hosted backing is
// gone or reclaimed. This list is a launch-metadata producer
// like any other — a consumer reading index_url straight off
// it shouldn't inherit a stale origin.
// gone or reclaimed. The taskbar launches recents by name (so
// AppDriver's guard applies), but this list is a
// launch-metadata producer like any other — a future consumer
// reading index_url straight off it shouldn't inherit a stale
// origin.
let backingGoneFlags: boolean[];
try {
backingGoneFlags =
@@ -421,9 +418,9 @@ export class LegacyFSController extends PuterController {
return {
...toAppSummary(app, {
config: this.config,
apiBaseUrl: this.config.api_base_url,
iconSize,
}),
icon: app.icon ?? null,
index_url: backingGone ? null : app.index_url,
...(backingGone
? { privateAccess: buildHostedBackingDenial() }
@@ -804,23 +801,10 @@ export class LegacyFSController extends PuterController {
this.stores.fsEntry,
body.source,
);
const destination = await resolveV1Selector(
const destinationParent = await resolveV1Selector(
this.stores.fsEntry,
body.destination,
);
// A file destination names the copy itself, replaced only with
// `overwrite`.
let newName = getString(body, 'new_name');
let destinationParent = destination;
if (!destination.isDir && !newName && destination.parentUid) {
const parent = await this.stores.fsEntry.getEntryByUuid(
destination.parentUid,
);
if (parent) {
destinationParent = parent;
newName = destination.name;
}
}
await assertAccess(
this.services.acl,
@@ -845,14 +829,14 @@ export class LegacyFSController extends PuterController {
? await findOverwriteTarget(
this.stores.fsEntry,
destinationParent,
newName ?? source.name,
getString(body, 'new_name') ?? source.name,
)
: null;
const copy = await this.services.fs.copy(userId, {
source,
destinationParent,
newName,
newName: getString(body, 'new_name'),
overwrite: overwriteRequested,
dedupeName: getBoolean(body, 'dedupe_name', 'change_name') ?? false,
});
@@ -948,7 +932,9 @@ export class LegacyFSController extends PuterController {
// Trash, and `null`/`{}` when restoring. See
// `src/gui/src/helpers.js` → `window.move_items`.
newMetadata: (body.new_metadata ?? undefined) as
Record<string, unknown> | null | undefined,
| Record<string, unknown>
| null
| undefined,
});
const overwrittenEntry = replacedByMove(overwriteTarget, moved, name);
const oldPath = source.path;
@@ -1303,10 +1289,7 @@ export class LegacyFSController extends PuterController {
});
}
const range = rangeForEntry(
entry,
resolveReadRange(req.headers.range, query),
);
const range = resolveReadRange(req.headers.range, query);
const download = await this.services.fs.readContent(entry, {
range,
});
@@ -1422,7 +1405,8 @@ export class LegacyFSController extends PuterController {
}
type SignedOrEmpty =
(SignedFile & { path?: string }) | Record<string, never>;
| (SignedFile & { path?: string })
| Record<string, never>;
const result: { signatures: SignedOrEmpty[]; token?: string } = {
signatures: [],
};
@@ -1806,50 +1790,19 @@ export class LegacyFSController extends PuterController {
// owner, so the layout it reveals is theirs to reveal, and the child
// signatures expire; the paths are the only part that outlives them.
if (entry.isDir) {
const signChild = (child: FSEntry) => {
const children = await this.services.fs.listDirectory(entry.uuid);
const signedChildren = children.map((child) => {
const { write_url: _, ...rest } = signEntry(child, signingCfg, {
ttlSeconds: NON_OWNER_SIGNATURE_TTL_SECONDS,
});
return { ...rest, path: maskEntryPath(child) };
};
const pages = this.services.fs.listDirectoryPages(entry.uuid);
const first = await pages.next();
const firstPage = first.done ? [] : first.value;
const second = await pages.next();
if (second.done) {
res.json(firstPage.map(signChild));
return;
}
// Past one page the listing streams, so a large folder isn't held
// in memory whole. A failure partway cuts the response off.
const secondPage = second.value;
const serialize = (child: FSEntry) =>
JSON.stringify(signChild(child));
async function* allPages() {
yield firstPage;
yield secondPage;
yield* pages;
}
async function* body() {
let separator = '[';
for await (const page of allPages()) {
if (page.length === 0) continue;
yield separator + page.map(serialize).join(',');
separator = ',';
}
yield separator === '[' ? '[]' : ']';
}
res.type('application/json');
await pipeToResponse(contextBoundStream(body()), res);
});
res.json(signedChildren);
return;
}
// File: stream bytes with Range support.
const range = rangeForEntry(
entry,
resolveReadRange(req.headers.range, query),
);
const range = resolveReadRange(req.headers.range, query);
const download = await this.services.fs.readContent(entry, {
range,
});
@@ -2039,17 +1992,15 @@ export class LegacyFSController extends PuterController {
*/
checkAppAcl = async (req: Request, res: Response): Promise<void> => {
const actor = this.#requireActor(req);
// Sessions only: for an app, this would be a window onto its neighbours' grants.
if (!isPlainUserActor(actor))
throw new HttpError(403, 'actor must be a user', {
legacyCode: 'forbidden',
});
const body = asRecord(req.body);
const subjectRef = body.subject;
const appRef = body.app;
const mode = (getString(body, 'mode') ?? 'read') as
'see' | 'list' | 'read' | 'write';
| 'see'
| 'list'
| 'read'
| 'write';
if (!subjectRef || !appRef)
throw new HttpError(400, '`subject` and `app` are required', {
legacyCode: 'bad_request',
@@ -2071,7 +2022,7 @@ export class LegacyFSController extends PuterController {
legacyCode: 'subject_does_not_exist',
});
let app: { uid: string; id: number } | null = null;
let app: { uid: string } | null = null;
if (typeof appRef === 'string') {
app =
(await this.stores.app.getByUid(appRef)) ??
@@ -2085,7 +2036,7 @@ export class LegacyFSController extends PuterController {
// Build an actor-under-user shape for the check.
const actorForApp = makeActor({
user: actor.user,
app: { uid: app.uid, id: app.id },
app: { uid: (app as { uid: string }).uid },
});
const allowed = await acl.check(actorForApp, descriptor, mode);
res.json({ allowed });
@@ -2133,12 +2084,10 @@ export class LegacyFSController extends PuterController {
// by default, so opt in by naming the downloader as who pays.
req.egressActor = actor;
const range = rangeForEntry(
entry,
const range =
typeof req.headers.range === 'string'
? req.headers.range
: undefined,
);
: undefined;
const download = await this.services.fs.readContent(entry, {
range,
});
@@ -17,19 +17,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Writable } from 'node:stream';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Context, runWithContext } from '../../core/context.js';
import type { PuterServer } from '../../server.js';
import { createTestUser, setupTestServer } from '../../testUtil.js';
import {
asRecord,
contextBoundStream,
fsEntryMimeType,
getBoolean,
getString,
loadLegacyAssociatedApps,
rangeForEntry,
resolveReadRange,
signEntry,
signEntryThumbnail,
@@ -709,50 +705,3 @@ describe('resolveReadRange', () => {
).toThrow('Invalid byte_count');
});
});
describe('rangeForEntry', () => {
it('drops the range for an empty file only', () => {
const entry = (size: number) => ({ size }) as FSEntry;
expect(rangeForEntry(entry(0), 'bytes=0-511')).toBeUndefined();
expect(rangeForEntry(entry(10), 'bytes=0-4')).toBe('bytes=0-4');
});
});
describe('contextBoundStream', () => {
it("reads every chunk in the caller's context", async () => {
// A sink slower than the source that completes writes from outside
// any request, so later chunks are asked for from its drain.
const pending: Array<() => void> = [];
const flush = runWithContext({}, () =>
setInterval(() => pending.splice(0).forEach((done) => done()), 5),
);
const sink = new Writable({
highWaterMark: 1,
write(_chunk, _encoding, done) {
pending.push(done);
},
});
const seen: unknown[] = [];
try {
await runWithContext({ actor: 'caller' as never }, async () => {
async function* chunks() {
for (let i = 0; i < 40; i++) {
await new Promise((resolve) => setTimeout(resolve, 1));
seen.push(Context.get('actor'));
yield 'x';
}
}
await new Promise((resolve, reject) => {
contextBoundStream(chunks())
.pipe(sink)
.on('finish', resolve)
.on('error', reject);
});
});
} finally {
clearInterval(flush);
}
expect(seen).toHaveLength(40);
expect(seen.every((actor) => actor === 'caller')).toBe(true);
});
});
@@ -17,9 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { AsyncResource } from 'node:async_hooks';
import { posix as pathPosix } from 'node:path';
import { Readable } from 'node:stream';
import { contentType as contentTypeFromMime } from 'mime-types';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
@@ -152,29 +150,6 @@ export function resolveReadRange(
return undefined;
}
/**
* A response body read from `chunks` for `pipeToResponse`. Each chunk is pulled
* in the caller's async context, which share masking reads, not in whichever
* socket event asked for more.
*/
export function contextBoundStream(
chunks: AsyncGenerator<string, void, undefined>,
): Readable {
const next = AsyncResource.bind(() => chunks.next());
const stop = AsyncResource.bind(() => chunks.return(undefined));
return Readable.from({
[Symbol.asyncIterator]: () => ({ next, return: stop }),
});
}
/** No range is satisfiable on an empty file, so it reads whole: empty, 200. */
export function rangeForEntry(
entry: FSEntry,
range: string | undefined,
): string | undefined {
return entry.size === 0 ? undefined : range;
}
// Accepts either `{ path }` or `{ uid }` or `{ id }` or `{ parent, name }`
// from a legacy body field. Returns a resolved entry (throwing 404 if not
// found or 400 if no usable ref is present).
@@ -17,9 +17,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { Request, RequestHandler, Response } from 'express';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
@@ -27,7 +24,6 @@ import type { Actor } from '../../core/actor.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { embedFrameAncestors, embedHostOrigin } from './HomepageController.js';
// ── Test harness ────────────────────────────────────────────────────
//
@@ -79,7 +75,6 @@ interface CapturedResponse {
statusCode: number;
body: unknown;
contentType?: string;
location?: string;
}
const makeReq = (init: {
@@ -88,7 +83,6 @@ const makeReq = (init: {
actor?: Actor;
hostname?: string;
protocol?: string;
originalUrl?: string;
}): Request => {
return {
body: {},
@@ -96,7 +90,6 @@ const makeReq = (init: {
headers: {},
params: init.params ?? {},
path: init.path ?? '/',
originalUrl: init.originalUrl ?? init.path ?? '/',
hostname: init.hostname ?? 'test.local',
protocol: init.protocol ?? 'http',
actor: init.actor,
@@ -134,11 +127,6 @@ const makeRes = () => {
captured.contentType = value;
return res;
}),
redirect: vi.fn((location: string) => {
captured.statusCode = 302;
captured.location = location;
return res;
}),
};
return { res: res as unknown as Response, captured };
};
@@ -222,72 +210,23 @@ describe('HomepageController shell routes', () => {
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
});
it('leaves guiTokenDigest out for a session cookie that is not a user session', async () => {
const { actor } = await makeUser();
const appActor = {
...actor,
app: { id: 1, uid: 'app-test' },
session: { uid: uuidv4() },
} as unknown as Actor;
const req = Object.assign(makeReq({ path: '/', actor: appActor }), {
tokenSource: 'cookie',
cookies: { puter_token_v2: 'legacy' },
});
const { res, captured } = makeRes();
await callRoute('get', '/', req, res);
expect(String(captured.body)).not.toContain('guiTokenDigest');
});
it('serves the shell on /dashboard and /action', async () => {
for (const path of ['/dashboard', '/dashboard/', '/action/*splat']) {
it('serves the shell on /settings, /dashboard, /desktop, /action, /@:username', async () => {
for (const path of [
'/settings',
'/settings/*splat',
'/dashboard',
'/dashboard/',
'/desktop',
'/desktop/',
'/action/*splat',
'/@:username',
]) {
const { res, captured } = makeRes();
await callRoute('get', path, makeReq({ path }), res);
expect(typeof captured.body).toBe('string');
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
}
});
it('redirects /desktop to the root, keeping the query', async () => {
for (const path of ['/desktop', '/desktop/']) {
const { res, captured } = makeRes();
await callRoute(
'get',
path,
makeReq({ path, originalUrl: `${path}?ref=abc&x=1` }),
res,
);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/?ref=abc&x=1');
expect(captured.body).toBeUndefined();
}
const { res, captured } = makeRes();
await callRoute('get', '/desktop', makeReq({ path: '/desktop' }), res);
expect(captured.location).toBe('/');
});
it('redirects /settings to the dashboard Home tab', async () => {
for (const path of ['/settings', '/settings/*splat']) {
const { res, captured } = makeRes();
await callRoute('get', path, makeReq({ path }), res);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/dashboard#home');
expect(captured.body).toBeUndefined();
}
});
it('redirects /@:username to the dashboard', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/@:username',
makeReq({ path: '/@alice', params: { username: 'alice' } }),
res,
);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/');
expect(captured.body).toBeUndefined();
});
});
// ── /app/:name ──────────────────────────────────────────────────────
@@ -417,21 +356,47 @@ describe('HomepageController GET /app/:name', () => {
expect(html).not.toContain('owner_user_id');
});
it('redirects /desktop/app/:name to /app/:name, keeping the query', async () => {
it('serves the same app shell under /desktop/app/:name', async () => {
const { userId } = await makeUser();
const name = `desk-${Math.random().toString(36).slice(2, 10)}`;
await server.stores.app.create(
{
name,
title: 'Desktop App',
description: 'opens on the desktop instead of the dashboard',
index_url: `https://example.com/${name}/`,
approved_for_listing: 1,
},
{ ownerUserId: userId },
);
const { res, captured } = makeRes();
await callRoute(
'get',
'/desktop/app/:name',
makeReq({ params: { name }, path: `/desktop/app/${name}` }),
res,
);
expect(captured.statusCode).toBe(200);
const html = String(captured.body);
expect(html).toMatch(/<!DOCTYPE html>/i);
expect(html).toContain('Desktop App');
});
it('returns 404 under /desktop/app/:name when the app is unknown', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/desktop/app/:name',
makeReq({
params: { name: 'editor' },
path: '/desktop/app/editor',
originalUrl: '/desktop/app/editor?file=%2Falice%2Fa.txt&x=1',
params: { name: 'no-such-app' },
path: '/desktop/app/no-such-app',
}),
res,
);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe('/app/editor?file=%2Falice%2Fa.txt&x=1');
expect(captured.body).toBeUndefined();
expect(captured.statusCode).toBe(404);
expect(String(captured.body)).toMatch(/<!DOCTYPE html>/i);
});
it('omits index_url even for a public app — the shell is not the launch authority', async () => {
@@ -465,141 +430,39 @@ describe('HomepageController GET /app/:name', () => {
// -- app landing rate limit --
describe('HomepageController app landing rate limit', () => {
it('rate-limits /app/:name per network', () => {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === '/app/:name',
);
if (!route) throw new Error('No GET /app/:name route');
expect(route.options.rateLimit).toEqual({
scope: 'app-landing',
limit: 600,
window: 60_000,
key: 'ip',
});
it('rate-limits /app/:name and /desktop/app/:name per network', () => {
for (const path of ['/app/:name', '/desktop/app/:name']) {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === path,
);
if (!route) throw new Error(`No GET ${path} route`);
expect(route.options.rateLimit).toEqual({
scope: 'app-landing',
limit: 600,
window: 60_000,
key: 'ip',
});
}
});
});
// ── /show/* ─────────────────────────────────────────────────────────
describe('HomepageController GET /show/*splat', () => {
it('redirects to the path in the dashboard Files tab, keeping the query', async () => {
it('emits a launch_app explorer call with the post-/show path', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/show/*splat',
makeReq({
path: '/show/alice/My%20Documents',
originalUrl: '/show/alice/My%20Documents?ref=abc',
}),
makeReq({ path: '/show/alice/Documents' }),
res,
);
expect(captured.statusCode).toBe(302);
expect(captured.location).toBe(
'/dashboard?ref=abc#files/alice/My%20Documents',
);
expect(captured.body).toBeUndefined();
});
});
// ── /sw.js ──────────────────────────────────────────────────────────
describe('HomepageController GET /sw.js', () => {
const headersOf = (res: Response): Record<string, string> =>
Object.fromEntries(
(res.set as ReturnType<typeof vi.fn>).mock.calls.map(
([key, value]) => [String(key).toLowerCase(), String(value)],
),
);
it('serves the worker with root scope, as JavaScript, revalidated each time', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'puter-sw-route-'));
await mkdir(path.join(root, 'dist'));
await writeFile(path.join(root, 'dist', 'sw.js'), '// worker\n');
const config = server.services.homepage.config as {
gui_assets_root?: string;
env?: string;
};
const prev = { root: config.gui_assets_root, env: config.env };
config.gui_assets_root = root;
config.env = 'prod';
const { res, captured } = makeRes();
try {
await callRoute('get', '/sw.js', makeReq({ path: '/sw.js' }), res);
} finally {
config.gui_assets_root = prev.root;
config.env = prev.env;
}
expect(captured.statusCode).toBe(200);
expect(captured.body).toBe('// worker\n');
expect(headersOf(res)).toMatchObject({
'content-type': 'text/javascript; charset=utf-8',
'cache-control': 'no-cache',
'service-worker-allowed': '/',
});
});
it('is a 404 when there is no worker to serve', async () => {
const config = server.services.homepage.config as {
gui_assets_root?: string;
};
const prev = config.gui_assets_root;
config.gui_assets_root = path.join(tmpdir(), 'no-such-gui-root');
const { res, captured } = makeRes();
try {
await callRoute('get', '/sw.js', makeReq({ path: '/sw.js' }), res);
} finally {
config.gui_assets_root = prev;
}
expect(captured.statusCode).toBe(404);
});
it('is registered for the root subdomain only', () => {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === '/sw.js',
);
expect(route?.options.subdomain).toBe('');
});
});
describe('embedFrameAncestors', () => {
const config = {
origin: 'https://example.test',
domain: 'example.test',
api_base_url: 'https://backend.example.test',
};
it('names a sibling subdomain that embeds the browser', () => {
expect(
embedFrameAncestors(config, 'https://sheets.example.test/doc/1'),
).toBe("'self' https://sheets.example.test");
});
it('refuses the configured API host even off the api label', () => {
expect(
embedFrameAncestors(config, 'https://backend.example.test/x'),
).toBe("'self'");
});
});
describe('embedHostOrigin', () => {
const config = {
origin: 'https://example.test',
domain: 'example.test',
api_base_url: 'https://backend.example.test',
static_hosting_domain: 'site.example.test',
};
it.each([
['https://sheets.example.test/doc/1', 'https://sheets.example.test'],
['https://backend.example.test/x', null],
['https://api.example.test/', null],
['https://someone.site.example.test/', null],
['https://a.sheets.example.test/', null],
['http://sheets.example.test/', null],
['https://example.test/', null],
['not a url', null],
[undefined, null],
])('%s frames as %s', (referer, expected) => {
expect(embedHostOrigin(config, referer)).toBe(expected);
// The launch payload is JSON-serialized into the rendered HTML.
// We assert the rendered shell included the explorer launch hint
// pointing at the expected (slashed) path.
const html = String(captured.body);
expect(html).toContain('launch_app');
expect(html).toContain('explorer');
expect(html).toContain('/alice/Documents');
});
});
@@ -20,39 +20,14 @@
import express from 'express';
import path from 'node:path';
import { PuterController } from '../types.js';
import { isPlainUserActor } from '../../core/actor.js';
import { toAppShellView } from '../../util/appShellView.js';
import type { PuterRouter } from '../../core/http/PuterRouter';
import {
isReservedSubdomain,
subdomainLabelOf,
} from '../../core/http/middleware/hostRedirects.js';
import type { IConfig } from '../../types';
import type { UserRow } from '../../stores/user/UserStore';
import type {
PuterHomepageService,
PageMeta,
PopupFrame,
ShellContext,
LaunchOptions,
} from '../../services/homepage/PuterHomepageService';
/**
* GUI popups a first-party page can show in a frame at `/embed/<action>`,
* instead of opening `/action/<action>` in a window, plus the upgrade and
* top-up dialogs, which it would otherwise open in a dashboard tab, and the
* email confirmation, which it otherwise can't show.
*/
export const FRAMED_POPUP_ACTIONS = [
'sign-in',
'show-open-file-picker',
'show-save-file-picker',
'show-directory-picker',
'upgrade',
'top-up',
'request-email-confirmation',
'attach-custom-domain',
] as const;
/** Longest description/og:description/twitter:description on `/app/<name>`. */
export const APP_META_DESCRIPTION_MAX = 150;
@@ -68,9 +43,9 @@ const APP_LANDING_LIMIT = {
};
/**
* App descriptions can run long; search snippets and social cards want a short
* blurb. Collapses whitespace, then cuts at a word boundary with an ellipsis so
* the result never exceeds `APP_META_DESCRIPTION_MAX`.
* App descriptions can run long; search snippets and social cards want a
* short blurb. Collapses whitespace, then cuts at a word boundary with an
* ellipsis so the result never exceeds `APP_META_DESCRIPTION_MAX`.
*/
export function appMetaDescription(text: string): string {
const clean = text.replace(/\s+/g, ' ').trim();
@@ -81,64 +56,6 @@ export function appMetaDescription(text: string): string {
return `${cut.replace(/[\s,;:.!?…—-]+$/, '')}…`;
}
type EmbedFramingConfig = Pick<
IConfig,
| 'origin'
| 'domain'
| 'api_base_url'
| 'static_hosting_domain'
| 'static_hosting_domain_alt'
| 'private_app_hosting_domain'
| 'private_app_hosting_domain_alt'
>;
/**
* The origin of a first-party page that may frame an `/embed/*` page, named
* exactly from the request's referrer, or null. A wildcard over `*.<domain>`
* would also admit user-hosted sites whenever the hosting domains sit under it,
* so only a single label under `<domain>` qualifies, and not one this server
* answers itself (the API, WebDAV, SDK and native-app hosts).
*/
export function embedHostOrigin(
config: EmbedFramingConfig,
referer: string | undefined,
): string | null {
const gui = new URL(config.origin ?? `https://${config.domain}`);
let parent: URL;
try {
parent = new URL(referer ?? '');
} catch {
return null;
}
const host = parent.hostname.toLowerCase();
const label = subdomainLabelOf(host, config.domain);
const hostingDomains = [
config.static_hosting_domain,
config.static_hosting_domain_alt,
config.private_app_hosting_domain,
config.private_app_hosting_domain_alt,
]
.map((d) => (d ?? '').toLowerCase().split(':')[0])
.filter(Boolean);
const allowed =
parent.protocol === gui.protocol &&
parent.port === gui.port &&
label !== undefined &&
!isReservedSubdomain(label) &&
host !== new URL(config.api_base_url).hostname &&
!hostingDomains.some((d) => host === d || host.endsWith(`.${d}`));
return allowed ? parent.origin : null;
}
/** `frame-ancestors` for an `/embed/*` page: the GUI and {@link embedHostOrigin}. */
export function embedFrameAncestors(
config: EmbedFramingConfig,
referer: string | undefined,
): string {
const host = embedHostOrigin(config, referer);
return host ? `'self' ${host}` : "'self'";
}
/**
* Routes that render the Puter GUI shell, plus a catch-all static fallback
* under `<gui_assets_root>/src` for non-dist/src paths (images, fonts, lib
@@ -172,7 +89,7 @@ export class HomepageController extends PuterController {
req: express.Request,
res: express.Response,
metaOverrides: Partial<PageMeta> = {},
popupFrame?: PopupFrame,
launch: LaunchOptions = {},
) => {
const meta = { ...defaultMeta(req), ...metaOverrides };
const actor =
@@ -181,133 +98,30 @@ export class HomepageController extends PuterController {
actor?: Parameters<typeof homepage.send>[0]['actor'];
}
).actor ?? null;
await homepage.send(
{
req,
res,
actor,
guiTokenDigest: this.#guiTokenDigest(req, actor),
},
meta,
popupFrame,
);
await homepage.send({ req, res, actor }, meta, launch);
};
// -- Root + path-aliased shell routes ------------------------
router.get('/', {}, (req, res) => sendShell(req, res));
/**
* Headers shared by the `/embed/*` pages; false when the request was
* redirected instead. SDK bootstrap parameters must not change an
* embed's account or API origin, so a query string is dropped. Checked
* on the raw URL; `req.query` drops pairs past the parser's limit.
*/
const prepareEmbed = (
req: express.Request,
res: express.Response,
path: string,
): boolean => {
if (req.originalUrl.includes('?')) {
res.redirect(path);
return false;
}
res.removeHeader('X-Frame-Options');
res.setHeader(
'Content-Security-Policy',
`frame-ancestors ${embedFrameAncestors(this.config, req.get('referer'))}`,
);
res.setHeader('Cache-Control', 'no-store');
return true;
};
router.get('/embed/apps', {}, async (req, res) => {
if (!prepareEmbed(req, res, '/embed/apps')) return;
// Shown to visitors without a session; a signed-in embed fetches
// its own list, so a failure here only costs signed-out visitors.
const guestApps = await this.services.recommendedApps
.getRecommendedApps()
.catch((): Array<Record<string, unknown>> => []);
res.send(homepage.renderAppBrowserEmbed({ guestApps }));
});
router.get('/embed/feedback', {}, (req, res) => {
if (!prepareEmbed(req, res, '/embed/feedback')) return;
// The feedback is recorded against this origin, which
// frame-ancestors has just pinned to the actual framing page.
res.send(
homepage.renderFeedbackEmbed({
hostOrigin: embedHostOrigin(
this.config,
req.get('referer'),
),
}),
);
});
// The GUI runs the popup's own boot, answering the framing page
// instead of an opener.
for (const action of FRAMED_POPUP_ACTIONS) {
const embedPath = `/embed/${action}`;
router.get(embedPath, {}, async (req, res) => {
// Routes match case-insensitively and with a trailing slash,
// and the shell boots the desktop for any other spelling, so
// only the exact one may be framed.
if (req.originalUrl !== embedPath) {
res.redirect(embedPath);
return;
}
if (!prepareEmbed(req, res, embedPath)) return;
await sendShell(
req,
res,
{},
{
hostOrigin: embedHostOrigin(
this.config,
req.get('referer'),
),
},
);
});
}
// Old settings links land on the dashboard's Home tab.
router.get('/settings', {}, (_req, res) =>
res.redirect('/dashboard#home'),
);
router.get('/settings/*splat', {}, (_req, res) =>
res.redirect('/dashboard#home'),
);
router.get('/settings', {}, (req, res) => sendShell(req, res));
router.get('/settings/*splat', {}, (req, res) => sendShell(req, res));
router.get('/dashboard', {}, (req, res) => sendShell(req, res));
router.get('/dashboard/', {}, (req, res) => sendShell(req, res));
const queryOf = (req: express.Request) => {
const separator = req.originalUrl.indexOf('?');
return separator === -1 ? '' : req.originalUrl.slice(separator);
};
// Old desktop links land on the dashboard, query intact.
router.get('/desktop', {}, (req, res) =>
res.redirect(`/${queryOf(req)}`),
);
router.get('/desktop/', {}, (req, res) =>
res.redirect(`/${queryOf(req)}`),
);
router.get('/desktop', {}, (req, res) => sendShell(req, res));
router.get('/desktop/', {}, (req, res) => sendShell(req, res));
router.get('/action/*splat', {}, (req, res) => sendShell(req, res));
// Old links to a user's public folder land on the dashboard.
router.get('/@:username', {}, (_req, res) => res.redirect('/'));
// Old desktop app links open the app in the dashboard, query intact.
router.get('/desktop/app/:name', {}, (req, res) => {
const name = encodeURIComponent(String(req.params.name ?? ''));
res.redirect(`/app/${name}${queryOf(req)}`);
});
router.get('/@:username', {}, (req, res) => sendShell(req, res));
// -- /app/:name - app metadata baked into the shell ----------
// `/desktop/app/:name` is the same landing booted on the desktop
// instead of the dashboard; the GUI strips the prefix and treats the
// rest of the path identically, so both render the same shell.
const sendAppShell = async (
req: express.Request,
@@ -362,25 +176,26 @@ export class HomepageController extends PuterController {
{ rateLimit: APP_LANDING_LIMIT },
sendAppShell,
);
router.get(
'/desktop/app/:name',
{ rateLimit: APP_LANDING_LIMIT },
sendAppShell,
);
// -- /show/* - launch explorer with the requested file path --
// Old /show links open the path in the dashboard's Files tab, query intact.
router.get('/show/*splat', {}, (req, res) => {
const filePath = req.path.slice('/show'.length);
res.redirect(`/dashboard${queryOf(req)}#files${filePath}`);
});
// -- /sw.js - the service worker, scoped to the whole origin --
router.get('/sw.js', { subdomain: '' }, async (_req, res) => {
const source = await homepage.serviceWorkerSource();
if (source === null) {
res.status(404).send('Not found');
return;
}
res.set('Content-Type', 'text/javascript; charset=utf-8');
res.set('Cache-Control', 'no-cache');
res.set('Service-Worker-Allowed', '/');
res.send(source);
const launch: LaunchOptions = {
on_initialized: [
{
$: 'window-call',
fn_name: 'launch_app',
args: [{ name: 'explorer', path: filePath }],
},
],
};
return sendShell(req, res, {}, launch);
});
// -- Fallback static mount -----------------------------------
@@ -395,38 +210,6 @@ export class HomepageController extends PuterController {
);
}
}
/**
* Lets the GUI skip `/get-gui-token`, which reads the session cookie: the
* digest of the token it would hand over, null when there is no session
* cookie (either name the auth probe reads), and undefined (the GUI asks)
* when a cookie didn't resolve to a session.
*/
#guiTokenDigest(
req: express.Request,
actor: ShellContext['actor'],
): string | null | undefined {
if (req.tokenSource === 'cookie') {
if (!actor || !isPlainUserActor(actor) || !actor.session?.uid) {
return undefined;
}
return this.services.auth.guiTokenDigest(
actor.user as UserRow,
actor.session.uid,
);
}
const cookies = (req.cookies ?? {}) as Record<string, unknown>;
const hasSessionCookie = [
this.config.cookie_name,
'puter_token_v2',
].some(
(name) =>
!!name &&
typeof cookies[name] === 'string' &&
cookies[name] !== '',
);
return hasSessionCookie ? undefined : null;
}
}
const safeJsonParse = (s: string): Record<string, unknown> | null => {
@@ -198,7 +198,7 @@ describe('HostingController /delete-site', () => {
);
expect(emitSpy).toHaveBeenCalledWith(
'subdomain.delete',
{ subdomain: site.subdomain, uid: String(site.uuid), userId },
{ subdomain: site.subdomain, uid: String(site.uuid) },
{},
);
} finally {
-2
View File
@@ -27,7 +27,6 @@ import { EventsController } from './events/EventsController.js';
import { FSController } from './fs/FSController.js';
import { HomepageController } from './homepage/HomepageController.js';
import { HostingController } from './hosting/HostingController.js';
import { KvShareController } from './kvShare/KvShareController.js';
import { LegacyFSController } from './fs/LegacyFSController.js';
import { NotificationController } from './notification/NotificationController.js';
import { OIDCController } from './oidc/OIDCController.js';
@@ -59,7 +58,6 @@ export const puterControllers = {
broadcast: BroadcastController,
notification: NotificationController,
events: EventsController,
kvShare: KvShareController,
share: ShareController,
webdav: WebDAVController,
oidc: OIDCController,
File diff suppressed because it is too large. Load diff
@@ -1,272 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Request, Response } from 'express';
import type { Actor } from '../../core/actor.js';
import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
import { HttpError } from '../../core/http/HttpError.js';
import { bodyRecord } from '../../core/http/requestBody.js';
import {
assertShareableAppUid,
parseKvShareRecipient,
} from '../../services/kvShare/kvShares.js';
import type {
KvShareHandleListOptions,
KvShareOwnerListOptions,
KvShareOwnerListState,
} from '../../stores/events/KvShareHandleStore.js';
import {
KV_HANDLE_LIST_LIMIT_CAP,
KV_SHARE_OWNER_LIST_STATES,
} from '../../stores/events/KvShareHandleStore.js';
import { KV_GLOBAL_APP_KEY } from '../../stores/systemKv/SystemKVStore.js';
import { normalizeLimit } from '../../util/pagination.js';
import type { KvShareView } from '../../services/kvShare/KvShareService.js';
import { EVENTS_LIST_LIMIT, KV_SHARE_BATCH_MAX } from '../events/limits.js';
import { PuterController } from '../types.js';
/** The app-less namespace is no app, so the wire says `null` for it. */
const publicAppUid = (appUid: string): string | null =>
appUid === KV_GLOBAL_APP_KEY ? null : appUid;
/**
* The namespace a listing's `appUid` names, in the form responses use: an app
* uid, or `null` for the app-less namespace.
*/
const listAppUid = (value: unknown): string | undefined => {
if (value === undefined || value === '') return undefined;
if (typeof value !== 'string')
throw new HttpError(400, '`appUid` must be a string', {
legacyCode: 'bad_request',
});
return value === 'null' ? KV_GLOBAL_APP_KEY : assertShareableAppUid(value);
};
const badRequest = (message: string): HttpError =>
new HttpError(400, message, { legacyCode: 'bad_request' });
/** A share as `POST /kv/shares` reports it. */
const shareBody = (share: KvShareView) => ({
handle: share.handle,
prefix: share.prefix,
access: share.access,
appUid: publicAppUid(share.appUid),
holder: share.holderUsername,
label: share.label,
notify: share.notify,
createdAt: share.createdAt,
});
/** `recipients` when the body names it: 1 to `KV_SHARE_BATCH_MAX` entries. */
const batchRecipients = (
body: Record<string, unknown>,
): unknown[] | undefined => {
const { recipient, recipients } = body;
if (recipients === undefined) return undefined;
if (recipient !== undefined)
throw badRequest('Send either `recipient` or `recipients`, not both');
if (
!Array.isArray(recipients) ||
recipients.length === 0 ||
recipients.length > KV_SHARE_BATCH_MAX
)
throw badRequest(
`\`recipients\` must be an array of 1 to ${KV_SHARE_BATCH_MAX} recipients`,
);
return recipients;
};
const listState = (value: unknown): KvShareOwnerListState | undefined => {
if (value === undefined || value === '') return undefined;
if (!(KV_SHARE_OWNER_LIST_STATES as readonly unknown[]).includes(value))
throw new HttpError(400, '`state` must be `live`, `revoked` or `all`', {
legacyCode: 'bad_request',
});
return value as KvShareOwnerListState;
};
const pageOptions = (req: Request): KvShareHandleListOptions => {
const query = (req.query ?? {}) as Record<string, unknown>;
return {
limit: normalizeLimit(query.limit, { cap: KV_HANDLE_LIST_LIMIT_CAP }),
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
includeTotal: query.includeTotal === 'true',
appUid: listAppUid(query.appUid),
};
};
const ownerPageOptions = (req: Request): KvShareOwnerListOptions => ({
...pageOptions(req),
state: listState((req.query ?? {}).state),
});
/**
* Sharing a region of one account's key-value data with another account.
*
* Gated like the events surface: `subdomain: 'api'` plus `allowAccessToken`,
* with app-ness decided from `effectiveApp` in the service, which is where the
* owner, holder, delegation and namespace rules live. Only sharing asks for a
* confirmed email; listing, revoking and unsharing only narrow or report.
*/
@Controller('/kv')
export class KvShareController extends PuterController {
/**
* POST /kv/shares — share a region with another account, `read` unless the
* body asks otherwise. Sharing the same region with the same account again
* changes its level, label or `notify` flag in place. `recipients` shares
* it with several at once and reports each one on its own item.
*/
@Post('/shares', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
requireVerified: true,
})
async share(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const body = bodyRecord(req);
const fields = {
prefix: body.prefix,
access: body.access,
appUid: body.appUid,
label: body.label,
notify: body.notify,
};
const recipients = batchRecipients(body);
if (recipients) {
const items = await this.services.kvShare.mintKvHandles(
actor,
{ ...fields, recipients },
{ defaultAccess: 'read' },
);
res.json({
items: items.map((item) =>
item.share
? {
recipient: item.recipient,
...shareBody(item.share),
}
: {
recipient: item.recipient,
error: {
message: item.error?.message ?? 'Not shared',
code:
item.error?.legacyCode ??
item.error?.code ??
'internal_error',
},
},
),
});
return;
}
const share = await this.services.kvShare.mintKvHandle(
actor,
{ ...fields, recipient: parseKvShareRecipient(body.recipient) },
{ defaultAccess: 'read' },
);
res.json(shareBody(share));
}
/**
* GET /kv/shares — what this account has shared out, retired included
* unless `state` narrows it.
*/
@Get('/shares', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
rateLimit: EVENTS_LIST_LIMIT,
})
async listSharedByMe(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const page = await this.services.kvShare.listKvHandles(
actor,
ownerPageOptions(req),
);
res.json({
items: page.items.map((row) => ({
handle: row.handle,
prefix: row.prefix,
access: row.access,
appUid: publicAppUid(row.appUid),
holder: row.holderUsername,
label: row.label,
notify: row.notify,
createdAt: row.createdAt,
revokedAt: row.revokedAt,
revokedBy: row.revokedBy,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total !== undefined ? { total: page.total } : {}),
});
}
/** GET /kv/shares/with-me — live shares this account holds. */
@Get('/shares/with-me', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
rateLimit: EVENTS_LIST_LIMIT,
})
async listSharedWithMe(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const page = await this.services.kvShare.listKvSharesWithMe(
actor,
pageOptions(req),
);
res.json({
items: page.items.map((row) => ({
handle: row.handle,
access: row.access,
appUid: publicAppUid(row.appUid),
label: row.label,
notify: row.notify,
createdAt: row.createdAt,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total !== undefined ? { total: page.total } : {}),
});
}
/**
* DELETE /kv/shares/:handle — the owner revokes, the holder gives it up,
* and anyone else is told it is not there.
*/
@Delete('/shares/:handle', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
})
async unshare(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
res.json(
await this.services.kvShare.removeKvShare(actor, req.params.handle),
);
}
#requireActor(req: Request): Actor {
const actor = req.actor;
if (!actor?.user)
throw new HttpError(401, 'Unauthorized', {
legacyCode: 'unauthorized',
});
return actor;
}
}
@@ -21,9 +21,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
/**
* Route-level coverage for the notification endpoints the desktop calls over
* HTTP, so this suite is what catches a route going missing or a gate turning
* a legitimate dismiss or removal away.
* Route-level coverage for the notification endpoint. Dismissing is the only
* mark the desktop performs over HTTP, so this suite is what catches the route
* going missing or a gate turning a legitimate dismiss away.
*/
describe('notification endpoints over HTTP', () => {
let env: PuterTestEnv;
@@ -85,34 +85,4 @@ describe('notification endpoints over HTTP', () => {
});
expect(after?.acknowledged).toBeFalsy();
});
it('removes a notification', async () => {
const owner = env.users.user;
const { userId, uid } = await seedNotification(owner.username);
const res = await post('/notif/delete', owner.token, { uid });
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({});
const after = await env.server.stores.notification.getByUid(uid, {
userId,
});
expect(after).toBeNull();
});
it('refuses an anonymous removal', async () => {
const { userId, uid } = await seedNotification(env.users.other.username);
const res = await fetch(new URL('/notif/delete', env.apiOrigin), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ uid }),
});
expect(res.status).toBeGreaterThanOrEqual(400);
const after = await env.server.stores.notification.getByUid(uid, {
userId,
});
expect(after?.uid).toBe(uid);
});
});
Loaded 100 of 1320 files, more files were not shown because too many files have changed in this diff. Show more