Compare commits

..
397 Commits
Author SHA1 Message Date
Reynaldi Chernando a9ec15d121 Update NPM readme (#3510) 2026-08-05 17:07:02 -07:00
Daniel Salazar eb53c842dd fix: oidc issues with cache (#3512)
closes #3497
closes #3502
2026-08-05 17:05:31 -07:00
Daniel Salazar 1be5ce45f7 feat: support dekstop app linking again (#3511) 2026-08-05 14:16:02 -07:00
Neal Shah 294055e055 PUT-1436, PUT-1435, PUT-1371 (#3507) 2026-08-05 17:11:50 -04:00
jelveh 036008d8a7 fix: keep uninstalled recommended apps from resurrecting in the Apps tab
Uninstall only revokes permissions, but the recommended launch list is a
global hardcoded set that knows nothing about per-user revokes — so an
uninstalled recommended app's tile came back on every reload. Persist
uninstalled app names in kv (dashboard_removed_apps) and filter only the
recommended merge against them; installedApps is never filtered, so a
genuinely (re)installed app always shows.
2026-08-05 11:01:14 -07:00
Daniel Salazar 6df292f495 fix: PUT-1429 (#3508) 2026-08-05 01:17:34 -07:00
Daniel Salazar 40c8d09f05 fix: PUT-1401 (#3504) 2026-08-04 16:37:33 -07:00
meridah7 4a211b9092 docs: document the provider option and refresh the vendor list in chat() (#3505) 2026-08-04 16:22:03 -07:00
meridah7 24fc0b0e5f fix(ai-chat): infron default model is dropped by the aggregator alias rule (#3506)
* fix(ai-chat): use a resolvable default model for the infron provider

* test(ai-chat): assert the infron default model is in the served catalog
2026-08-04 16:21:55 -07:00
Nariman Jelveh f58772ec33 feat: swap browser favicon to the focused app's icon in dashboard 2026-08-04 14:16:32 -07:00
Neal Shah eb830799c0 add limit bypass (#3500)
* add limit bypass

* update test
2026-08-03 19:43:59 -04:00
jelveh 8df72ef343 fix: dashboard app drawer icon popping in late
The drawer resolved its own icon URL (a differently-sized variant under a
different URL than the dashboard tiles use), so its <img> fetched cold and
the icon popped in mid-intro. Reuse the bitmap an Apps-tab tile or Home-tab
recent already decoded — instant from the image cache; when nothing is
rendered yet (deep links), fade the icon in on load instead of popping, and
fall back to the generic app icon on a failed fetch.
2026-08-03 11:50:43 -07:00
jelveh 63b2fc34d9 feat: highlight pasted items in dashboard files tab
Pasted (copied or cut) items now land selected, the same treatment
uploads get. copy_clipboard_items resolves with the created items'
paths instead of firing and forgetting, moveClipboardItems returns
each move response's authoritative final path (with Keep Both the
landed name differs from the source's), and both dashboard paste
entry points refresh with strong consistency and hand the new paths
to selectUploadedRows. Pastes into a folder that isn't rendered match
no rows and the highlight is a no-op.
2026-08-03 06:52:40 -07:00
Nariman Jelveh 33d7324a24 feat: 'Keep Both' option in name-conflict dialogs (#3496)
Pasting or moving onto an existing name only offered Replace or
Cancel. Add a macOS-style middle ground: Keep Both retries the
operation with dedupe_name, landing the item as "name (1).ext" and
leaving the existing item untouched.

- puter-js move.js now forwards dedupeName to the wire (copy already
  did; move dropped it).
- All four conflict dialogs offer the new button: copy_clipboard_items,
  copy_items and move_items in helpers.js, and the dashboard's
  moveClipboardItems. Multi-item selections show
  Replace / Replace all / Keep Both / Skip.
- New keep_both translation key (other locales fall back to English).
2026-08-03 06:39:18 -07:00
Nariman Jelveh 62be61642d fix: phantom name conflict when pasting onto a just-renamed path (#3495)
Renaming (or moving) an entry left the OLD path's cache key serving
the pre-update entry for the full 60s TTL: updateEntry invalidated
only keys derived from the updated row, whose path is already the new
one. Pasting an item under the freed name then hit the stale cache in
the collision check and reported a conflict for a file that no longer
exists — and accepting the Replace it offered deleted the renamed file,
since the stale entry carries its uuid.

Read the pre-update entry when the patch changes the path and
invalidate its keys alongside the new ones. The move flow was shielded
by the outer.gui.item.moved cache-invalidation handler at the
controller layer; rename had no such band-aid, and fixing the store
covers every caller regardless of which events fire.
2026-08-02 23:53:59 -07:00
Nariman Jelveh 8cebbcf3be fix: thumbnails breaking after copy — duplicate the S3 object per copy (#3494)
A copied fsentry kept its source's thumbnail pointer verbatim, so both
rows shared one S3 thumbnail object. fs.remove.node deletes the
pointed-to object, so the first removal among the sharers — e.g. the
remove performed by a replace-on-copy — broke every other sharer's
thumbnail. FSService already emits fs.copy.node for exactly this
reason (its doc comment describes the duplication), but the thumbnails
extension never subscribed to it.

Add the missing handler: S3-copy the thumbnail to a freshly minted key
and repoint the copied row, under the same only-keys-we-minted trust
rule as the read/remove paths. If the shared object is already gone
(pre-fix damage), null the pointer instead of leaving the row
advertising a thumbnail it doesn't have.
2026-08-02 23:22:24 -07:00
Nariman Jelveh 5c2a423658 fix: progress window covering paste conflict dialog; ghost row after Replace (#3493)
* fix: progress window covering paste conflict dialog; ghost row after Replace

Pasting a copied file onto a name collision buried the Replace/Cancel
dialog under a stuck 'Preparing...' progress window, and answering
Replace left a stale duplicate row in every client.

- helpers.js: copy_clipboard_items armed its delayed progress window
  with a 0ms timer (its siblings use 2s), so the window opened
  instantly over the dialog. Use 2s, and in all three of
  copy_clipboard_items / copy_items / move_items pause the timer while
  a conflict dialog (or the own-location / trash-deny alerts) is
  waiting for input, re-arming it after. A window that opens
  mid-operation now shows the current file instead of a stuck
  'Preparing...', and the trash-deny bail no longer leaks a timer that
  opened an orphan window after the operation ended.
- LegacyFSController: /copy and /move dropped the legacy 'overwritten'
  response field and never emitted item.removed for the entry an
  overwrite deleted, so clients kept a ghost row until re-listing.
  Resolve the entry before the operation, return it, and emit
  item.removed on success.
- helpers.js: copy_items read resp[0].overwritten but removed
  resp.overwritten (always undefined), and the data-uid cleanup
  selectors were unquoted — invalid CSS when a UUID starts with a
  digit. Fixed all three sites.

* test: pin the v1 overwrite/collision wire contract for move and copy

The collision tests asserted only statusCode 409, which is how the
item_with_same_name_exists code regressed to 'conflict' unnoticed and
broke every replace/skip prompt in the GUI. Assert the legacy code and
entry_name explicitly, and add controller tests for the overwrite path:
the replaced entry must ride along as 'overwritten' in the /copy and
/move responses and be announced via outer.gui.item.removed so clients
drop its row.
2026-08-02 22:17:10 -07:00
Nariman Jelveh 1a9f025db6 fix: surface name collisions when pasting instead of failing silently (#3492)
Cut+paste onto a folder containing an item with the same name failed
with no feedback: the dashboard's moveClipboardItems swallowed the
error into console.error and cleared the clipboard, and the rewritten
backend broke the v1 wire contract the GUI's conflict prompts key on.

- FSService: move() and copy() collisions again return
  item_with_same_name_exists + entry_name (the contract the write path
  already preserves) instead of a generic 'conflict' code. This also
  restores the desktop's existing Replace/Skip dialogs, which were
  silently broken against the new backend.
- Dashboard: moveClipboardItems now mirrors the desktop's move_items
  conflict flow (Replace / Replace all / Skip / Cancel, retry with
  overwrite) and surfaces other move errors in an alert.
- keyboard.js: the desktop's global Ctrl+V handler threw an uncaught
  TypeError on every paste in dashboard mode (its file container has
  no data-path); guard it — which also prevents a double-paste if
  dashboard containers ever gain one.
2026-08-02 21:33:01 -07:00
jelveh a4667073f5 Update RecommendedAppsService.ts 2026-08-02 20:55:33 -07:00
jelveh 23d251e482 fix: dashboard files list blanking during same-directory refresh
Re-rendering the directory already on screen (after an upload, sort
change, undo, etc.) used to clear the list and show a spinner before
the readdir round-trip, blanking the pane for the whole fetch. Keep
the current rows visible until the fresh listing arrives, then swap
the DOM in one pass and restore the scroll position. Navigation to a
different directory still clears immediately.
2026-08-02 20:30:20 -07:00
jelveh 0c9dfb03e2 Show action bar only in mobile select mode 2026-08-02 19:49:14 -07:00
Nariman Jelveh 6dc928e0c3 fix: file picker dialogs going behind parent window when dragged by titlebar 2026-08-02 17:38:25 -07:00
Daniel Salazar 84582ee33d fix: alarm channels (#3491) 2026-08-01 15:55:06 -07:00
Daniel Salazar 116d6e6663 tests: big test push for better coverage (#3490) 2026-08-01 14:29:59 -07:00
Daniel Salazar 7d0d44aef9 feat: slack alarms (#3489) 2026-08-01 13:12:41 -07:00
Daniel Salazar 3ac6c8532e fix: open ai cost (#3488) 2026-08-01 12:34:36 -07:00
Nariman Jelveh f6a50c4fb4 Update recommended apps list order
Reorders and updates the recommended apps list: removes 'butler', 'code', and 'traffic-tap-puzzle'; adds 'contacts', 'diagram', and 'basketball-tap' (relocated from end); adjusts overall ordering of several apps.
2026-08-01 12:26:44 -07:00
Daniel Salazar c8113d7514 fix: auth message popups (#3487)
* fix: auth message popups

* remove v1 auth
2026-08-01 10:45:16 -07:00
jelveh 6159bc9ae1 Polish dashboard settings button styling
Update `.dashboard-settings-card .button` to use dashboard theme tokens for text, background, and borders, and add clearer hover/focus/active states with subtle elevation and transitions. Also override disabled styles in this context so dark mode no longer shows hard-coded light greys from the base `.button:disabled` rule.
2026-08-01 00:27:27 -07:00
Daniel Salazar 3a11d0d1f1 feat: sandboxed app workers (#3486) 2026-08-01 00:02:06 -07:00
jelveh 535cf5753e Merge branch 'main' of https://github.com/HeyPuter/puter 2026-07-31 14:14:11 -07:00
jelveh cee4a6ccce Improve app reorder mode on touch devices 2026-07-31 14:14:04 -07:00
Daniel Salazar 1f1f95c2f8 fix: auth me for local dev (#3484) 2026-07-31 14:04:59 -07:00
jelveh f600e857b0 GUI: touch reorder mode for the dashboard Apps tab
Long-press-to-drag can't be made reliable on touch: touch-action is
consulted at gesture start, so the pager's pan-x claims the finger
before a drag can begin (worst on iOS). Replace it with an explicit
edit mode - a cog button (touch-primary devices only) enters it, tiles
jiggle and drag on first movement, iOS-style x badges uninstall, and
Done exits. Drops still persist immediately, same as desktop.

Also raise the drag ghost above the window z-index bands; it was
rendering invisibly behind the fullpage dashboard window.
2026-07-31 13:46:17 -07:00
Neal ShahandDaniel Salazar 08d1708378 change cors auth path (#3464)
* change cors auth path

* undo oidc ref changes

* scary OIDC state changes

* fix: bad cors signin

* puterjs changes

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-07-31 01:55:01 -04:00
Nariman Jelveh f1bc065730 GUI: fix dev-server port-retry crash under Express 5 (#3482)
Express 5's app.listen wraps the listen callback in once() and also
invokes it on 'error', with the error as the first argument — at which
point server.address() is null, so the startup log threw a TypeError
and killed the process before the EADDRINUSE handler could try the
next port. Bail out of the callback when it receives an error and let
the 'error' listener own the retry.

Verified: with 4000 occupied the server now logs the retry and comes
up on 4001; with the port free it binds 4000 as before.
2026-07-30 18:11:12 -07:00
Nariman Jelveh b55782f066 GUI: add npm start --server=<domain> to run the GUI against a remote backend (#3481)
- npm start --server=puter.com (or -- --server=...) skips the local backend
  and serves the bundled GUI locally, pointed at the remote server's API.
  Bare domains resolve to https://api.<domain>; full origins are used
  verbatim. gui_origin points at the remote origin so /whoarewe, login,
  anti-csrf, socket.io, and builtin apps hit the real backend (CORS-open).
- --extensions=<dir>[;<dir>...] bundles out-of-tree GUI extension
  directories (sugar for PUTER_GUI_EXTENSION_PATHS). Their imports resolve
  as if the files lived in src/gui/src/extensions, with the extension's own
  files taking precedence, and bare imports fall back to the repo-root
  node_modules.
- Fix the bit-rotted dev-server: Express 5 wildcard routes, pass gui()
  params (previously called with none), inject the service_script shim,
  load bundle.min.js + bundle.min.css in prod mode, serve /sdk, and
  properly await the webpack build (it previously resolved immediately).
2026-07-30 16:29:54 -07:00
jelvehandClaude Fable 5 2c9ae3489e Suggested apps: rank registered apps above the editor fallback
For extensions with no intentional built-in mapping (doc, docx, and
every other unmapped type), suggestionsForExtension fell back to
['editor'], and #resolveForExtension always placed built-ins ahead of
apps from app_filetype_association. Since suggested[0] drives the GUI's
double-click open path and /open_item, a .docx defaulted to opening as
plain text in editor even when a word processor explicitly registered
the extension.

Tag the unknown-extension result as a fallback and order third-party
filetype-association apps ahead of it. Intentional mappings (code, txt,
md, images, pdf, media) keep built-ins in the head slot as before, and
the editor guess still appears as a last-resort option.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-30 13:51:01 -07:00
Daniel Salazar b28b41c90b otel: better telemetry numbers (#3480) 2026-07-30 13:23:16 -07:00
Nariman Jelveh f34c4dc335 Apps: normalize filetype associations to bare lowercase extensions (#3479)
* Apps: normalize filetype associations to bare lowercase extensions

Suggested-apps lookups match app_filetype_association rows against the
bare lowercase extension ('docx'), but writes stored whatever the
developer typed. Rows like '.docx' never matched, so those apps
silently dropped out of Open With suggestions.

AppStore now canonicalizes on write (trim, lowercase, strip leading
dots, dedupe, drop empties) and tolerates the dotted legacy form on
read: getAppsByFiletype normalizes the requested extension, matches
both 'docx' and '.docx', and dedupes apps associated under both forms.
Cache invalidation keys are normalized the same way. Existing dotted
rows work without a data migration.

* Update apps tests for extension canonicalization

Adjust apps API tests to match current normalization behavior for `filetypeAssociations`: extension values are stored as lowercase bare extensions (e.g. `.txt` -> `txt`), while MIME types remain unchanged. Added inline comments in both test suites to document this expected remap.
2026-07-30 13:13:14 -07:00
Daniel Salazar 5a157197b6 fix: PUT-1398 (#3478) 2026-07-30 01:42:40 -07:00
Daniel Salazar 8a711e0254 driver controller change (#3477)
* fix: launch app

* driver controller change
2026-07-30 00:41:29 -07:00
jelveh a22321daa2 Dashboard: closing a launched app goes home, not into its launcher
An app launched by another app minimizes its launcher (iOS-style
takeover). Closing the child then popped onto the launcher's history
entry, and the popstate handler restored it — so quitting an app threw
the user into a different full-screen app they had not asked for, zoom
animation and all. Closing an app should go home.

Mark the launcher when it is minimized FOR a child
(data-minimized_for_child). If it still carries that mark when the child
closes, the close's single history hop lands on the dashboard instead:
the entry is rewritten to the dashboard's route and the launcher is left
minimized, still running behind its tile's running dot. No extra history
traversal — the joint session history an app's iframe shares is exactly
what pop_dashboard_app_url's watchdog exists to survive, so the hop
count is unchanged.

Back is untouched and still returns to the launcher — that is the
navigation gesture, whereas close dismisses. Any restore (Back, tile
click, Forward) clears the mark, so quitting a child after the user has
brought the launcher back leaves the launcher alone.
2026-07-29 22:04:10 -07:00
jelveh fbf1bd18b1 Dashboard: an app launched by another app takes over the tab
In dashboard mode apps are full-tab experiences, but an app launched by
another app (puter.ui.launchApp) opened as a floating titlebar window
over its full-tab parent: the parent stayed on screen around its edges,
couldn't be raised (focusWindow never raises stay_on_top windows), and a
child with no Apps-tab tile had no switcher to come back to once
minimized.

Give child launches the same treatment as tile launches — maximized, so
they get the headless chrome and control drawer — and minimize the
parent behind them, iOS-style. State only: the parent's /app/<name>
history entry already sits beneath the child's, so Back from the child
(and the child's close, which consumes its own entry) lands on the
parent's entry and the existing popstate handler restores it. The parent
keeps running while hidden, so parent/child IPC is unaffected.

Explorer keeps its windowed form, background apps don't minimize their
parent, and an explicit `maximized` option still wins. Desktop mode is
untouched: both changes are gated on is_dashboard_mode, so an app
launching an app there still gets a floating child over a visible
parent.
2026-07-29 21:36:47 -07:00
jelveh dc371fc4eb Windows: keep dialogs and their apps in the stay-on-top z band
focusWindow re-raised a focused window's parent (and children) with the
bare z counter. For a file dialog owned by a fullpage/dashboard app that
DEMOTED the app out of the 99999999+ stay-on-top band it was created in,
burying app and dialog under every other open app window — opening a
file picker from an app stacked over another (e.g. an app launched from
Dev Center) made both vanish beneath the window below. The dialog itself
had the same flaw: raised into the plain counter band, under every app.

Raise each window within its own stacking band instead: stay-on-top
windows (and windows hanging off one through parent_uuid, walked up the
chain) get 99999999 + counter; everything else keeps the bare counter —
so desktop stacking arithmetic is unchanged. Same demotion family as the
showWindow restore fix in #3427.
2026-07-29 21:11:21 -07:00
jelveh 97e4e78df4 Dashboard files: drop the root crumb from the breadcrumb
Every path rendered as "> Puter > user > ...", but the root crumb is
noise on anything beneath it. Render it only at the root itself, where
it's all there is to show (the Up button can still reach /, and an
empty breadcrumb bar there would strand the user). Each crumb keeps
its leading caret, including the first.
2026-07-29 20:12:22 -07:00
jelveh 007649296b Keyboard: stop swallowing Enter when nothing claims it
The global Enter handler preventDefault'ed unconditionally, then
returned without acting unless a launch-menu item, context-menu item,
or selected item existed. That suppressed the browser's native Enter
activation on whatever was focused — a focused link or button (e.g. a
dashboard sidebar item, a dialog button) did nothing on Enter.

Move preventDefault/stopPropagation into the branches that actually
handle the key; otherwise fall through so native activation runs. The
handled paths are unchanged: Enter still opens the selected item,
launch-menu entry, and context-menu entry.
2026-07-29 19:40:16 -07:00
jelveh 9392aed402 Dashboard: restyle keyboard focus ring on sidebar controls
Sidebar items (and the user/collapse/close buttons) had no
:focus-visible style, so keyboard focus painted the UA's default blue
ring — hard-edged and bleeding outside the item. Use the dashboard's
own selection ring instead, tucked inside the control so it hugs the
rounded corners. :focus-visible only, so mouse clicks never paint it.
2026-07-29 19:40:06 -07:00
jelveh 32e417fe79 Dashboard files: clicking an open file returns to its window
In dashboard mode there is no taskbar, so a minimized app window's only
switcher was its Apps-tab tile — invisible from the Files tab. Opening a
file, minimizing, and clicking the file again launched a second instance
of the app, stranding the first (and any unsaved edits) somewhere
unreachable.

Make the file row itself the switcher:

- launch_app stamps the opened file's uid on the window (data-file_uid;
  the signature's uid wins so shortcuts resolve to their target)
- open_item (dashboard mode) restores/focuses an existing window that
  has the file open instead of launching a duplicate, mirroring the
  Apps-tab tile's single-instance behavior — keyed by file, not app, so
  opening a different file still gets its own instance
- re-clicks while a launch's fetches are still in flight are swallowed
  (same idea as TabApps._launchingApps, keyed by file uid, TTL'd so a
  failed launch can't swallow clicks forever)
- dashboard file opens now default to maximized, so they get the same
  headless full-tab chrome + control drawer as tile launches instead of
  a floating titlebar window
- rows show a dot while their file is open in a (possibly minimized)
  window — under the name in grid view, inline after it in list view —
  driven by the existing dashboard-app-windows-changed event

Desktop mode is untouched: the reuse branch and maximized default are
gated on is_dashboard_mode, and opening the same file twice there still
creates two windows as before.
2026-07-29 19:03:33 -07:00
Daniel Salazar 0e1f617401 fix: errors for bad keys (#3475) 2026-07-29 15:58:53 -07:00
Felix-Ayush bd6efaaa4e fix(puter-js): ignore peer offers for unknown connections (#3473)
createAnswer ran outside the connection null-check, so stale or unknown
offer ids threw on the signalling socket. Guard the whole offer path and
return the #message promise from onmessage so callers can observe errors.
2026-07-29 15:58:39 -07:00
Nariman Jelveh 2b6dc528ce Merge branch 'main' of https://github.com/HeyPuter/puter 2026-07-29 15:48:21 -07:00
Nariman Jelveh 9368ad57b6 Preserve UI state across tab visibility refresh 2026-07-29 15:47:59 -07:00
Felix-Ayush e97d338f3a fix(ai-chat): preserve max_tokens 0 for Alibaba, DeepSeek, and Claude (#3472)
These providers used `max_tokens || default`, so an explicit zero was
replaced by the fallback. Use nullish coalescing so 0 is forwarded.
2026-07-29 18:38:31 -04:00
Nariman Jelveh a87a517b82 Anchor file row menu to button edge
File row overflow menus in the dashboard now open anchored to the ⋯ button (below it with right-edge alignment) instead of pointer position, improving placement consistency. UIContextMenu now supports `position.right` for right-edge pinning, and the ⋯ trigger keeps an active visual state while its menu is open so hover styling doesn’t drop when the cursor moves onto the menu.
2026-07-29 15:25:51 -07:00
Nariman Jelveh dbb52171c8 Refine dashboard file row hover styling 2026-07-29 15:05:21 -07:00
Daniel Salazar 6c020318f9 perf: traces and perf improvements (#3471) 2026-07-29 14:35:15 -07:00
Nariman Jelveh e16994db3b Fix I-beam cursor on dashboard file rows in Safari
Safari ignores user-select: none for cursor styling, showing a text I-beam over file rows. Setting cursor: default explicitly fixes this and is inherited by child elements.
2026-07-29 14:18:50 -07:00
Daniel Salazar 8491a0b55d fix: limit api pointing to apps only (#3470) 2026-07-29 14:03:27 -07:00
Felix-Ayush 710e4da4c0 fix(puter-js): size img2txt data URIs by decoded bytes (#3457)
Match speech2txt/speech2speech and reject on decoded payload size, not
data-URI string length. Base64 expands ~4/3, so string-length checks
falsely rejected valid images under the 10MB limit.
2026-07-29 12:21:21 -07:00
Felix-Ayush fd1f4e2ac2 fix(puter-js): forward temperature 0 and max_tokens 0 in ai.chat (#3455)
Truthy checks dropped legitimate zero values, so deterministic sampling
never reached the driver. Match the stream option and use !== undefined.
2026-07-29 12:21:15 -07:00
Kobi Hikri 70bf03a6e7 ci: attach provenance and SBOM attestations to the published image (#3463) 2026-07-29 12:21:02 -07:00
Felix-Ayush 6f90d7a199 fix(ai-chat): forward temperature 0 and max_tokens 0 to providers (#3456)
Completions-family providers used truthy spreads, so temperature: 0 and
max_tokens: 0 were stripped before the SDK call. Align with Responses
providers that already use !== undefined.
2026-07-29 11:49:36 -07:00
Daniel Salazar f0b1947c54 feat: make uninstall also revoke + dashboard css changes (#3469)
* feat: make uninstall also revoke

* fix: wording for free trial
2026-07-29 10:40:43 -07:00
Daniel Salazar 92193ff11f chore: kill collector (#3466) 2026-07-28 19:02:49 -07:00
Daniel Salazar f9396b7283 fix: thumbnail res (#3465) 2026-07-28 17:31:01 -07:00
Sauhbagya PrasadandDaniel Salazar 7cf3e03433 Replaced Nginx with Caddy (#3378)
* Replace nginx with Caddy

* updated caddy location

* seprate logic for local and with domain caddy setup

* updated install

* updated install scripts

new ps1 script with caddy, and removed instances of nginx

* fix Caddy Host routing, restore TLS/SELinux/healthcheck, update docs

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-07-28 14:52:13 -07:00
Neal Shah 0a61b4e78b add fixes 1 (#3458)
* add fixes 1

* change socketio test

* clarify that success/error callbacks are legacy

* fix wisp issues
2026-07-28 17:34:12 -04:00
Reynaldi Chernando 1db4324c02 remove deprecated ai models (#3444)
* remove deprecated ai models

* test

* fix tests
2026-07-28 12:41:04 -07:00
Daniel Salazar 69529ee0db perf: try to improve app opens speed (#3462)
* perf: try to improve app opens speed

* make /rao non blocking to allow faster conn swap
2026-07-28 11:12:44 -07:00
Daniel Salazar 7a7ff47442 chore: bump version (#3461) 2026-07-27 23:59:51 -07:00
Daniel Salazar 148771a82b fix: performance degradations in app fetching + puter-js module parent class (#3460)
* fix: centralize module methods in parent class

* fix: performance degradations in app fetching
2026-07-27 23:41:51 -07:00
Daniel Salazar 5bad6a972f fix: puter-js cleanup around docs and ai resolution (#3459)
* fix: puter-js cleanup around docs and ai resolution

* more jsdoc stuff

* fix: docs

* fix: doc types + misc hardening

* fix: harden auth
2026-07-27 22:31:05 -07:00
Luke Oliff 8bb7d59527 feat: add Speechify TTS driver (puter-tts) (#3453)
* feat: add Speechify TTS driver

Adds a SpeechifyTTSProvider under the ai-tts driver (mirrors the xAI/
ElevenLabs REST-provider shape), registered in TTSDriver alongside the
existing providers. Wires puter-js txt2speech with provider: "speechify"
support, default voice/model, and the speechify-tts driver alias.

Every outbound request sets Speechify-Caller: puter; base URL is
https://api.speechify.ai only; default model is simba-3.2.

* fix: replace placeholder voice IDs with real simba-3.2/simba-english voices

DEFAULT_VOICE and the starter voice catalog used invalid IDs (henry,
cliff, kristy, george, aria) that the real API rejects with 400. Swapped
in confirmed-real voices from a live GET /v1/voices call: geffen_32,
dominic_32, harper_32, hugh_32, imogen_32, and alec for the
simba-english model-override test.

* fix: purge remaining placeholder voice IDs from client, docs, and types

The previous fix only covered the backend provider — the client-side
default in tts.js, its test, the txt2speech docs page, and the ai.d.ts
type comment all still referenced the invalid henry/cliff/kristy/george/
aria set. All replaced with the live-verified voices (geffen_32 default).
Also corrects the docs link to docs.speechify.ai.
2026-07-27 17:04:11 -07:00
Neal Shah 8732494442 dead code cleanup (#3441) 2026-07-27 17:51:15 -04:00
Daniel Salazar cade75ac9b fix: PUT-1385 PUT-1382 PUT-1381 PUT-1380 PUT-1379 (#3454) 2026-07-27 11:42:40 -07:00
meridah7 9abc9d1a19 feat: add Infron AI chat provider (#3433)
Adds Infron (https://infron.ai) as an aggregator provider for the
puter-chat-completion driver, following the OpenRouter provider as
reference per doc/contributing-apis.md.

- OpenAI-compatible gateway at llm.onerouter.pro/v1
- Dynamic model catalog (kv-cached 15 min, authenticated fetch),
  filtered to chat-capable LLM entries
- Bills the gateway-reported authoritative cost when present
  (usage: {include: true}); falls back to catalog per-token pricing
- Registered as an aggregator so its aliases never shadow
  first-party providers
- Offline unit tests (mocked SDK/axios against a real test server)
  plus an env-gated integration test
2026-07-27 08:38:04 -07:00
Nariman Jelveh f3fd8a30da Rework permission requests: new dialog, working popup flow for websites (#3447)
* Rework permission requests: new dialog, working popup flow for websites

- Replace the UIWindow-based permission prompt with a standalone top-layer
  <dialog> (responsive, light/dark, app/site identity, input protection)
- Implement puter.ui.requestPermission for env=web: opens the GUI's
  /action/request-permission popup with pinned origin/source/msg_id,
  popup-closed detection, and a check-permissions polling fallback for
  crossOriginIsolated openers
- Move the GUI's request-permission action into postAuthActions so
  signed-out users sign in first; identify the app by opener origin,
  correlate responses with original_msg_id, close the popup after answering
- Always respond from the IPC handler so the SDK promise can't hang;
  normalize the result to a strict boolean (a failed grant no longer
  resolves truthy)
- Accept origin in /auth/grant-user-app and /auth/revoke-user-app,
  mirroring grant-dev-app (fixes puter.perms.grantOrigin/revokeOrigin)
- Add Playwright e2e coverage for both the desktop and popup flows;
  update e2e harness for the auth_token_v2 localStorage key
- Update types and docs (perms request methods now work on websites)

* Harden permission request flows

- Grace period before treating popup close as denial: the GUI posts the
  decision then closes the popup, and postMessage delivery is not ordered
  relative to `closed` becoming true, so a grant could race to a false
- Unique popup window name per request so window.open name-reuse can't
  hijack a still-pending request's popup
- request-permission action always answers the requester and closes the
  popup, even when app resolution or the dialog throws
- Permission dialog: refuse unidentifiable requesters, allowlist icon URL
  schemes, and time out the grant request into the retryable error path
- Validate app_uid/origin/permission types and length in grant-user-app
  and revoke-user-app
- Tests: revoke-by-origin and input-validation backend tests; e2e for
  dialog dedup, unsupported permissions, and the consent-dialog path

* Sign in first in the requestPermission fixture's email flow on the web

In env=web the site has no auth token, so whoami threw 401 immediately
and the email button appeared to do nothing. Sign in via popup first,
matching the real third-party flow; env=app already has a token and is
unaffected.

* Point the requestPermission fixture at the real api subdomain

The SDK sends credentialed CORS requests; the GUI host doesn't answer
with Access-Control-Allow-Credentials, so whoami (and any authed call)
from the fixture origin failed CORS and looped through retries.

* Fix /auth/list-permissions schema mismatches

The endpoint's queries referenced columns that don't exist:
user_to_app_permissions stores a numeric app_id FK (not app_uid), and
user_to_user_permissions uses holder_user_id (not target_user_id) —
every call 500'd. Join apps to expose the app's uid and use the real
column names.

Replace the catch-either-branch test (which documented the breakage
instead of failing on it) with real assertions covering all three
sections of the response.

* Identify permission requesters by origin only

The request-permission action took `app_uid` straight from the query
string and used it as the grant target whenever the origin was absent or
unresolvable. Now that /auth/grant-user-app accepts `origin` and prefers
`app_uid` when both arrive, the displayed identity and the grant target
could diverge; with only `app_uid` in the URL the dialog rendered with an
empty name, so a link could produce a bare "Allow" prompt for an unnamed
requester. Resolve the uid from the origin alone, and let the server
resolve it from that same origin when the client lookup fails.

Also give the no-gesture consent popup a unique window name. UI.js does
this on the direct path because window.open() reuses a window with a
matching name, but the PuterDialog fallback opened under the default
'Puter' — the same name sign-in uses, so a consent click could navigate
an in-progress sign-in popup away.

And guard the IPC responder: an app that closes its own window while the
dialog is up leaves target_iframe.contentWindow null.

* Keep the permission popup from signing the site in

The popup loads the GUI with embedded_in_popup=true, so it ran the
sign-in token exchange and posted puter.token to the opener before the
user answered the prompt. A site that called requestPermission() walked
away holding a user-app token for the account even when the user pressed
"Don't Allow" — and because the SDK's global puter.token handler feeds
event.data.token into setAuthToken() without looking at `success`, a
failed exchange posted token: null and wiped a token the site already
had. Keep running the exchange (it bootstraps the app row the grant
needs and caches host_app_uid) but leave the token in the popup. A site
that wants credentials still has to call signIn().

Escape on the SDK's consent dialog left the caller pending forever.
PuterDialog wired its Cancel and close buttons but not the <dialog>'s
native cancel event, so the browser dismissed the dialog and nothing
reported it: no dialog, no popup, no answer. Route cancel to the same
handler. Programmatic close() fires only `close`, so launching the popup
— which closes this dialog — is unaffected, and the implicit-auth flow
stops hanging on Escape too.

Serialize the permission dialogs. showModal() makes the whole document
inert rather than just the requesting app's window (which is what the
UIWindow it replaced did), and the dedup map only coalesced identical
requests, so an app asking for permissions in a loop stacked one modal
per request and walled the user off from the desktop — including from
the app doing it. Prompts now queue and open one at a time, and each
caller still gets its own decision.

Identify apps by more than their title. `title` is free-form text the
author picks and is not unique, so it was the whole identity of a prompt
an app titled "Puter Settings" could raise; the registered `name` is
unique and format-restricted, so show it underneath. Give the name line
the unicode-bidi isolation the origin line already had, since escaping
leaves bidi overrides intact.

Stop the dialog from answering over its own in-flight grant: a dismissal
while the POST was outstanding resolved false for a permission the server
was committing. Ignore dismissals while granting, and time-box the
request with AbortController (AbortSignal.timeout isn't everywhere) so a
hung network can't leave a modal no one can close. Fail closed on the
remaining paths that could reject or prompt uselessly — showModal()
throwing under <iframe sandbox>, and a requester known only by app_name,
whose Allow the server would always reject. Pass the error string to
.text() unencoded so translations containing an apostrophe don't render
&#39;.

The e2e suite covers all of it; each new test fails without its fix.

* Restrict the permission popup flow to third-party websites

requestPermission's new web path ran in every environment with a window,
including env='gui' — so a permission_denied driver retry inside the
Puter GUI would open a popup to the Puter origin from the desktop itself
and try to grant the permission to a phantom app for Puter's own origin.
Resolve false everywhere except env='web', the previous behavior.

* Settle the permission dialog when a failed grant has no dialog left

The cancel handler is preventDefault'd, but close requests can't be
suppressed forever: Chrome's close watcher lets a repeated Esc skip
cancel and force-close the dialog while the grant POST is in flight.
The close handler defers to that grant on purpose — but if the grant
then failed, fail_grant re-enabled buttons on a closed dialog and the
promise never settled, leaving the requesting app waiting forever.
Settle as a denial when the dialog is no longer open.

Also add regression tests for this and for the popup-flow env guard
(routing the CDN SDK URL to the local build, since the prod-built GUI
loads its SDK from js.puter.com).

* Withhold the auth token on the popup's first-visit paths

Keeping the token inside the permission popup only covered the plain
token exchange. Two other popup paths mint a user-app token and posted
it to the opener unconditionally: first-visit temp-user creation, and
the manual signup shown when temp users are refused. Both sit on the
path a brand-new visitor takes — the audience the website popup flow
exists for — so a site that asked about one permission and was denied
still walked away holding a token, for a temp account or a real one.
The SDK's global puter.token handler feeds whatever arrives straight
into setAuthToken(), so posting it is the whole of it.

Move the rule into util/popupAuth.js and consult it at every site that
posts the token, so the next token path has one place to ask.

The first-visit path also left the prompt itself unreachable: it waits
on the spinner promise, which only resolves when the spinner was up for
under 2s. End that wait for any action that keeps the popup open;
sign-in still closes the window as before.

The e2e test fails without the fix — the site holds a token after the
user presses "Don't Allow".

* Poll for the decision when the popup's opener is severed

crossOriginIsolated was the test for "the popup can't message us back",
but being isolated also requires COEP. A site sending COOP: same-origin
on its own still has its opener relationship severed when it opens the
Puter popup, and took the watch-the-window path instead — where the
detached proxy reports closed === true on the first tick, so
requestPermission resolved false about a second after the popup opened,
while the user was still reading the dialog. Their "Allow" then had
nowhere to go. Treat an already-closed popup as severed and poll.

Pin the expected event.source before those early returns. popupWindow
was assigned after them, so for the whole consent-dialog wait — as long
as the user takes to click Continue — the handler accepted a decision
from any window on the GUI origin. A forged answer is only advisory
since the grant is written server-side, but the check may as well hold.

Settle instead of rejecting when the consent dialog can't be appended:
document.body is null in a <head> script, and the throw both rejected a
promise documented to resolve to a boolean and left the message listener
behind.

* Key the dialog dedup by the identity its gate accepts

The gate treats an empty app_uid as absent and falls through to the
origin; the dedup key used ?? and kept the empty string, so two requests
from different origins would collide on one key and share a single
decision. No caller can produce a blank uid today — server uids are
never empty and the IPC path's empty attribute is stopped by the gate —
but the two lines have to agree.

* Close the gaps the permission-request flow left open

Seven defects found reviewing the new permission flow end to end, each
reproduced against a running server before being fixed.

Security:

- `cross_origin_isolated=true` bypassed `deliversTokenToOpener` entirely.
  That branch is checked first, mints a user-app token, publishes it via
  `/login/set` and returns — so one query parameter on a
  request-permission URL skipped the prompt and handed the opener a token
  through the unauthenticated `/login/wait`. Gate it with the same rule.

- Grant/revoke by `origin` could land on an unrelated app. An origin with
  no app row synthesises `app-<uuidv5>`, and the permission services
  resolve their identifier as uid *or name* — and the uuid namespace is a
  source constant, so the string is computable offline and registrable as
  an app name. Resolve origins to a uid that names a real app row.

- A website's host was elided on the right, hiding the registrable domain
  that says who is asking. Elide it from the left, as the sibling rule
  already intended.

- A grant whose response was lost (client-side abort, dropped reply) left
  the row committed while the dialog reported a denial. Withdraw it when
  the user then answers "Don't Allow".

Correctness:

- `pollDecision` needs the site's own token, which a permission popup
  deliberately never delivers, so a signed-out cross-origin-isolated site
  burned the full five-minute timeout before answering. Answer at once
  when there is nothing to poll with.

- `getUserAppToken` reports failure by returning null, and three callers
  read `.app_uid` off it. Guard all three, keep the first-visit spinner
  promise settling on its failure paths, and dispatch the `login` event on
  the manual-signup path so `postAuthActions` runs at all — a user who
  signed up inside a permission popup got a blank window and the site got
  no answer.

- Time-box the lookups that run while a request holds the dialog queue's
  slot: they have no timeout of their own, and a stall (not a failure)
  wedged every later permission request in the page.

Also harden the grant/revoke input validation the PR introduced — it
skipped `extra`/`meta`, so a non-object faulted *after* the row was
written, and its length cap was 16x the column it lands in — stop a
non-URL `origin` from throwing past the answer-and-close, and drop the CSS
left behind by the deleted dialog.

* Close three gaps left in the permission-request flow

Each was reproduced first — the squatting grant against a running server,
the COOP timing in a real browser — and each fix was then confirmed by
reverting it and watching the new test fail.

Security: the dialog could name one site and grant to another.

The squatter guard added for grant/revoke by `origin` only ran when
`app_uid` was absent, and the dialog sends both — so `app_uid` won and the
guard never applied. `getAppUIDFromOrigin` returns the synthetic
`app-<uuidv5(origin)>` for any origin with no app row of its own, and the
grant endpoint resolves `app_uid` as uid *or name*, so the grant landed on
whoever registered an app under that computed name (the format allows it,
and the namespace is a source constant). A link like
`/action/request-permission?origin=https://a-site-you-trust.example` named
that site in the prompt while "Allow" handed the permission elsewhere.

Fixed on both sides of the wire. The action now sends the origin alone —
no uid resolved in the browser is safe to forward, whatever its source —
and a supplied `origin` now decides the target on the server even when an
`app_uid` travels beside it: the origin is what the prompt showed the
user, so it is what the grant has to follow.

Correctness: a COOP-only site was answered before the user decided.

7efc0c0b routed a severed opener to `pollDecision` by treating an
already-closed popup as severed, but that reads `closed` synchronously
after `window.open()` — before the navigation whose response headers cause
the severing has committed. Measured in Chromium: `closed` is false at
0ms and true by 200ms. So a site sending COOP: same-origin without COEP
still took the watch-the-window path, and `requestPermission` resolved
false 1.1s after the click while the dialog was still on screen. The
"Allow" that followed committed a grant the site had been told it did not
get. Tell the two apart by when the close lands, and keep the in-flight
message's grace period on both branches — an answer already on its way
outranks whatever the close is taken to mean.

Correctness: a grant that timed out was not withdrawn.

`grant_may_have_committed` was only set in the fetch's `catch`, which
cannot run until the timeout's timer callback returns — and that callback
already calls `fail_grant`, which settles the dialog as a denial outright
when the dialog was force-closed mid-grant. The reconciliation was
skipped in exactly the case it exists for. Record the unknown outcome in
the timer, where the timeout already means the request left the browser.

Also require a `token` from the user-app exchange rather than just a
non-null body: an HTTP failure (a blocked origin, a 5xx) returns the
parsed *error* body, which is truthy, so the guard added for this missed
it — handing the opener an `undefined` token, and prompting for a grant
whose app row was never bootstrapped.

Known limitation, now more reachable: a severed opener cannot signal a
denial at all, since nothing is written for one, so those sites wait out
the poll timeout before receiving false. A grant still resolves promptly.

* Deliver the uncertain-grant withdrawal from a closing popup

The permission dialog reconciles a denial after an uncertain grant by
firing a revoke in the background, but the popup flow posts the answer
and closes the window right after settling — and a plain fetch is
cancelled with its document, so the withdrawal never reached the server
and the user was told "denied" while the grant stayed live. Send it
with keepalive so the browser delivers it independently of the popup,
and cover the popup flow with a regression test (the existing
withdrawal tests only exercise the desktop flow, where the GUI
outlives the dialog).

Also make the popup boot's getAppUIDFromOrigin guard functional: the
helper reports failure by resolving to a null/undefined uid, not by
throwing, so the catch never engaged and a failed lookup clobbered
window.host_app_uid with undefined despite the comment claiming the
token exchange's value was kept.

* Keep the ai-chat model-map build inside the server lifecycle

onServerStart fired #buildModelMap without awaiting or tracking it, so
the network fetches it does (notably Ollama auto-discovery, which is
enabled by default and doomed on any machine without a local Ollama)
kept running after server.shutdown() resolved. In vitest that let the
provider's console.error land during worker teardown, which surfaces as
"Closing rpc while onUserConsoleLog was pending" — the unhandled error
that intermittently fails CI (last seen attributed to
WispController.test.ts). A rejection in the detached chain would also
have been an unhandled rejection.

Track the promise, catch and log rejections, and await it from
onServerShutdown so no provider I/O or logging outlives the server.
Also disable Ollama auto-discovery in setupTestServer's defaults —
every test server was firing a pointless model-list fetch at localhost.

* Settle requestPermission on the launch paths that could still throw

Three gaps left by the permission-request rework, each verified against a
live stack before and after the fix.

The IPC handler normalises a non-object `options` so it can always reply,
but `typeof null === 'object'` let null through the guard; reading
`.permission` off it threw out of the message listener before any reply,
so `puter.ui.requestPermission(null)` hung forever in env=app while
env=web answered false for the same input.

In the env=web branch only the consent-dialog path was wrapped, even
though its own catch reasons that this resolves to a boolean for every
other caller. A `window.open` refused by throwing rather than by
returning null escaped the launch branch and rejected instead.

The perms docs were flipped to platforms: [websites, apps], but every
entry point except `request()` reads the signed-in user's identity
first, so on a signed-out site they reject with Unauthorized and never
prompt — the permission popup deliberately does not sign the site in.
Document the sign-in precondition on those pages.

* Measure a permission's width after the rewrite that decides it

The new grant validation capped `permission` at 255 to match the column
it lands in, but it measured the caller's raw string. `fs:/path:mode` is
rewritten to `fs:<uuid>:mode` before storage, so what lands in the column
is ~44 characters however deep the path is. Granting access to a deeply
nested file therefore returned 400 even though the identical target
granted by uuid returned 200 and stored 44 characters — and because a 4xx
is read as an outright refusal, the permission dialog showed its
retryable error and could never succeed on retry.

Bound the request body only against absurd input, and enforce the column
width in the permission service on the rewritten string, before the app
is resolved so an oversized permission still refuses ahead of a missing
app. Covered both ways: a rewritten-short permission is accepted, and one
that no rewriter shortens is still refused.

* Require a registered app when a dev-app grant names an origin

The user-app handlers resolve a caller-supplied origin through
#registeredAppUidFromOrigin because appUidFromOrigin synthesises
app-<uuidv5(origin)> for an origin with no app row, and the permission
services resolve their identifier as uid-or-name — so the synthetic uid,
derived from a published namespace constant and computable offline,
lands on whoever registered an app under that literal name. The dev-app
handlers were left resolving the raw synthetic uid.

That leg matters at least as much: a dev-app grant is scanned with the
issuer's authority for anyone running as that app, so a squatted grant
hands over the granting user's permission. Verified against the store —
the grant landed on the squatter rather than rejecting.

Without a squatter the synthetic uid resolves to nothing and these
already 404, so the guard costs the legitimate case nothing; a test
covers a registered origin still resolving to its app.

Also assert that revoke accepts the same oversized-but-rewritten
permission grant does, since the dialog's withdrawal of an uncertain
grant depends on that symmetry.

* Revoke the row a user-app grant actually wrote

`app-root-dir:<uid>:<mode>` is a pseudo-permission: its rewriter resolves
it to a real `fs:<root_uid>:<mode>` only while a user-app permission row
is being written, and resolves to a match-nothing sentinel at all other
times so a scan can't match through the fs path.

Revoke shared that rewrite but not the flag, so it aimed the DELETE at
the sentinel: it removed nothing and reported success while the fs
permission stayed live. The permission dialog withdraws a grant whose
outcome it couldn't confirm through exactly this path, so a user who
answered "Don't Allow" after a dropped grant response kept the access
they had just refused.

Grant and revoke now share one rewrite helper. It also has to work for a
caller outside a request scope — an internal job, or a direct unit test —
where `Context.set` has nothing to set the flag on; an empty scope reads
the same as no scope, so it only makes the flag settable.

* Elide a long host from the left, as its own rule intends

The identity line is the only thing on the permission dialog naming the
requester, so a host too long for the dialog has to lose its front, not
its tail: the registrable domain is the part that says who is asking.

`direction: rtl` was there for that, but paired with
`unicode-bidi: plaintext` it does nothing — plaintext takes the base
direction from the content's own first strong character, which for any
Latin host is LTR, so the ellipsis went back on the right. Measured in
the real dialog, `account-security.paypal.com.verify-login.example`
rendered as `account-security.paypal.com.verify-l…`, reading as PayPal.

Isolating instead keeps the box anchored to the end of the text, and
still stops a bidi control character in the host from reordering
anything around it.

The test that covers this asserted the computed `direction` — the
property, not the outcome — so it passed throughout. It now measures
which characters are actually on screen, and that the host still reads
in source order.

* Tell a severed opener from a closed one by whether it can answer

The web popup flow decided which it was looking at by timing: a
`popup.closed` flip within 3s of `window.open()` was COOP severing the
opener, anything later was the user closing the window. Both halves
misfire, and both were reproduced against a real popup.

A COOP-only site whose popup navigation commits after the cutoff had its
severing read as a close, so the site was told "denied" about a second
later — while the prompt was still coming up. The Allow the user went on
to click then committed a grant the site had been told it did not get,
which is the failure the cutoff was introduced to prevent: how long a
navigation takes says nothing about whether the opener survived it.

The other way round, a signed-in site whose user dismissed the popup on
sight had that close read as severing, and fell back to polling for a
decision. A denial writes nothing to poll for, so the caller waited out
the full five-minute timeout instead of being answered.

So ask the question directly: the popup now announces itself to its
opener, which it can only do while the relationship is intact. Having
heard from it proves a later close is a real close and the answer is now;
never hearing from it means the prompt may be live in a window that
cannot answer, and the decision is read back from the server as before.
The announcement carries no token, and goes out before any sign-in gate —
a gate that delayed it would make abandoning sign-in look severed.

Measured: the popup announces itself ~290ms after opening, and a close
just after that is answered in ~1.3s rather than five minutes.

* Stop revoking a literal `*` after a dev-app revoke-all

The `*` arm of /auth/revoke-dev-app fell through: after
`revokeDevAppAll` it also ran `revokeDevAppPermission(…, '*')`, a
DELETE naming a row called literally `*` — which matches nothing —
plus a second `revoke` audit entry for the same action. The user-app
twin already if/elses its two arms; the dev-app handler now matches it,
and a test pins the parity: everything revoked, one audit row.

* Match the popup's messages against a canonical GUI origin

The web flow compared `event.origin` to `puter.defaultGUIOrigin` as raw
strings, but they are different kinds of value: the event carries the
browser's canonical origin serialization, while the configured origin is
whatever text was supplied — a trailing slash, an explicit default port,
or a stray path all name the same origin and all fail the comparison.

The mismatch doesn't read as a config error, it reads as the user's
answer: with every message from the popup dropped, the missing
`permissionPromptReady` makes the popup's close look like a severed
opener, and the missing decision leaves that path to answer on its own —
"denied", for a guest, moments after the user clicked Allow and the
grant committed.

Parse the configured origin once and compare canonical-to-canonical; the
popup URL is built from the same parsed origin, so a trailing slash no
longer yields a `//action/...` path either. A configured origin that
cannot parse could never have hosted the prompt, so it now denies up
front instead of opening a broken window. Pinned by an e2e test that
re-points the SDK at the same GUI through a trailing-slash origin and
expects the grant to be heard; it fails against the raw comparison.

* Take a permission popup's requester from the browser, not the link

`app_uid` was removed from the request-permission URL because the uid
names who receives the grant, so it has to come from the requesting
origin rather than from whoever built the link. Two other parameters
still carried exactly that identity, and the origin is the identity
twice over: it is the name the dialog attributes the request to, and it
is what the server resolves into the app the grant is written against.

`opener_origin` is believed on every popup boot, ahead of the referrer.
`origin` was the fallback in the request-permission block itself,
reached whenever there is no opener at all. Either one lets a bare link
raise a consent prompt in some other app's name — the token exchange
bootstraps an app row for whatever origin was typed, so the grant then
commits against it — while the user is looking at a domain the requester
does not control. That is the whole dialog defeated: a site can name
`docs.google.com` and have the Allow land on Google's app row.

So a permission popup now takes only an origin the browser vouches for:
`document.referrer`, or the opener's own reply to the `requestOrigin`
handshake. Neither can be forged to another origin. Nothing legitimate
relied on the parameters — the SDK sends neither, and the OIDC redirect
`opener_origin` exists for drops `action` too, so no permission flow can
arrive through one. The `origin` fallback only ever fired when there was
no opener, which is to say when there was no requester either; that path
now reports its denial as usual instead of prompting.

The referrer is safe here precisely because `action` is only
`request-permission` on the popup's first load, so it is always the
opener's. Restoring the action across an OIDC hop would break that — the
returning navigation's referrer is the identity provider — which is why
the round trip is withheld rather than repaired:

A popup that signs in through OIDC comes back to a redirect URI the
server hard-codes to `/action/sign-in`, so it returns believing it is a
plain sign-in popup. It posts `puter.token`, which the SDK's global
listener feeds straight into `setAuthToken()`, and never runs the action
it was opened for. For a permission prompt that is the exact outcome
`deliversTokenToOpener` exists to prevent: the site is signed in without
ever asking, the user is never shown the permission they were brought
there to decide, and the request resolves as a denial. Nothing in the
returned URL says what the popup was for, so it cannot recover on its
own. Until the redirect can carry the action back — and the opener
origin can be re-established from the handshake on return — a popup
whose purpose cannot survive the hop does not offer the hop. Email
sign-in stays in the window and is unaffected.

Pinned by e2e tests that spoof each parameter and expect the prompt to
name the real opener, or not to appear at all. The long-hostname test
drove the dialog through `origin=`, which no longer produces one, so it
now asserts the CSS elision contract directly while the popup test
asserts the real flow applies the `perm-dialog-entity-host` class that
contract keys on.

* Refuse an origin the grant could never have named

The identity line elides a long host from the left, keeping the
registrable domain visible, which it does with `direction: rtl`. That is
sound for a host — every character in one resolves left-to-right, so the
string reads in source order — but it is not sound for arbitrary text,
whose neutral and RTL runs can render in an order they were not written
in. On the one line of this dialog whose whole job is saying who is
asking, that is the wrong thing to be lenient about.

The entity resolver reached that state through its own fallback: when
`new URL(origin)` threw it displayed the unparsed string and still marked
it as a host. An origin the server cannot parse cannot name a grant
target either — `AuthService#originFromUrl` rejects it, and rejects
non-http(s) schemes with it — so there was never anything to prompt
about. Deny at the gate, next to the existing "requester the grant can't
name" check, on the same test the server applies.

* Give the decision poll a deadline it can actually reach

`pollDecision` bounds itself with a five-minute budget, but it only reads
the clock between iterations and its `fetch` had no timeout of its own. A
request that never settles — a stalled connection, a proxy that accepts
and never answers — parks that `await` forever: the loop never comes
back round to check, `settle` is never called, and the caller's promise
stays pending for the life of the page with the message listener and
interval still attached. It is the only unconditional hang left in the
flow, and the least recoverable one, because the popup is already closed
on this branch and nothing the user does can rescue it. Each attempt now
gets ten seconds — long enough that a slow-but-working connection is
still heard, short enough that the deadline means something.

The request id changes for a related reason. It was `#messageID++`, a
small integer restarting at 1 on every page load, and `event.source` is
not pinned for as long as the no-gesture consent dialog waits for its
Continue click — a stretch of time the user paces. A permission popup
left open from before a reload posts this exact message shape to its
opener on the way out, and its counter value collides with a fresh
request's, settling it with the decision the user made about a different
permission. A random suffix makes the two impossible to confuse; the GUI
echoes the value back verbatim, which the loose comparison still handles.

* Answer the app when the signup gate throws

The requestPermission branch grew a `respond` helper so every exit tells
the app something and its promise settles instead of hanging. One exit
still doesn't: `await UIWindowSignup(...)` is outside the guarded region,
and `ipc_listener` has no outer catch, so a throw from the signup window
escapes the listener entirely — before any reply — and leaves the app
waiting forever. That is the precise failure the helper was added to rule
out. Treat it as the refusal it amounts to.

* Revert "Keep the ai-chat model-map build inside the server lifecycle"

This reverts commit 1b2482dcb8.
2026-07-27 08:37:41 -07:00
Daniel Salazar b2324cd212 chore: cleanup puter-js driver to match rest of changes (#3452) 2026-07-27 08:25:44 -07:00
jelveh a1c8ba4cdd Fix stuck grab cursor on aborted drag
When dragging is aborted by returning false from the sortable 'start' handler, the cursor plugin never fires its cleanup, leaving the body cursor stuck on 'grabbing'. Reset the body cursor explicitly on each early-return path.
2026-07-26 23:29:33 -07:00
jelveh 30461e7d95 Hide Trash from file explorer listing
Trash is accessible via the sidebar and should not appear as a row in the file explorer. Filters it out from directory listings and prevents socket events from re-adding it to the view.
2026-07-26 22:32:32 -07:00
jelveh 46253fb80a Disable hover during rubberband file select
Add a `rubberband-selecting` state on the dashboard while marquee selection is active in `TabFiles`, and remove it when drag ends. CSS now disables pointer events on surrounding files chrome (sidebar, directory tree, and header) during that state so hover highlights/tooltips don’t trigger when the cursor leaves the clamped selection area.
2026-07-26 22:23:54 -07:00
jelveh 29f2a4d9c9 Default file picker and sidebar to Home
Make Home consistently visible and selected in file navigation. The file tab now shows the Home directory entry and auto-selects Home (instead of Desktop) when no initial path is provided. In `UIWindow`, saved sidebar layouts are backfilled to include Home if older preferences are missing that entry, preventing users from losing quick access after upgrades.
2026-07-26 22:14:17 -07:00
jelveh e00e1e57da Centralize trash icon update logic
Extract duplicated trash icon update code into two shared helpers:

- `window.update_trash_icons(is_empty)` updates all trash icon surfaces (taskbar, desktop items, shortcuts, open window headers, Dashboard sidebar) in one place.
- `window.refresh_trash_state()` stats the trash, emits the socket event, and calls `update_trash_icons`.

Replace all scattered inline stat+emit+icon-update blocks across UIItem, UIWindow, UIDesktop, UIDashboard, keyboard, helpers, and generate_file_context_menu with calls to these helpers. Also syncs the Dashboard sidebar trash icon on load and after every destructive operation.
2026-07-26 21:51:07 -07:00
Nariman Jelveh 6668d778d2 feat: highlight uploaded items after upload
Uploads now land selected wherever they finish: the desktop, explorer
windows, file dialogs, and the dashboard Files tab. Selection is applied
from both completion channels — the batch response selects elements
already in the DOM, and items whose item.added socket event arrives
later are remembered (with a TTL) and selected on creation. The explorer
footer count and the open-file dialog's Open button are updated to
match, and disabled items (e.g. filtered out by a dialog's file-type
filter) are never selected.
2026-07-26 21:14:26 -07:00
Nariman Jelveh 7f3691aea3 Auth: land back on the /app/<name> the user came for after login/signup (#3435)
Landing on /app/<name> logged-out and authenticating used to dump the
user at the root dashboard on several paths, losing the app they came
for:

- OIDC login/signup only sent return_to for /desktop and /dashboard
  (and the backend whitelist only accepted those two), so OIDC from an
  app landing redirected to /.
- UIWindowSignup defaulted its post-success redirect to /, so password
  signup reached via the session list, ?action=signup, or in-app signup
  prompts (IPC.js) lost the app.
- OIDC error redirects always went to /?action=..., so a recovered
  attempt (e.g. account-not-found bounced to signup) also lost the app.

New helpers in src/gui/src/helpers/auth_redirect.js:
- get_auth_redirect_url(): stay on the page auth started from;
  /action/* pages go to /; strips action/auth_error/message/
  request_code so the reload doesn't re-open the auth window or pass
  auth params through to the app as launch args.
- get_oidc_return_to(): pathname when whitelistable, now including
  /app/<name> (trailing slash normalized).

Backend (OIDCController):
- Shared isWhitelistedReturnPath() accepts /desktop, /dashboard, and
  /app/<name> (charset mirrors APP_NAME_REGEX — no open redirect).
- buildErrorRedirectUrl() lands on the whitelisted originating page
  from the signed state's redirect_uri instead of always /.

Tested: 47/47 OIDC controller tests pass (4 new: return_to accepted/
rejected on start, success redirect to /app/<name>, error redirect
keeping /app/<name>); verified live on local dev for first-visit temp
user, password signup (incl. email-confirmation gate), and password
login — all land on /app/camera with the app open.
2026-07-26 20:33:46 -07:00
Nariman Jelveh 1e280601c6 fix: showSaveFilePicker from external websites (#3450)
Saving a file via puter.ui.showSaveFilePicker from a third-party website
(popup flow) consistently failed with a DataCloneError alert after
clicking Save, and saving over an existing filename showed a raw error
instead of the Replace/Cancel prompt.

Two bugs:

1. privacy_aware_path is a curried factory (world => fspath => ...), and
   initgui.js is the only module that imports it directly — so the popup
   save handler's privacy_aware_path(res.path) returned the inner
   function, which postMessage cannot structured-clone. Every other call
   site resolves the bare name to the correctly bound
   window.privacy_aware_path global, which is why only the external-site
   popup flow was broken. Use the global at the call site and import the
   factory under a distinct name so a bare call can't silently resolve
   to it again.

2. The v2 backend returns `conflict` for a same-name write, but the v1
   wire contract is `item_with_same_name_exists` + `entry_name`, which
   the GUI's save dialogs key on to offer the overwrite prompt. Restore
   the legacy code/field on the write-conflict error and carry
   HttpError.fields through the /batch per-op error serializer.

Verified end-to-end against a local backend: fresh save resolves the
caller's promise with the signed saved_file and closes the popup;
saving an existing name shows Replace/Cancel and Replace overwrites.
Backend suite shows no new failures.
2026-07-26 11:28:38 -07:00
Daniel Salazar 2262975785 fix: readdir response type (#3449) 2026-07-25 18:06:50 -07:00
Daniel Salazar e6e6e3ba9a chore: cleanup API driver calls PUT-1324 (#3448) 2026-07-25 18:05:36 -07:00
Daniel Salazar e85cd9d53d feat: readdir with depth (#3446) 2026-07-25 14:04:21 -07:00
Nariman Jelveh 828b5d9430 Fix popup file pickers rendering an empty explorer
The cover-page rule added for the auth windows forced `width: 100%` on
every `.window-cover-page .window-body`, including the popup file,
directory, and save pickers. That body is an item-container with
`overflow: auto`, so it establishes its own formatting context and may
not overlap the floated `.window-sidebar`. Unable to shrink to fit, it
dropped below the sidebar, pushing the file list and the Cancel/Open bar
outside the 700x400 popup and leaving the page background showing
through as grey.

`.window-body-filedialog { width: auto !important }` was meant to cover
this but loses on specificity, one class against two. Scope the auth
rule with `:not(.window-filedialog)` instead; that class is only set for
the three dialog kinds, so no auth or session cover page changes.
2026-07-25 13:02:15 -07:00
jelveh 3b1ba72ddd Share item-added updates across GUI shells
Extracted desktop `item.added` container update logic into a shared `apply_item_added_to_containers` helper and wired it into both `UIDesktop` and `UIDashboard`. The helper handles overwrite updates, new item insertion, icon refresh, and resorting for matching item containers. This fixes dashboard-hosted explorer/file dialogs not reflecting uploads until navigation.
2026-07-24 23:01:10 -07:00
jelveh 4245ac04ac Isolate upload dialog to prevent double uploads
Replace reuse of `#upload-file-dialog` with a dedicated hidden file input owned by `init_upload_using_dialog`. This avoids collisions with the dashboard Files tab’s `onchange` property handler, which could trigger duplicate concurrent uploads and "Entry already exists" races. The new flow stores the current target path once per dialog open, clears input value before click (so re-selecting the same file still fires `change`), and snapshots `FileList` to an array before clearing to keep async uploads reliable.
2026-07-24 18:26:43 -07:00
Daniel Salazar b8559c1221 chore: cleanup other puter-js modules to match new structure (#3440) 2026-07-24 17:42:06 -07:00
Daniel Salazar 6c4fa629a9 fix: allow root token to also call ai drivers (#3442) 2026-07-24 17:41:38 -07:00
jelveh e6531d4357 Merge branch 'main' of https://github.com/HeyPuter/puter 2026-07-24 15:38:38 -07:00
jelveh 0c27571030 Dashboard files: compact drag ghost + spring-loaded nav controls
- Replace the full-tile drag ghost with a compact icon+name chip:
  stacked sheets and a count badge for multi-selections, dimmed
  source rows, same design in list and grid views.
- Holding a dragged item over back/forward/up or a breadcrumb
  segment now spring-navigates there (700ms dwell); cancelling
  the drag returns to the original directory.
2026-07-24 15:37:33 -07:00
Neal Shah 6e6d9d2869 buildXhr, dedup, retry (#3432)
* buildXhr, dedup, retry

* change retry intervals
2026-07-24 18:37:09 -04:00
Nariman Jelveh 34529b2e79 Dashboard files: replace solid-blue selection with tinted accent wash
Selected tiles/rows now use a subtle accent tint plus hairline ring
instead of a solid --select-color fill, so icons and labels keep their
natural colors in both themes. Also refines the grid tile's hover
'...' button (inset, hairline border, hover state), fixes the
light-mode selected-row text color that referenced a dark-mode-only
variable, and drops the opaque icon chip visible on tinted list rows.
2026-07-24 13:09:31 -07:00
Nariman Jelveh 7e4ac6542e Update TabApps.js 2026-07-24 12:42:17 -07:00
Daniel Salazar 3331dbd464 chore: make fs upload cleaner in puter.js (#3436) 2026-07-24 12:18:19 -07:00
Daniel Salazar 391b175a68 fix: remove deprecated claude models (#3438) 2026-07-24 10:54:01 -07:00
Neal Shah 19f35b4200 add opus 5 (#3437) 2026-07-24 13:49:20 -04:00
jelveh 9877bb79d0 Polish mobile dashboard sidebar drawer UX
Refactors the dashboard’s mobile sidebar into a modal-style drawer with a coordinated state helper, scrim overlay, and dedicated close button. Adds accessibility improvements (`aria-label`, `aria-expanded`) and stronger dismissal behavior (scrim tap, close button, Escape, and swipe-left with velocity/threshold handling). Updates mobile CSS for safe-area-aware positioning, improved touch targets/pressed states, frosted toggle styling, drawer shadows/transitions, and drag-state handling for smoother interactions.
2026-07-23 18:56:43 -07:00
Nariman Jelveh 2982e894b1 Update dashboard.css 2026-07-23 18:14:47 -07:00
Nariman Jelveh 10bdf701e0 Dashboard: start the minimize morph immediately, bookkeep the URL behind it
The minimize controls consumed the app's /app/<name> history entry
first and let the popstate handler do the visible hide. When the app's
iframe had stacked joint session-history entries, the pop only settles
at pop_dashboard_app_url's 400ms watchdog — so clicking minimize sat
still for ~half a second before the morph began.

Hide the window in the click frame instead (minimize_window, shared by
the head button, the context menu, and the control drawer) and demote
the pop to pure URL bookkeeping. The eager hide is safe against both
ways the pop settles: every hideWindow branch marks data-is_minimized
synchronously, and the popstate handler and the watchdog both already
skip minimized windows. The browser's Back button still minimizes
through the popstate handler as before.
2026-07-23 17:50:32 -07:00
Nariman Jelveh e764a10ee4 Dashboard: recover minimize/close when history.back() misses the app entry
Minimize (and close's URL cleanup) on headless dashboard app windows
work by consuming the app's /app/<name> history entry with
history.back() and letting the popstate handler do the actual hide.
But back() traverses the JOINT session history, which the app's iframe
shares: if the app navigated internally after load (SPA router,
redirects — and browsers differ on which iframe navigations stack
joint entries), the back() consumes the IFRAME's entry instead. The
parent's URL never changes, no popstate arrives, and the
dashboard_url_pop_pending latch stays stuck — every later minimize and
close becomes a silent no-op, and the stranded /app/<name> entry makes
later traversals resurrect windows the user already dismissed.

Fix with a watchdog after each back(): a successful pop is a
same-document traversal of the parent (app entries are pushState over
the dashboard route), so its popstate lands well inside 400ms and the
watchdog no-ops. If the URL still names the app by then, do what the
popstate would have done — minimize through the same lookup and
minimized-guard — and replaceState the stranded entry back to the
dashboard's own route (captured at push time, tab hash included) so no
stale entry survives.
2026-07-23 17:23:03 -07:00
Neal Shah 7e461e14a2 remove unneeded write origin append (#3434) 2026-07-23 19:09:23 -04:00
Daniel Salazar 48cc706ad3 feat: paginated fetching for all (#3431)
* feat: paginated fetching for all

* fix: metering top up gui reporting
2026-07-23 15:07:58 -07:00
Daniel Salazar 20ea616b59 chore: cleanup kv module (#3430) 2026-07-23 10:05:19 -07:00
Nariman Jelveh 73a7a38ea8 Dashboard: redesign app control pill as a top-edge drawer (#3427)
* Dashboard: redesign app control pill as a top-edge drawer

The floating control capsule over headless dashboard apps consumed
noticeable space, especially on mobile. Replace it with a drawer: a
slim glass tray flush with the top edge (icon + title + minimize +
close) whose tongue-shaped handle hangs from its bottom center. At
rest only the tongue peeks in from the edge; hovering it (mouse),
tapping it, or keyboard-focusing the drawer slides the tray down.
The collapse is a single GPU transform instead of the pill's
max-width squeezes, and the open-then-retract intro now visibly
retracts into the tongue, teaching where the controls live.

Hover-expansion is gated on pointerType === 'mouse': touch taps
synthesize a pointerenter right before click, which made the old
pill's toggle see an already-open state and shut it again.

Also fixes two window-stacking bugs surfaced while testing:

- showWindow demoted stay_on_top windows (every fullpage/dashboard
  app window, created in the 99999999+ z band) to a plain counter
  z-index on restore. focusWindow deliberately never raises
  stay_on_top windows, so after minimize/restore a single dashboard
  focus buried the app permanently. Restore now re-raises into the
  stay-on-top band.

- Pressing the drawer now focuses its window, as the titlebar it
  replaces did. The document-level activation handler works off
  mouseover_window, which only mousemove refreshes — a tap with no
  intervening mousemove (touch, restored windows) would re-raise
  the dashboard over the app.

* Dashboard: restored windows come back with the drawer collapsed

The restore re-played the open intro (expand + auto-retract). A restore
should bring back the app, not the chrome — the user already met the
controls on open, and they just USED them to minimize. showWindow now
forces the drawer shut instead, which also covers the Back-button
minimize path, where the window hides without touching drawer state.

* Dashboard drawer: left-align the title against the icon

The tray is asymmetric — one icon on the left, two buttons on the
right — so a title centered in its fixed box landed off the tray's
true center and read as misaligned against the centered tongue below.
Left-aligned, icon + title cluster as the identity on the left and
the controls cluster on the right.

* Dashboard drawer: morph the tongue into the tray instead of sliding

The drawer is now ONE glass surface that changes shape, rather than a
tray that slides in from off-screen with a handle below it. At rest
it's the tongue; opened, the same surface swells into the tray while
the grabber bar rides the morph and settles into a slim strip along
the tray's bottom edge as the dismiss handle — same handle, both
states. Content is revealed from the center outward through an inner
clipping layer (the surface itself can't be overflow:hidden without
clipping the toggle's touch halo) and condenses in/out of focus with
an opacity + blur + scale crossfade. Opening gets a soft overshoot
and settles; closing is quicker and lands without bounce.

The open width is DERIVED in CSS from the same part variables that
lay out the controls row, so the surface always fits its content
exactly and breakpoints (touch sizes, hidden title under 500px) only
override parts.

* Refine dashboard drawer open-state handle

Update the headless dashboard app drawer so the grabber bar disappears when the drawer is open and only the bottom dismiss strip remains interactive. This adjusts tray/strip sizing on desktop and mobile, adds opacity timing for smooth dissolve/reappear transitions during open/close, and aligns in-code docs/comments with the new behavior.
2026-07-22 22:11:39 -07:00
jelveh 5c3fab5579 Revert "Dashboard: fix minimized apps getting permanently buried under the dashboard (#3429)"
This reverts commit bbc86e7b70.
2026-07-22 22:11:15 -07:00
jelveh 695862c61f Increase dashboard directory icon size
Adjusts the directory icon width in the dashboard files section from 28px to 35px for improved visibility.
2026-07-22 21:45:09 -07:00
Nariman Jelveh bbc86e7b70 Dashboard: fix minimized apps getting permanently buried under the dashboard (#3429)
* Dashboard: fix minimized apps getting permanently buried under the dashboard

Fullpage/dashboard app windows are created stay_on_top in the
99999999+ z band, but showWindow's restore path demoted them to a
plain counter z-index — and since focusWindow deliberately never
raises stay_on_top windows, the demotion stuck: one dashboard focus
after a minimize/restore buried the app permanently (URL and tab
title still naming an app the user could no longer see). Restore now
re-raises stay-on-top windows into their band.

Also make the control pill focus its window on mousedown, as the
titlebar it replaces did. The document-level activation handler works
off mouseover_window, which only mousemove refreshes — a tap with no
intervening mousemove (touch, restored windows) would activate the
stale window and could re-raise the dashboard over the app. Deferred
a tick so it runs after that handler and wins.

* Dashboard: restored windows come back with the pill collapsed

The restore re-played the open intro (expand + auto-retract). A restore
should bring back the app, not the chrome — the user already met the
controls on open, and they just USED them to minimize. showWindow now
forces the pill shut instead, which also covers the Back-button
minimize path, where the window hides without touching pill state.

* Dashboard pill: left-align the title against the icon

The pill is asymmetric — one icon on the left, two buttons on the
right — so a title centered in its fixed box landed off the pill's
true center and read as misaligned. Left-aligned, icon + title
cluster as the identity on the left and the controls cluster on
the right.
2026-07-22 21:40:04 -07:00
Daniel Salazar e35b57068b test: add extra tests (#3428) 2026-07-22 21:13:44 -07:00
Daniel Salazar 5faed55076 fix: autoclaim app when making a subdomain (#3426) 2026-07-22 21:12:38 -07:00
Daniel Salazar 928d5fec16 chore: cleanup AI module for puter-js (#3423) 2026-07-22 18:18:54 -07:00
velzie ebcc4bb597 Add loopback support to puter.peer (#3425)
* allow setting port instead of invite code in puter.peer

* fix grabbing fallbackice under wrong response
2026-07-22 20:13:35 -04:00
Neal Shah 51541d3739 initial pjs helper stuff (#3421)
* initial pjs helper stuff

* move resolveReauth

* move location of PuterClient and rename to networkUtils
2026-07-22 18:48:11 -04:00
Nariman Jelveh 43b9200205 Dashboard: headless full-tab apps with a floating control pill (#3424)
* Dashboard: headless full-tab apps with a floating control pill

Maximized app windows in dashboard mode no longer render a titlebar —
the app covers the full tab. The head's controls move to lighter-weight
affordances:

- A floating pill overlaid top-center of the app (parent DOM, above the
  iframe): expanded it shows the app icon, title, minimize, and close;
  it opens expanded so first-time and deep-link users see it, then
  collapses to a subtle capsule that re-expands on hover, tap, or
  keyboard focus. Minimize routes through the same URL-entry consumption
  as the Back button; close tears the window down normally. Dark
  translucent glass so it reads over any app content; safe-area aware;
  bigger targets on coarse pointers; title hidden under 500px; respects
  prefers-reduced-motion.
- Apps-tab tiles double as the app switcher: a macOS-dock-style running
  dot marks tiles with a live (visible or minimized) window, and the
  tile context menu gains a Quit item for running apps. UIWindow
  broadcasts window open/close so the dots stay current.
- puter.ui.setWindowTitle also updates the pill title.

Only maximized, URL-owning app windows in dashboard mode go headless:
dialogs, explorer, non-maximized child windows, and everything on the
desktop keep their titlebars. dashboard.css's 29px head-height
compensation is scoped to windows that still have heads, so headless
app bodies fill the window exactly.

* Fix pill buttons swallowed by their own hover halo

The pill's invisible hover halo (an absolutely-positioned ::after with
negative inset) painted above the pill's static children, so real
pointer clicks hit-tested to the halo and never reached the minimize/
close buttons — while element.click() (which skips hit-testing) worked,
hiding the bug from programmatic checks. Stack the halo at z-index -1
(the pill is its own stacking context, so the outside ring still sits
above the app's iframe) and raise the interactive children explicitly.

* Review round: pill/dashboard edge-case fixes

- Latch pop_dashboard_app_url while its history.back() is in flight: a
  double-click on minimize (or a close racing a minimize) issued two
  back()s, and the second popped the dashboard's own entry — navigating
  clean out of the page. Any popstate or push settles the latch.
- Close a running app's windows when it is uninstalled: the tile is a
  headless app's only switcher, so a minimized instance would otherwise
  be stranded — impossible to restore or quit.
- puter.ui.setWindowTitle in dashboard mode now also updates the
  browser-tab title (while that app owns the URL) and the window's
  data-name, so a later restore re-applies the app's current title
  instead of its launch-time one. Desktop behavior unchanged.
- Nudge collapsed-pill contrast (opacity 0.55 → 0.65, border alpha
  0.16 → 0.22) so the capsule stays findable over dark app content.

* Adjust dashboard tile spacing and label sizing

Tweaks My Apps tile layout constants to match the real icon/label geometry: tile height is increased, label top margin is widened, and the running-status dot is moved slightly lower to avoid overlap during icon hover scaling. It also raises dashboard app pill title line-height to prevent descenders from being clipped when ellipsis overflow is active.

* Pill and running-dot visual polish

- Fixed-width pill: the title gets a fixed 150px centered box instead of
  a cap, so every app's pill is identical and the minimize/close buttons
  sit at the same screen position in every app — they're operated by
  muscle memory, like any titlebar's controls.
- Give the pill title line-height 1.35: overflow:hidden (needed for the
  ellipsis) also clips vertically, and at line-height 1 it cut the
  descenders off g/p/y.
- More air around the tile running dot: label margin 6px → 10px and the
  dot 2px lower (tile row height 78px → 82px to keep the label line in
  budget — the pager layout reads the CSS var, so it follows). The
  icon's 1.08x hover growth no longer touches the dot.

* Size the pill icon to balance the capsule

16px read as undersized next to the 26px buttons in the 36px capsule —
bump the expanded icon to 20px with a proportional (25%) radius, and
object-fit: cover in case an app icon isn't square. Collapsed size is
unchanged.
2026-07-22 14:28:06 -07:00
jelveh d5e9417d2d Dashboard apps own the URL: /app/<name> entries with Back-to-minimize
- Opening an app in dashboard mode pushes a real history entry at
  /app/<name>; Back minimizes the app (it keeps running), Forward
  restores it — or relaunches it if it was closed
- The minimize/close buttons consume the entry via history.back() so
  the address bar never names an app that is no longer on screen
- Landing directly on /app/<name> now boots the dashboard with the app
  open maximized in-page instead of the desktop; embeds, popups, and
  explicit fullpage boots keep the desktop flow, and fullpage_on_landing
  no longer opts a landing out of the dashboard
- Desktop mode no longer rewrites the URL at all (no /app/<name> or
  folder-path replaceState on focus, no /desktop resets on close or
  minimize); tab titles still track the focused window
2026-07-22 10:50:10 -07:00
jelveh dec205d959 Lighten tile-to-window restore animation
Update `showWindow` to use a shorter, window-only morph when restoring a minimized app from a dashboard tile. `morph_window_from_tile` now accepts options for `icon_half` and `duration`, gates icon ghost creation accordingly, and derives transition/cleanup timing from the configured duration so un-minimizing feels distinct from a full app launch.
2026-07-22 09:57:24 -07:00
jelveh d87f3464fc Support Cmd/Ctrl+click and adaptive bg color
Update the dashboard app tile click handler so Ctrl/Cmd+click opens apps in a new tab (matching the context menu behavior) while keeping existing target-link/app-name routing. Also switch the default body background to white and add a dark-mode media query to use a dark background color.
2026-07-21 20:33:27 -07:00
jelveh 25410cac92 Allow tile morph animations on phones
Removes `isMobile.phone` checks that previously disabled dashboard tile morph transitions. Window open/close morph selection and tile-launch ghost setup now run on phone the same way they do elsewhere, while still honoring animation-disable and reduced-motion conditions.
2026-07-21 19:13:48 -07:00
jelveh 66a1baef24 Preplay dashboard tile launch morph animation
Add click-time launch feedback for dashboard app tiles so users get immediate visual response while app startup is still loading. TabApps now starts and settles tile launch state around `launch_app`, logs launch failures, and keeps duplicate-launch protection.

UIWindow now tracks in-flight tile feedback with a WeakMap, introduces `begin_dashboard_tile_launch`/`settle_dashboard_tile_launch`, and updates `morph_window_from_tile` to avoid replaying the icon half when it already ran at click time. Ghost/icon timing and cleanup were adjusted so fallback and failed launches do not leave stale tile state.
2026-07-21 19:09:32 -07:00
jelveh 5c4df9cbd7 Revert "feat(gui): start the tile open-morph on click, before the launch fetches"
This reverts commit f89b3a888a.
2026-07-21 18:25:39 -07:00
jelveh f89b3a888a feat(gui): start the tile open-morph on click, before the launch fetches
A fresh launch has a server round-trip (app info, signing) between the
click and the window's creation, so the open morph used to start late —
on a slow connection the click gave no feedback at all. The two halves
of the morph are now decoupled:

- On click, begin_dashboard_tile_launch starts the icon's half
  immediately: the ghost enlarges in place and dissolves exactly as
  before, and the real icon stays hidden — the empty slot reads as
  'launching' — while the fetches run.
- When the window opens, morph_window_from_tile claims the click-time
  ghost instead of spawning a second one, runs the window half alone,
  and restores the icon when it lands. On a fast launch the halves
  overlap into one motion; on a slow one the window zooms out of the
  empty slot.
- settle_dashboard_tile_launch (in the launch's finally) puts the icon
  back whenever the window never claims it: launch failed, app opened
  in background, or the morph fell back to the plain fade. The launch
  also gained a .catch, so a failed launch logs instead of leaking an
  unhandled rejection.

The un-minimize path has no fetch and is untouched: it still spawns its
own ghost inside the morph.
2026-07-21 17:58:05 -07:00
Nariman Jelveh bf545dda25 Dashboard: open apps maximized in-page, with minimize and single-instance behavior (#3419)
* feat(gui): open dashboard apps maximized in-page with minimize + single instance

Apps launched from the dashboard's Apps tab now open as maximized Puter
windows in the same page (via launch_app) instead of a new browser tab.
External website shortcuts still open in a new tab since arbitrary sites
can't reliably be iframed.

Dashboard app windows keep their minimize button (fullpage mode normally
hides it since there's no taskbar). With no taskbar item to animate
toward, hideWindow now hides the window in place and flags it with
data-minimized_in_place; showWindow un-hides it via that flag. An
explicit flag is used because data-orig-* can't distinguish the two
minimize paths - drag/maximize handlers set those attrs too.

Tiles are single-instance: clicking a tile un-hides a minimized
instance or focuses a visible one instead of launching a duplicate, and
launches in flight swallow repeat clicks.

Also fixes .window-body-app height in dashboard mode: fullpage mode
sizes it to 100% assuming no titlebar, which clipped the bottom 29px of
every app.

* feat(gui): iOS-style minimize-to-icon morph in dashboard mode

Minimizing a window in dashboard mode now zooms it into its app's tile
on the Apps tab — but only when the user can see that tile (Apps section
active AND the tile on the pager page currently in view); otherwise the
existing hide-in-place fade is kept, as on phones and with
prefers-reduced-motion.

The animation is a two-layer morph on one 450ms cubic-bezier(0.32,
0.72, 0, 1) path: the live window scales onto the tile's icon box
(transform + opacity only — no per-frame iframe reflow) while its corner
radius grows toward the icon squircle, and an enlarged clone of the icon
flies congruently with it, cross-fading in as the window fades out and
landing exactly on the real icon's slot (hidden until the handoff), so
the window reads as becoming the icon.

Window geometry is never touched: only inline transform/opacity/radius
are set and restored byte-identical when the animation ends, and
showWindow()'s existing minimized-in-place path restores as before —
including a reopen mid-animation.

* fix(gui): keep the minimize-morph ghost icon square (contain-fit)

The icon clone previously mapped rect-onto-rect over the window, which
stretched the square icon anamorphically. A covering square was tried
and jittered: it protruded past the card's short edge and the halo
popped in mid-crossfade.

Contain-fit resolves both: the ghost scales uniformly (always square),
sized to the window's short edge and centered — its side tracks the
card's short edge exactly for the whole flight (both interpolate
linearly between the same endpoints on the same curve), so the card's
rectangular flanks melt away around a steady, undistorted icon.

* feat(gui): add 'Open in new tab' to app tile context menu

Every tile's right-click menu now leads with Open in new tab, which
opens the app the way tiles did before in-page windows: /app/<name> in
a new browser tab (noopener), or the site link for external tiles.
Non-uninstallable apps previously had no menu at all; they now get this
single item, and uninstallable ones show it above a divider + Uninstall.

* feat(gui): reverse morph — dashboard tile icons zoom into their opening window

Opening an app in dashboard mode now runs the minimize morph backwards:
the window grows out of its tile's icon box (transform + opacity only,
radius relaxing from the 22% squircle to the resting value) while a clone
of the icon enlarges IN PLACE on its slot — capped at 2.5x, fully dissolved
by ~250% growth — as the window fades in underneath on the same 450ms
cubic-bezier(0.32, 0.72, 0, 1) path with the crossfade handoff mirrored
(window in at 80-240ms, ghost out at 120-320ms).

Both open paths get the morph, with the same visibility conditions as
minimize (Apps section active, tile on the pager page in view, not phone,
no reduced motion), falling back to the existing fades otherwise:

- fresh launch: TabApps passes window_options.morph_from_dashboard_tile,
  and UIWindow morphs instead of the 70ms opening fade. Since .window is
  display:none from the stylesheet until later in the pipeline, the hook
  shows it before measuring (nothing has painted yet, so neither the early
  show nor the hide-back on a declined morph can flash).
- un-minimize: showWindow's minimized-in-place branch morphs instead of
  fadeIn(150), keeping the z-index bump and 80ms focus timing.

A data-window_morphing flag guards against opposing morphs fighting over
the window's inline styles: minimize clicked mid-open-zoom (or a tile
click mid-minimize-zoom) falls back to the pre-morph fade behavior, and
each animation still restores every inline property byte-identical when
it ends.

* Update dashboard.css

* feat(gui): cap the open-morph ghost icon at 2x, dissolving in place

The tile icon no longer flies toward the window center nor grows to full
contain-fit (10x+ reads as a giant blurry sticker): it enlarges in place
on its own slot and its growth is capped so the fade-out completes right
around 200% of its size — the window alone carries the motion and the
rest of the growth.

* fix(gui): drop the tile :active press-shrink that stuttered the open morph

Clicking a tile launches the app and the open morph grows the icon in
place — the 0.95 press-shrink made every launch read as shrink-then-grow.
The icon now holds its hover scale through the click and enlarges
straight from there (the morph ghost is measured from the rendered rect,
so it picks up the hovered size seamlessly).
2026-07-21 17:41:45 -07:00
Neal Shah c706b4472d use max_tokens instead of hardcoded value 1000 (#3422) 2026-07-21 18:25:40 -04:00
Neal Shah 93b8041c3d don't allow prototype defined method to be called in drivers (#3413)
* don't allow prototype defined method to be called in drivers

* fix test
2026-07-21 18:01:52 -04:00
Daniel Salazar 89f9f9728f fix: PUT-1355 PUT-1351 PUT-1208 (#3420)
* fix: PUT-1355 PUT-1351 PUT-1208

* fix: dev center header
2026-07-21 14:17:33 -07:00
Daniel Salazar 645409eddb fix: misc hardening (#3418) 2026-07-21 13:55:00 -07:00
tamilselvanp57 58a763bcd1 Add Dev Center UI for viewing app users (#3349) (#3417)
Adds a 'View Users' link on the app Analytics tab that opens a modal
listing users who have authenticated into the app (username + email,
where the user has granted email:read to the app).

Consumes the existing puter.apps.get(...).users()/.getUsers() client
SDK method, backed by the app-telemetry driver's get_users - no new
backend work needed, per discussion on the issue.

Relates to #3349
2026-07-21 13:52:48 -07:00
Daniel Salazar fd91d72654 wip: better contributing docks (#3415) 2026-07-21 12:07:59 -07:00
Reynaldi ChernandoandCopilot Autofix powered by AI 7fbf491e6f Update docs meta tags and description (#3416)
* Update docs meta tags and description

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-21 16:43:00 +07:00
Daniel Salazar a8833de9d5 fix: misc hardening (#3414) 2026-07-20 23:53:37 -07:00
Daniel Salazar 3a8b6394de feat: standardized api pagination (#3412) 2026-07-20 15:58:23 -07:00
Nariman Jelveh f5d656650f feat(email): accept custom message headers in sendRaw (#3411)
* feat(email): accept custom message headers in sendRaw

Lets callers set transport-level headers such as List-Unsubscribe /
List-Unsubscribe-Post. Also documents the puter-email driver's automatic
unsubscribe / report-abuse footer and the new `suppressed` result field
in the puter.js email module.

* feat(email): envelope override in sendRaw + suppressed in EmailSendResult type

Adds an optional `envelope` field to SendMailOptions so callers can set
transport recipients independently of the visible To/Cc headers (used by
the email-send driver for per-recipient deliveries), and adds the
`suppressed: string[]` field to the public EmailSendResult typing to
match the driver's documented result.

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

* feat(email): document per-recipient best-effort delivery via `failed`

The email-send driver now attempts every recipient's private delivery
even when one fails, returning failed addresses in the result's
`failed` array instead of failing the whole call — so callers retry
only the failed subset and never re-mail delivered recipients. Adds
the field to the public EmailSendResult typing and module docs.
2026-07-20 11:55:46 -07:00
Daniel Salazar dd314da16d feat: require app or api tokens for ai api usages (#3407) 2026-07-20 08:34:44 -07:00
jelveh 47c1d4268f Fix overlapping context menu state on file rows 2026-07-19 20:08:00 -07:00
jelveh b9e87935cc Update dashboard.css 2026-07-19 20:00:18 -07:00
jelveh 73dd95bd41 Open Desktop by default in the Files tab 2026-07-19 19:53:52 -07:00
jelveh c5bc358ef2 Keep file rows visually active while their context menu is open 2026-07-19 19:51:38 -07:00
jelveh 57c9eb9e1a Fix grid badge drop shadow and hover overlap with item menu 2026-07-19 19:23:02 -07:00
jelveh a0c0544928 Remove extra top padding from files header 2026-07-19 19:15:51 -07:00
jelveh 6dcae81a56 Fix uninstall animation snapping after a prior FLIP slide 2026-07-19 18:57:04 -07:00
jelveh 09baee6381 Delay uninstall tile animation until after the modal closes 2026-07-19 18:48:06 -07:00
jelveh 68aab718a1 Add keyboard navigation to the Apps tab 2026-07-19 18:43:57 -07:00
jelveh 5d6b2f3265 Uninstall apps optimistically with a tile removal animation 2026-07-19 18:35:01 -07:00
jelveh 28730010c3 Fix inconsistent icon sizing in dashboard grid views 2026-07-19 12:24:21 -07:00
jelveh 817ece40ba Modernize folder icon yellows 2026-07-19 12:24:20 -07:00
jelveh 7c868e947a Fix Public folder icon grid and align glyph system 2026-07-19 12:07:10 -07:00
jelveh 66984cd3d6 Improve glyph sizing on system icons 2026-07-19 12:03:09 -07:00
jelveh 5fa508ba91 improve dashboard file list and grid styles 2026-07-19 11:48:46 -07:00
jelveh c1fca467bf Use bundled fallback for null app icons 2026-07-19 10:52:56 -07:00
jelveh e726c19311 Add compact grid option to Files view menu
Reworked the Files view toggle into a context menu with List, Compact Grid (`grid-sm`), and Grid modes, and refactored view handling into shared helpers (`isGridView`, `viewClass`, `setView`, `applyViewMode`) so grid behavior is applied consistently across rendering and drag previews. Added compact-grid CSS overrides to reduce tile, icon, text, and badge sizing while reusing existing grid layout behavior.
2026-07-19 09:48:31 -07:00
jelveh 6643642aa7 Fix item-more color for selected rows in dashboard
Ensure the item-more icon uses the correct sidebar item color when a dashboard file row is selected, including on hover.
2026-07-19 09:26:54 -07:00
jelveh 65dc3b5f4c Use thinner dashboard file nav icons
Switches the Files tab header navigation buttons to new thin arrow SVG assets and aligns icon styling with a lighter visual weight. It also updates several header action SVG paths (new folder, upload, list/grid, sort, select) and sets path action icon color to #5c5c5c so the controls look consistent.
2026-07-18 21:44:02 -07:00
jelveh c225fabbd4 Fix clipped app tiles and soften files header hover styling
Apps tab: tile rows only budget one label line, so two-line app names
made flexbox shrink the icon box into a landscape crop and clip the
label mid-glyph. Clamp labels to a single line and exempt the icon box
from flex shrinking.

Files tab: header hover states (nav arrows, breadcrumbs, action
buttons) now use a neutral #EEE chip and no longer recolor the glyph;
nav arrows also render at full opacity when enabled.
2026-07-18 21:27:24 -07:00
jelveh fdcc2e301b Fix My Apps tile icon and label overflow
Prevent app tile icons from shrinking when labels overflow, and clamp tile labels to a single line so they fit within the fixed tile height. This keeps the grid layout stable while preserving full app names in the existing tooltip.
2026-07-18 20:54:09 -07:00
Nariman Jelveh 183ab0780d fix: verified Dashboard bug fixes (routing, files, apps, usage, mobile menu, CSS) (#3406)
* fix: don't double-refresh Dashboard tabs on browser back/forward

A single back/forward navigation fires both popstate and hashchange, so
handleRouteChange ran twice, calling the tab's onActivate twice — every
back/forward to the Apps tab issued duplicate /installedApps and
/get-launch-apps requests and re-rendered the tab twice. Track the last
handled URL and skip the second event.

* fix: store raw values in Dashboard file-row data attributes

renderItem passed html_encode()d strings to setAttribute, which stores
them literally — a file named "Tom & Jerry.txt" got
data-path="...Tom &amp; Jerry.txt". Every flow that reads the attribute
back (keyboard cut/copy/paste, delete-to-trash, drag-and-drop, selection
actions) then hit the server with the mangled path and failed with
"Entry not found". The socket item.renamed/item.updated handlers had the
same problem via jQuery .attr(), and item.moved/item.removed matched rows
with html_encode()d selector needles that can't match raw attributes.

Store raw values everywhere (matching the desktop convention and the
raw readers throughout TabFiles), match paths by comparison instead of
selector interpolation, and repair the html_encode(x) ?? fallback
expressions that could never take their right-hand side. Verified live:
copy/paste of a name containing '&' now succeeds.

* fix: escape folder names in Dashboard nav-history menu (stored XSS)

The back/forward taphold menus interpolated path.basename(history_item)
straight into UIContextMenu item html, which renders it verbatim. A
folder named with an HTML/script payload executed when the user opened
the history menu after visiting it. The desktop file manager already
encodes this value; match it with html_encode(). Verified live: the
payload now renders as inert text and its onerror never fires.

* fix: harden Dashboard hash routing against malformed and unknown tabs

Three routing defects, all reachable from a shareable URL:

- A malformed percent-sequence (e.g. `#100%`) made parseDashboardRoute's
  decodeURIComponent throw at module load, blanking the entire GUI. Guard
  the decode and fall back to the raw hash. Verified: `#100%` now boots.

- A hash whose tab segment contained a quote (`#foo"bar`) was interpolated
  into a jQuery selector that throws, leaving dashboard event handlers
  unbound. Resolve the route tab against the known tab set (falling back to
  Apps) before it ever reaches a selector. This also fixes an unknown hash
  activating the Apps section without its `.dashboard-content.apps` styling.

- Re-clicking the already-active sidebar tab pushed duplicate history
  entries, so Back became a no-op until pressed repeatedly. Only pushState
  when the hash actually changes.

Verified live for all three.

* fix: keep Dashboard file list footer and size cells in sync on live updates

Incremental updates only touched hidden data attributes, so the visible
UI drifted from the data:

- Adding a file via the socket (item.added) or creating one inserted a
  row but never called updateFooterStats, so the "N items · size" footer
  stayed frozen at the old count. Removals and moves had the same gap.
- item.updated (and the in-place update in UIDashboardFileItem) rewrote
  data-size/data-modified but not the on-screen .item-size/.item-modified
  cells, so a remote overwrite left a stale size (e.g. "5 B" for a file
  that had grown to 2 KB).

Refresh the footer after add/remove/move and repaint the size/modified
cells on update. Verified live: add, overwrite, and delete now all
reflect immediately.

* fix: Dashboard Files shift-click selection after navigation and byte formatting

Shift-click selection:
- renderDirectory cleared the .selected class but left
  window.latest_selected_item pointing at a now-detached row from the
  previous directory. The shift handler set shift_clicked=true, found the
  anchor's index as -1, skipped the range select, and onclick then
  early-returned on shift_clicked — so the first shift-click in a new
  directory selected nothing. Reset the anchor when it detaches, only take
  the range path when the anchor is still in the list, and treat a
  shift-click with no valid anchor as a plain select that becomes the
  anchor (onclick otherwise ignores clicks while Shift is held).

formatFileSize:
- Returned "NaN undefined" for missing/invalid sizes and "1.5 undefined"
  for terabyte-plus files (sizes array stopped at GB). Guard non-finite
  and non-positive input, clamp the unit index, and add TB/PB.

Verified live: first shift-click selects the item, a second shift-click
extends the range, and formatFileSize returns 0 B / 1.5 TB / 2 PB.

* fix: show a message instead of a blank pane when a Dashboard folder fails to open

renderDirectory empties the file list before awaiting readdir, so a
readdir rejection (permission lost, folder deleted from another session,
network error) left a completely blank pane with only a console error.
Render a centered "This folder couldn't be opened." message on the catch
path. Verified live: a simulated readdir failure shows the message,
clears the spinner, and leaves navigation working (next folder loads).

* fix: Dashboard Usage/Home tab data freshness, errors, and dead controls

TabUsage:
- The Upgrade/Manage button was shown unconditionally and its click did
  `new window.UIUpgradeAccount()`, which only exists on hosted puter.com —
  on self-hosted it threw a TypeError from a dead button. Hide the button
  when UIUpgradeAccount is absent and guard the click.
- The tab had no onActivate and bound its refresh to a nonexistent
  element, so usage numbers were frozen at page-load for the whole
  session. Add onActivate to refetch (verified: reactivating refetches).
- Resource names rendered the backend's `_dot_` escaping literally
  ("gemini-2_dot_5-flash"); un-escape for display and html_encode the cell.
- The two loaders had no error handling; a failed fetch left the tab
  blank with an unhandled rejection. Catch each and show a fallback.
- Guard capacity 0 so storage no longer renders "NaN%".

TabHome:
- Plan button kept saying "Manage →" after a subscription lapsed; reset it
  to "Upgrade →" in the free branch.
- Guard capacity 0 ("NaN%") on the storage card.
- "Your Plan ›" card header had the arrow affordance but no target and did
  nothing on click; point it at the Usage tab like its siblings.
- Returning to the tab fired both focus and visibilitychange, and
  refreshAndBroadcast both called refresh() and dispatched the event its
  own listener handles — up to 4 duplicate reloads per focus. Drop the
  direct call and coalesce the focus/visibility pair.

TabHome + TabApps:
- window.open(externalAppUrl, '_blank') lacked noopener, exposing the
  dashboard tab to reverse tabnabbing from an external app site. Add
  noopener,noreferrer.

Verified live: Usage and Home render without NaN or console errors, the
upgrade button is hidden on self-hosted, and 'Your Plan' now navigates.

* fix: multiple Dashboard CSS defects (dead rules, contrast, responsive)

- The desktop rule that hides the row ⋮ button ended two selectors with a
  comma before an @media block, so the whole construct was invalid and
  discarded — the ⋮ showed on every list-view row on desktop instead of
  deferring to right-click. Verified: it's now hidden (display:none).
- Native-drop dark-mode styles keyed off .window[data-color-scheme="dark"],
  an attribute never set anywhere; converted to @media (prefers-color-scheme:
  dark) like every other dark rule so they actually apply.
- .myapps-tile-label set white-space:nowrap after a 2-line -webkit-line-clamp,
  defeating the clamp so long app names clipped mid-glyph on one line;
  removed it.
- .dashboard-sidebar-separator was defined a second time with a contradicting
  box model (background line + full-width margin), rendering a doubled,
  edge-to-edge divider; removed the duplicate so the inset border rule stands.
- Grid-view .item-icon had a hardcoded background:white and border-radius:2px
  overriding its own border-radius:8px (glaring white tiles in dark mode,
  square-ish corners); use var(--dashboard-background) and keep 8px.
- .files-footer used hardcoded #666/#CCC on theme-variable backgrounds
  (dim in dark, near-invisible separator in light); use the text/muted vars.
- Fixed the .ui-droppable-over typo (jQuery UI emits ui-droppable-hover).
- The context-menu backdrop's "desktop transparent" rule used min-width:768px
  while every mobile rule uses max-width:768px, overlapping at exactly 768px
  (a common tablet width) — a non-dimming, invisible modal shield; bumped to
  769px.
- At 481-768px the fixed hamburger toggle sat on top of the Files directories
  column's first folder because .dashboard-content.files padding wins over the
  media-query padding; add top clearance to the directories column there.

Verified light-mode desktop is unchanged (directories padding still 16px, ⋮
hidden, footer colors resolve).

* fix: only offer Uninstall for Dashboard apps where it actually sticks

Uninstall was suppressed via a hardcoded 8-name allowlist that had drifted
out of sync with the backend's ~26 recommended apps. For the ~18 unlisted
recommended apps (Calculator, Code, the games, …) the menu offered
Uninstall, revokeApp resolved, the tile vanished — then get-launch-apps
re-added it on the next load and it reappeared, so the uninstall silently
reverted.

Compute uninstallability from the actual lists: an app is uninstallable
only if it's in the user's installedApps AND not in the recommended list
(and not a protected core app). Recommended apps — installed or not —
resurrect on reload, so Uninstall is hidden for them. Verified live: a
synthetic installed-not-recommended app shows Uninstall; a recommended
app shows none.

* fix: guard Dashboard Apps loads against stale overwrites and drag/error clobbering

loadApps only checked for an in-progress drag before its await, and its
catch wiped whatever was on screen. Three concurrency issues followed:

- A slow, older load resolving after a newer one could overwrite the
  newer app list (and clobber a reorder the user saved while the stale
  fetch was in flight). Tag each load with an increasing id and skip
  applying one only when a strictly newer load has already applied —
  gating on "already applied" (not "latest started") so the first load to
  resolve still populates the list for the pager's ResizeObserver.
- A drag that began while a load was awaiting could have the grid rebuilt
  out from under it; re-check the drag after the await.
- A transient re-fetch error replaced a working grid with "Failed to load
  apps"; only show that placeholder when nothing has loaded yet.

Verified live: a slow older load no longer clobbers a newer one, and the
grid still renders normally on activation.

* fix: page through all installed apps in the Dashboard instead of capping at 100

The /installedApps endpoint clamps limit to 100 and paginates, but the
Apps tab fetched a single page — a user with more than 100 installed apps
silently lost the alphabetically-last ones from both the grid and search,
with no way to launch or uninstall them from the dashboard. Loop pages
until a short one comes back (the common <100-app case still makes a
single request). Verified: the request now carries &page=1 and stops
after one page for a small account; the loop pulls all pages otherwise.

* fix: make the mobile Dashboard context menu handle submenus, disabled items, and positioning

The touch context-menu modal (used whenever maxTouchPoints > 0) had three
defects:

- Items with a submenu and no onClick ("New", "Open With") rendered as
  plain buttons that did nothing and didn't even close the menu — the two
  submenu-bearing actions were simply unreachable on touch. Drill into the
  submenu on tap, with a Back row to return.
- disabled items were rendered as active buttons and executed their
  onClick, so a folder's disabled "Paste Into Folder"/"Publish as website"
  ran anyway. Render them inert (disabled attribute + dimmed class + a
  click-handler guard), mirroring the desktop UIContextMenu.
- Non-touch devices that still route here (touchscreen laptops) got the
  modal pinned at a hardcoded left:300px, far from the tapped item on a
  wide screen. Center it over the target and clamp to the viewport.

Verified live (with maxTouchPoints forced): "Open With" opens its submenu
and Back returns; disabled "Paste Into Folder" is inert; the modal is
positioned near the item.

* fix: repair Apps-tab regressions from the Dashboard bug-fix pass

- A non-array /installedApps response (an error payload) was read as
  end-of-pagination, silently rendering the grid without any installed
  apps; fail the load so the explicit error state shows instead.
- A loadApps result that resolved mid-drag was discarded with no retry,
  freezing the grid on stale data for the rest of the visit; stash it
  and apply it after the drag ends, reconciled against the drag's final
  order so it can't undo a reorder whose save is still in flight.
- Uninstall disappeared for recommended/recent apps, removing the only
  UI path that revokes their permissions; offer it again and instead set
  expectations in the confirm modal (the tile stays for apps that
  get-launch-apps re-adds).
- init and the initial-route onActivate both fired a full load on open;
  share the in-flight load instead of issuing a duplicate request trio.

* fix: repair Files/routing regressions from the Dashboard bug-fix pass

- The no-anchor shift-click fallback also fired when shift-clicking the
  current anchor, collapsing an existing multi-selection to one item,
  and it ignored Ctrl/Cmd; only run it when there is genuinely no
  anchor, and keep the existing selection when Ctrl/Cmd is held.
- Unknown hash values (a stale bookmark, an in-page anchor) were
  coerced to 'apps' and yanked the user off their current tab with a
  refetch and autofocus; ignore them outright, both at boot and on
  hashchange, while still keeping untrusted hashes out of selectors.
- The item.updated socket handler re-implemented the row-refresh from
  TabFiles and had already drifted (trashed items showed their raw UID
  instead of metadata.original_name); extract a shared row updater and
  delegate to it.

* fix: repair Usage-tab regressions from the Dashboard bug-fix pass

- Hiding the plan button whenever window.UIUpgradeAccount was missing
  at check time could hide it for the whole session on hosted
  deployments that attach the script after dashboard init; keep
  re-checking for a while before concluding the install is self-hosted.
- init and the initial-route onActivate both refreshed usage on a
  direct #usage open; share the in-flight refresh instead of issuing
  duplicate request pairs.

* fix: repair Dashboard CSS regressions from the bug-fix pass

- Repairing the malformed item-more rule put display:none into effect
  for the first time, removing the visible-and-used desktop row '⋯'
  button; drop the rule instead (it was never in effect on any release).
- The desktop context-menu backdrop breakpoint (min-width: 769px) left
  fractional viewport widths between 768px and 769px with the mobile
  dimming; use the exact complement of the mobile max-width: 768px
  rules instead.

* fix: make Dashboard app uninstall honest about recents and resilient mid-flight

- Recently-opened apps were classified as sticky-removable, but the
  recent list is built from app-open history that a revoke doesn't
  touch, so their tiles reappeared on the next load — the very bug the
  uninstall gating set out to fix. Treat recents like recommended.
- The confirm modal claimed every staying tile was 'provided by Puter',
  which is false for third-party recents; describe the actual reason.
- Uninstall now marks in-flight loads stale so a fetch that started
  before the revoke can't resurrect the removed tile.
- A pagination failure after the first page no longer discards the
  pages already fetched — one flaky request among N used to turn the
  whole grid into 'Failed to load apps'.

* fix: keep the Dashboard Usage tab stable through transient failures

- A failed refresh no longer wipes an already-rendered usage table;
  the unavailable note only shows when there is nothing on screen yet
  (mirrors the Apps grid's error handling).
- The plan-button check now re-checks indefinitely with backoff instead
  of giving up after ~10s, so an arbitrarily late UIUpgradeAccount
  attachment can't leave a subscriber without the button all session.

* fix: Dashboard Files footer sync and Home refresh resilience

- The shared row updater now refreshes the footer's item-count/total
  line, which is computed from the data-size attributes it just wrote;
  a remote overwrite no longer leaves the row and footer disagreeing.
- Size/Modified are only written when the update payload carries them,
  so a minimal event can't zero out correct values on screen.
- A failed readdir now resets the footer instead of keeping the
  previous directory's counts over an empty pane.
- Home's focus-refresh caps its wait on refresh_user_data, so a whoami
  that never settles can't leave the plan/usage cards stale forever.

* fix: contain partial Apps loads and keep the uninstall flag current

- A partial installed-apps list (a page beyond the first failed) now
  renders only when the grid is empty — it must never replace a
  complete grid already on screen — and saveOrder refuses to persist
  while the list is partial, since overwriting the saved order with a
  truncated one would drop the missing apps' positions for good.
- Opening an app from the grid now flips its tile's uninstallable flag:
  the open puts it in the server-side recent list, so a revoke from
  that moment on leaves the tile in place, and the modal must not act
  on the stale load-time snapshot.
- The in-flight-load invalidation moved into _invalidateInFlightLoads()
  so the seq protocol lives in one place.

* fix: Usage-tab error guard, dead spinner, and poll decay

- The transient-error guard now tracks 'a table has rendered' instead
  of data length, so an account with legitimately zero usage doesn't
  get its empty table replaced by the unavailable note on a flaky
  refresh.
- update_usage_details animated a spinner element that exists nowhere
  in this tab and held every refresh open for an artificial 1s minimum;
  both halves of the dead control are now gone.
- The plan-button recheck backs off to a 60s heartbeat instead of
  polling at 2s forever on installs where UIUpgradeAccount never
  appears.

* fix: guard minimal file updates; tighten selection and socket hot paths

- The shared row updater only writes the fields an update payload
  actually carries, so a minimal event can't blank a row's name/path —
  the same hazard the size/modified guards already covered.
- The all-rows lookup in the selection handler now happens only under
  Shift instead of on every click, and the single-select block shared
  by the no-anchor shift-click and drag-handle paths lives in one
  selectSingle helper.
- item.removed and item.moved narrow by data-uid (O(1) selector) before
  comparing paths instead of scanning every row per socket event.

* fix: replace partial-load and uninstall-flag patches with convergent designs

The previous round's containment patches each spawned new corner cases
(a persistently failing page froze all refreshes; the partial-load
saveOrder bail silently discarded reorders; the tile-open flag flip
lost races with in-flight loads, server recording, and popup blockers).
Replace both mechanisms:

- A partial installed-apps fetch (later page failed) now always applies,
  supplemented with the apps already known from the previous list — the
  grid and search can't shrink from one flaky request, refreshes never
  freeze, and an empty grid still gets the fetched pages.
- saveOrder carries over saved names missing from the current list
  instead of refusing to persist, so no app's saved position is ever
  dropped; stale names are harmless (reconcileAppOrder ignores them)
  and preserve the position of apps that return.
- The load-time uninstallable snapshot is no longer patched client-side
  on tile open. Uninstall now revokes, optimistically removes the tile
  when expected to stick, and refetches — the grid converges to
  server-side truth instead of guessing at recents state.

* fix: don't blank filenames on metadata-only updates; unify range selection

- A payload carrying metadata but no name resolved to an empty display
  name and blanked the row's visible filename — skip the write when the
  resolved name is empty.
- The shift-range branch now goes through the same applySelection
  helper as the no-anchor and drag-handle paths (it takes the row set),
  removing the last hand-rolled copy of the selection bookkeeping.

* fix: rank-preserving saved-order merge; keep the pager put after uninstall

- The saved-order carryover appended missing apps' names at the tail,
  permanently demoting their saved grid positions — the very thing it
  claimed to protect. Replace it with mergeSavedOrder in appOrder.js
  (beside its tested siblings, with unit tests): each missing name
  keeps its rank among surviving names, so a drag during a partial-load
  session neither drops hidden apps' positions nor teleports them
  behind the dragged tile.
- The post-uninstall convergence refetch rendered without preservePage,
  snapping the pager back to page 1 and replaying the load fade; thread
  render options through loadApps so the background sync keeps the
  user's page.

* fix: keep hidden apps' saved ranks through the mid-drag stash path

- _applyPendingLoad reconciled a stashed load against the visible-only
  on-screen order, tail-appending any app returning to the grid and
  contradicting the order the drag had just saved — the rank-demotion
  defect resurfacing through one more path. It now prefers
  _savedOrderNames, the canonical saved list that saveOrder keeps
  merged with hidden apps at their ranks, over the possibly pre-drag kv
  snapshot the stashed load fetched.
- The post-uninstall optimistic render now also skips the load fade;
  it was the one in-place rebuild still hiding the grid behind the
  opacity-0 icon gate.
- mergeSavedOrder's rank rescan collapsed to a single forward
  insertion pointer (verified output-identical; the 20 unit tests pin
  the behavior).

* fix: gate stale kv-order snapshots on the latest local save

A drag that started and ended while a load was in flight escaped the
stash path entirely: the load applied normally and replayed its
pre-drag kv snapshot, visibly reverting the just-saved reorder (and
permanently clobbering it after the next save). Conversely, the stash
path unconditionally preferred the local record, discarding a genuinely
fresher order fetched from another window when the drag committed
nothing.

Record the load-seq boundary at each saveOrder; a resolving load (both
apply paths, via _resolveOrderNames) replays its fetched kv order only
when it was issued after the latest local save — otherwise the
canonical in-memory saved list wins. Also rewrites the _applyPendingLoad
header, which described the pre-rank-merge implementation.

* Simplify app uninstall tile handling

Remove the uninstallability flag and modal warning logic, and treat uninstall as a local UI removal after permission revoke. This avoids an immediate reload that could re-add recommended/recent apps, while stale in-flight loads are still invalidated. Also simplifies app loading by dropping unused render options from `loadApps`/`_fetchAndRenderApps` and related merge comments.

* fix: match raw item paths in move_items cleanup and shortcut re-point

Item rows store data-path/data-shortcut_to_path unencoded, so the
html_encode()d attribute selectors missed names containing & < > " '
and the destination-row exclusion guard could remove a legitimate row
just created by a concurrent item.moved handler. Compare raw values
case-insensitively instead.

* fix: bound the Usage plan-button retry poll

The UIUpgradeAccount retry chain decayed to a 60s heartbeat but never
stopped, leaving self-hosted installs polling forever and pinning the
captured $el_window. Give up after ~30s of decaying retries; onActivate
re-checks on every return to the tab, so late script loads still get
the button.

* fix: drop the recent-opens list from the Apps tab grid

Recents are open history, not installs: they resurrected uninstalled
apps' tiles and showed merely-visited sites as if installed. The grid
is now recommended + installedApps; anything the user actually uses
still appears because opening an app grants it a permission. Recents
still power the Home tab.
2026-07-18 20:39:57 -07:00
jelveh 81c69f243b Align sign-up email button with OIDC styling 2026-07-18 09:35:13 -07:00
jelveh 6a3ecaff2a improve and unify OIDC button styling 2026-07-18 09:18:21 -07:00
jelveh 70f343d6fe Defer taskbar creation in fullpage mode 2026-07-17 14:38:06 -07:00
jelveh 172aa01e1c Trim file context menu actions 2026-07-17 14:23:52 -07:00
jelveh cba6ac8184 Clamp and normalize window progress values 2026-07-17 14:14:50 -07:00
jelveh 492f3d235a Add active label variant to context menu height rule 2026-07-17 13:54:12 -07:00
jelveh d9a01a3748 Add responsive item properties modal to Dashboard 2026-07-17 13:21:15 -07:00
Nariman Jelveh aab21ef91d don't record app open in iframe env 2026-07-17 11:42:48 -07:00
Daniel Salazar 2479e6a064 fix: FS permissions (#3404) 2026-07-17 11:01:34 -07:00
jelveh 7cba26bb39 Revert "fix(dashboard): make app drag-to-reorder work on touch (iOS)"
This reverts commit af0dc06f49.
2026-07-17 08:53:46 -07:00
jelveh af0dc06f49 fix(dashboard): make app drag-to-reorder work on touch (iOS)
On iOS/WebKit the pager's `touch-action: pan-x` made horizontal touchmove
non-cancelable, so the moment a long-press drag moved the finger the
browser started a native page-pan and fired pointercancel, aborting the
reorder before it visibly began. Preventing pointermove doesn't stop
native scrolling on iOS, and the reordering `touch-action: none` rule was
applied only after the drag began — too late, since touch-action is
latched at gesture start.

Drop the explicit pan-x (back to auto) so horizontal touchmove is
cancelable, and install a non-passive touchmove listener that
preventDefaults only once reordering is armed (post long-press). Before
arming it's a no-op, so a quick swipe still flips pages natively.
2026-07-17 08:47:58 -07:00
Nariman Jelveh 14900514b9 Dashboard: drag-to-reorder apps in the Apps tab (#3401)
* feat(dashboard): drag-to-reorder apps in the Apps tab

Let users arrange their My Apps tiles in any order via drag-and-drop.
The order is stored per-user in puter.kv (`dashboard_apps_order`) as a
list of app names and re-applied on load; apps installed since the order
was saved are appended, and uninstalled ones are dropped.

Interaction:
- Mouse/pen: drag past a small threshold to reorder; a plain click still
  opens the app.
- Touch: long-press to pick up a tile (a quick swipe still flips pages).
- Dragging to a scroller edge dwells, then flips to the adjacent page so
  tiles can move across pages; the floating drag image lives on <body> so
  the pager's overflow can't clip it, and displaced tiles reflow with a
  FLIP animation.
- Escape (or losing the window) cancels and reverts; reordering is
  disabled while a search filter is active; prefers-reduced-motion is
  honored.

Also fixes a latent pager bug where programmatic page changes (arrows,
dots, wheel) didn't update the active page indicator, because those
scrolls don't reliably emit 'scroll' events; goToPage now tracks the
page eagerly.

The order-reconciliation logic is extracted into a pure module
(appOrder.js) with unit tests.

* fix(dashboard): ignore stray pointers and no-op drags

- Filter drag pointermove/up/cancel by pointerId so a second finger can't
  hijack or prematurely end an in-progress reorder.
- Only persist a new order when the drop actually changed it, so an
  accidental long-press or drop-in-place doesn't freeze the default
  ordering into a custom one.

* fix(dashboard): address review of app drag-to-reorder

Correctness:
- Preserve the touch long-press → Uninstall path. A long-press now *arms*
  reordering instead of immediately grabbing the tile; moving begins the
  drag, while holding still lets the native context menu fire (which
  cancels the pending pickup). This restores the only touch route to
  Uninstall that the first cut had removed.
- Only swallow the post-drag click when the drop actually reordered
  something, so a small pointer drift or a drop-in-place still opens the
  app like a plain click.
- Guard the pre-start "intent" window: cancel a pending pickup before any
  re-render (ResizeObserver / loadApps), and bail out of _beginDrag if the
  pressed tile was detached, so a layout change mid-press can't reinsert a
  stale node and persist a corrupted order.

Cleanup:
- Reuse reconcileAppOrder for the drop path instead of a duplicate helper.
- Read the saved order in parallel with the app-list fetches.
- Cache the reduced-motion MediaQueryList instead of re-querying per move.
- Fold the touch-scroll cancel into _endDrag(false); drop _abortDragIntent.

* feat(dashboard): smoother, more tolerant app reordering

Make drag-to-reorder feel like the iOS home screen instead of snapping
and flickering:

- Slow the reflow to 320ms with a gentle spring easing.
- Hit-test against each tile's *resting* box, not its live
  getBoundingClientRect. Mid-animation a tile sits between slots, so
  testing its live box was dropping it under the pointer and swapping it
  straight back — the source of the back-and-forth jitter.
- Require the dragged icon's centre to be well inside a tile before it
  becomes the drop target (a deadzone around each tile), so hovering a
  boundary no longer thrashes.
- Probe with the dragged icon's centre rather than the fingertip, and
  batch the FLIP into a single reflow so large grids stay smooth.
2026-07-17 07:57:05 -07:00
Daniel Salazar 7faaef8cf0 tests: more tests for puter.js (#3402) 2026-07-16 22:35:15 -07:00
Nariman Jelveh c188257f85 Session Manager: compact redesign + responsive modal (no UIWindow) (#3400)
* redesign: compact, scannable Dashboard Session Manager

The session list rendered each entry as a bulky card with a 5-row
Created/Last active/Expires/IP/Client key-value table and a large filled-red
Revoke button, so only a couple of sessions fit on screen and destructive
actions dominated the layout.

Reworked it into an icon-led, compact list:
- One row per session: a device/kind icon tile (laptop / phone / globe by OS,
  bolt for workers, key for API tokens, the real app icon for app sessions),
  the title + badges, and two tight meta lines (client · IP, then a fainter
  last-active · created · expires with absolute-time tooltips).
- Destructive actions are calm: per-row Revoke and the rename pencil are quiet
  and reveal on hover (always shown on touch); "Revoke all other sessions" is a
  subtle ghost button instead of a filled block competing with search.
- Current session gets a green accent, tint, and pill, and has no revoke button.
- Toolbar search gains an inline icon + focus ring; added a session count line;
  restyled kind badges; nested child sessions get a proper tree line.

Styling uses the existing dashboard design tokens. All behavior is preserved:
search filtering, parent/child tree with expand/collapse, inline rename
(optimistic + rollback), per-session revoke (access-token vs session routing),
revoke-all, current-session guard, and focus/interval refresh.

Adds ui_session_count_one / ui_session_count_other to en.js (other locales
fall back to en).

* refactor: render Session Manager as a responsive modal, not a UIWindow

Replaces the draggable UIWindow shell with a self-contained DOM modal:
- Backdrop + centered card on desktop (max 680px / 90vh); full-screen sheet
  on phones (<=640px). Fade/scale in, respects prefers-reduced-motion.
- Adds a header bar (title + close), backdrop-click and Escape to close, and
  a scroll-contained body.
- Confirmations no longer use UIAlert (itself a UIWindow). Revoke, revoke-all,
  and the rename-error path now use in-modal confirm/alert sheets, so there is
  no cross-window z-index juggling — important because this can be opened from
  a stay_on_top window (UIWindowCopyToken).

Drops the UIWindow and UIAlert imports entirely. All behavior is preserved:
search, parent/child tree + expand/collapse, inline rename (optimistic +
rollback), per-session revoke (access-token vs session routing), revoke-all,
current-session guard, and the focus/60s-interval refresh (cleaned up on close).
2026-07-16 20:48:54 -07:00
Nariman Jelveh 4543a645f2 fix: verified Dashboard bugs (10 separate fixes) (#3399)
* fix: prevent crash rendering website files without a workers array in Dashboard Files

TabFiles.renderItem accessed `file.workers.length` unguarded when deciding
whether to show the website badge. Directory entries that have a published
website but no `workers` array (the common case) made this throw
`Cannot read properties of undefined (reading 'length')`, so the row failed
to render — the file silently disappeared from the list (swallowed by the
Promise.allSettled batch) or was never added on socket-driven updates.

The sibling `is_worker` derivation on the same entry already guards with
`file.workers?.length`, and the desktop reference (UIItem.js) normalizes
`workers` to an array and shows the badge via `!is_worker`. Reuse the safe
`is_worker` flag here instead of touching `file.workers.length` directly.

* fix: correct Dashboard usage that showed negative spend for users with credits

The Usage tab and Home usage card derived month-to-date spend as
`monthUsageAllowance - allowanceInfo.remaining`. But the metering service
computes `remaining = remainingAllowance + remainingPurchasedCredits`
(MeteringService.getAllowedUsage), so `remaining` exceeds the monthly
allowance whenever a user holds purchased credits — making the displayed
spend and percentage negative (e.g. "-$9.90 used of $0.25", "-3960%").

Use the actual reported spend (`res.usage.total`, already returned by the
same endpoint) and clamp the percentage/bar to 100%. For a free user with no
credits and no overage this yields the identical value as before.

* fix: make Dashboard Files Download button work for a single selection

In mobile select mode the floating action bar appears with one item selected
(minCountForActionBar is 1) and shows an enabled Download button, but the
click handler only acted when 2+ rows were selected — so downloading a single
file did nothing.

Gate on `> 0` instead. window.zipItems already normalizes a single item to an
array, so the one-file case works. On desktop the bar only appears at 2+
selections, so this changes nothing there.

* fix: auto-fit column width in Dashboard Files no longer scales with row count

Double-clicking a column resize handle is meant to fit the column to its
longest cell. The per-row loop did `maxWidth = Math.max(maxWidth + 10, textWidth)`,
which forces `maxWidth` to grow by at least 10px on every row regardless of
content — so a folder with N files produced a column ~10*N px wide and
persisted that runaway width to KV storage.

Compare against the running max only (`Math.max(maxWidth, textWidth)`);
`textWidth` already includes cell padding.

* fix: clear empty-directory placeholder when a file is added incrementally

When a directory is empty, renderDirectory appends an absolutely-positioned
"No files in this directory." placeholder. If a file then appears via an
incremental path (socket item.added/item.moved -> UIDashboardFileItem), the
new row was inserted but the placeholder was never removed, so the "No files"
text overlapped the real item. insertAtSortedPosition can't clear it because
the placeholder has no `.item.row` class.

Remove the placeholder in UIDashboardFileItem before inserting the row,
matching what the in-app New Folder / New File flows already do.

* fix: stop leaking a document keydown handler per uninstall-modal dismissal

showUninstallModal bound `keydown.uninstall-modal` on document but only
detached it on the Escape and Confirm paths. Dismissing via Cancel or a
backdrop click called `close()`, which just removed the overlay and left the
handler attached (closing over a now-detached overlay). Each open→cancel
cycle stacked another document-level listener for the page lifetime.

Move the `.off('keydown.uninstall-modal')` into `close()` so every dismissal
path cleans up, and drop the now-redundant explicit detaches.

* fix: resolve 2FA setup promise on every close so Dashboard toggle can't freeze

UIWindow2FASetup only resolved its promise from the Done button and, in
on_before_exit, when setup had NOT succeeded. Clicking "Enable" sets
setup_succeeded=true and advances to the recovery-codes screen without
resolving; if the user then closes that screen via the backdrop or Escape
instead of Done, the promise never settles.

The Dashboard Security tab is the only caller: it disables the 2FA toggle and
`await`s this promise, so the hang leaves the toggle permanently disabled and
stuck in the pending state for the rest of the session. Resolve with
setup_succeeded on any exit; resolve_promise is idempotent so a prior
Done-resolve is unaffected.

* fix: refresh Dashboard tabs when navigating via browser back/forward

The sidebar-click and initial-route handlers both call the target tab's
onActivate (its refresh hook), but handleRouteChange — wired to hashchange
and popstate — only swapped the active classes. Since tab switches use
history.pushState (which doesn't fire popstate/hashchange), the only way to
reach handleRouteChange is browser back/forward, and doing so left tabs like
Home/Apps showing stale data.

Look up the tab by id and call onActivate after switching, matching the
sidebar-click path.

* fix: refresh Home "Recently used" apps instead of freezing them for the session

loadRecentApps only hit /get-launch-apps when window.launch_apps.recent was
empty. Once populated (typically at page load), every subsequent init/onActivate
/focus just re-rendered the same cached array, so apps launched during the
session never showed up under "Recently used" until a hard reload.

Drop the empty-list guard so the list refetches on the same triggers that
already refresh the usage cards. The fetch can only make the list fresher.

* fix: sync window.user.otp when enabling 2FA from the Dashboard

The disable path clears window.user.otp (in UIWindowDisable2FA), but the
enable path updated only the card's DOM, never the in-memory flag. TabSecurity
html() reads user.otp to decide the toggle/badge state, so after enabling 2FA
a re-render of the Security tab from the cached window.user showed it as
disabled for an account that actually had 2FA on.

Set window.user.otp = enabling on success so both directions stay consistent.
2026-07-16 20:03:38 -07:00
jelveh 75d3b07e0f fix: phantom shortcut badge on files created via dashboard New menu
renderItem() showed the shortcut badge whenever file.is_shortcut !== 0.
Directory listings return is_shortcut as 0, but the puter.fs.upload()
result used to incrementally render newly-created items omits the field,
so undefined !== 0 was true and every new file got a phantom shortcut
badge. Normalize is_shortcut to 0/1 before using it.
2026-07-16 19:05:28 -07:00
Nariman JelvehandClaude Opus 4.8 9da32c73cb Restore Files tab in dashboard (#3397)
* Restore Files tab in dashboard

Reinstates the Files tab (TabFiles) that was removed in 973d046d, bringing
back the dashboard file browser: directory navigation, list/grid views,
sorting, drag-and-drop, context menus, upload, and trash support.

- Restore src/gui/src/UI/Dashboard/TabFiles.js (identical to pre-removal)
- Re-wire UIDashboard.js: tab registration, initial file path, item socket
  handlers, and route-based file navigation
- Re-add file-tab CSS in dashboard.css and the selection-area rule in style.css
- Re-add dashboard-mode item creation hooks in helpers.js move_items
- Re-add #files/<path> deep-link parsing in initgui.js parseDashboardRoute,
  while preserving Apps as the default dashboard tab

* Only remove stale rows when moving items

* Fix Dashboard Files tab bugs

- Escape filenames in renderItem to prevent stored XSS via HTML in file
  names (matches UIItem); raw name is still preserved for display/rename.
- Read clone data-id from the nested .row in drag drop handlers so
  multi-select drag moves all selected items, not just the grabbed one.
- Resolve sidebar drop target from the element's data-path so dragging
  onto Public/Home works (they aren't keyed in user.directories); fix the
  matching UID-vs-path comparison in the drag-out handler.
- Set data-is_worker/data-worker_url from is_worker itself instead of an
  always-true "!== undefined" check, so worker context-menu behavior only
  applies to actual workers.
- Give delete-confirmation buttons explicit values so permanent delete
  works in non-English locales.
- Refresh the view after a context-menu move-paste, mirroring Ctrl+V.
- Guard the item.updated socket handler against the client's own echo,
  matching the other handlers and UIDesktop.

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

* Prevent Files tab lockup when readdir fails

renderDirectory sets renderingDirectory=true then awaits puter.fs.readdir
with no error handling. readdir rejects on any backend error (permission,
deleted directory, network), leaving renderingDirectory stuck true so the
re-entry guard blocks all future navigation and the spinner never clears.
Catch the rejection and reset state.

* Isolate render failures so one bad entry can't lock the Files tab

renderItem parsed file.metadata with an unguarded JSON.parse, but metadata
is client-writable and stored verbatim, so '', undefined, or malformed
values throw (item_icon.js guards the identical call). Combined with
Promise.all, one bad entry rejected the whole render and left the tab
frozen. Guard the parse and use Promise.allSettled for per-item isolation.

* Clear selection when clicking empty area in Files tab

The empty-area click handler is an arrow function, so `this` was the
TabFiles object rather than the container element, making `e.target === this`
always false. Only clicks landing exactly on `.files` deselected. Use
e.currentTarget so clicks on the container background deselect as intended.

* Avoid stacking image-preview document click handlers

showImagePreview bound a click.imagepreview handler without removing a
prior one. It is re-invoked during image arrow-navigation while a preview
is open, so identical document handlers accumulated until close. Remove
any existing handler before binding.

* Sort live-inserted items by display name

insertAtSortedPosition compared the new item's raw file.name against
existing rows' data-name (the display name). For trashed items, name is
the UID while the real name is in metadata.original_name, so newly trashed
items landed in the wrong position in Trash until a full re-render. Compare
on the new row's data-name to match sortFiles and the existing rows.

* Use display name in in-place item update

UIDashboardFileItem's in-place update path set data-name and the shown
name from raw file.name, unlike renderItem which uses
metadata.original_name || file.name. An update to an already-visible
trashed item replaced its name with the UID and corrupted data-name
(which sort and type-to-search read). Compute the display name to match.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-16 18:57:19 -07:00
Daniel Salazar 102cef523f fix: app id whitelisting (#3398) 2026-07-16 18:03:34 -07:00
Daniel SalazarandClaude Fable 5 0e1be72f92 test: tests for puter.js (#3396)
* test: tests for puter.js

* fix: ship lockfile for coverage devDeps; tolerate missing base coverage

npm ci failed on CI because package.json gained the babel/istanbul
devDependencies without the matching package-lock.json update. Also make
the coverage workflow's base leg best-effort so a base ref that predates
the coverage script reports without the comparison column instead of
failing the run.

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

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-16 15:52:37 -07:00
Neal Shah ef1afb9cc1 add just app telementry (#3387) 2026-07-16 14:04:54 -04:00
Neal Shah 0462ddd6f5 add support for step-up sessions (#3395)
* add support for step-up sessions

* update step up session
2026-07-16 13:58:49 -04:00
Neal Shah 8d7e70cde4 add kimi k3 (#3394) 2026-07-16 12:47:44 -04:00
Daniel Salazar 52e481128f wip: puter js tests structure (#3393) 2026-07-15 19:09:35 -07:00
Daniel Salazar c237e2433f fix: clean apps on subdomain deletion (#3392) 2026-07-15 13:38:53 -07:00
tamilselvanp57 a4bb57bb79 Fix static hosting domain fallback for self-hosted custom domains (#3382) 2026-07-15 11:45:10 -07:00
Daniel Salazar 861cf5efb8 feat: email api wip (#3391) 2026-07-15 11:30:16 -07:00
Daniel Salazar cf34f9fea9 fix: sanitize get user response (#3390)
* fix: sanitize get user response

* fix: app creation in dev center
2026-07-15 02:02:06 -07:00
jelveh 92a6d30e26 Add calendar to recommended apps 2026-07-14 23:04:24 -07:00
Neal ShahandDaniel Salazar 23e8705b03 Implement workers without cloudflare for local dev testing (#3389)
* typeify subdomains wip

* initial (untested) logic for LocalWorkerService

* Make it work, add lifecycle expiry since workers are process heavy in current implementation

* fix type errors

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-07-15 01:01:42 -04:00
Daniel Salazar 3240a4670a fix: admin gates (#3386) 2026-07-14 12:58:04 -07:00
Daniel Salazar 3bf83d40e9 fix: ui signup order (#3385) 2026-07-13 18:47:11 -07:00
Neal Shah c3368f553e Add GUI button for custom OIDC (#3384) 2026-07-13 20:32:37 -04:00
velzie 27def94d8b feat: support cross-origin-isolated login (#3338) 2026-07-13 17:36:56 -04:00
Neal Shah 2c139929ac Check if user is suspended on WebDAV basic auth and others (#3369) 2026-07-13 02:46:28 -04:00
Neal Shah b957f797da add support for ndjson logging (#3381)
* add support for ndjson logging

* move install location
2026-07-13 02:43:56 -04:00
jelveh 393500d565 Add calculator to recommended apps
Include 'calculator' in RECOMMENDED_APP_NAMES within RecommendedAppsService.ts so the calculator app is included in the recommended apps list.
2026-07-11 23:04:39 -07:00
jelveh a108837e82 Revert "Add delayed centered spinner for apps tab"
This reverts commit 859e88753f.
2026-07-11 15:21:59 -07:00
jelveh 859e88753f Add delayed centered spinner for apps tab
Show a subtle centered loading spinner for the Apps dashboard when fetching apps if the request is slow. Implements a 1.5s delay before showing the spinner and guarantees a minimum 1.5s visible time to avoid blinking; the spinner is only shown when the container is empty (so background refreshes keep existing tiles). Adds a finish helper to clear the spinner timer and await the minimum visible time before rendering results or errors. Also adds CSS for the spinner element and its animations.
2026-07-11 15:16:04 -07:00
Daniel Salazar 5160b44c4b fix: missing OIDC error messages (#3377)
* fix: missing OIDC error messages

* fix: card verification user prefill out for subs
2026-07-11 08:18:13 -07:00
Daniel Salazar 17a9485fb2 Add spans to drivers and high frequency paths (#3376) 2026-07-10 23:15:09 -07:00
Daniel Salazar d3c44f3d6c fix: cache healthcheck in mem for each node (#3375) 2026-07-10 17:41:09 -07:00
Neal Shah d49ba5281d add support for ignoring specific issues in healthcheck (#3374)
* add support for ignoring specific issues in healthcheck

* add mark-degraded to healthcheck
2026-07-10 17:08:03 -04:00
jelveh c107c80bd8 Open dashboard links at #home anchor 2026-07-10 13:22:58 -07:00
Daniel Salazar cf90e775bf fingerprint: use device info for rate limit too (#3372) 2026-07-09 20:22:56 -07:00
Daniel Salazar 0a46096c41 fix: ai rate limits (#3371) 2026-07-09 16:35:03 -07:00
Daniel Salazar 289a44ae79 fix: OIDC state (#3370) 2026-07-09 14:47:34 -07:00
Neal Shah 827e9bc343 fix possible XSS entrypoints (#3368) 2026-07-09 17:19:55 -04:00
Daniel Salazar f2ffaeb823 fix: add phone errors into kv for debugging (#3367) 2026-07-09 12:25:17 -07:00
Neal Shah 6f594f4b60 add 5.6 models (#3366) 2026-07-09 14:46:57 -04:00
jelveh 326be042f1 Merge branch 'main' of https://github.com/HeyPuter/puter 2026-07-08 22:33:20 -07:00
jelveh 5b824fb745 Extract resolveTileDisplay and broaden app search
Move logic that determines a tile's displayed title and target link into resolveTileDisplay (use hostname/index_url for external apps) and have buildTileHtml consume it. Expose data-target-link on tiles. Update app search to match the displayed title, raw title, name, and uid so queries find website-shortcut tiles and external apps consistently (matching Home tab behavior).
2026-07-08 22:33:13 -07:00
Reynaldi Chernando cf6cb126b5 fix grok 4.5 release date (#3365) 2026-07-09 12:30:39 +07:00
Reynaldi ChernandoandCopilot Autofix powered by AI 302df1edd2 update user pays model docs (#3364)
* update user pays model docs

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-09 08:54:04 +07:00
jelveh e212509ed3 Avoid clipping scaled icons in horizontal scroller
Add a negative top margin and matching top padding to the horizontal scroller to prevent first-row icons from being clipped when they scale on hover. Overflow-x:auto previously caused vertical clipping of scaled icons; margin-top:-8px and padding-top:8px bleed the clip edge upward so scaled icons remain visible without changing scroll behavior.
2026-07-08 18:35:57 -07:00
Daniel Salazar d3e3755d6d chore: cleanup uneeded ai messages (#3363) 2026-07-08 17:55:49 -07:00
jelveh 948ce6e529 Merge branch 'main' of https://github.com/HeyPuter/puter 2026-07-08 17:51:38 -07:00
jelveh d117dc231e Implement paged apps launcher UI 2026-07-08 17:50:34 -07:00
Daniel Salazar eefa53e5bd fix: misc auth bug fixes (#3362) 2026-07-08 17:50:22 -07:00
Neal Shah db89fbfc52 add grok 4.5 (#3360) 2026-07-08 14:44:05 -04:00
Daniel Salazar 97ec5d115c fix: double counting purchased credit usage (#3359) 2026-07-07 21:43:09 -07:00
Daniel Salazar 722a6cf50f fix: phone retry limit messagin (#3358) 2026-07-07 21:05:41 -07:00
Neal Shah d039dcfee5 fix appTelemetry (#3357) 2026-07-07 20:10:13 -04:00
Daniel Salazar d09aa11f2e feat: add whatsapp support? (#3356) 2026-07-07 16:13:18 -07:00
jelveh 8a2264546d Always use hash for dashboard tabs
Previously the dashboard used a clean root URL for the 'apps' tab and hashes for other tabs. This change makes tab links and history entries consistently use a hash (e.g. #apps, #home) by always generating href as `#<id>` and pushing `#<section>` to history. Simplifies URL handling and ensures the Apps tab also reflects its hash in the address bar.
2026-07-07 15:16:59 -07:00
0801e1dc44 feat: add signup disable config and fix telemetry startup (#3319)
* feat: add signup disable config and fix telemetry startup

* fix: nits for config spread and 403 checks

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

---------

Co-authored-by: Daniel Salazar <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-07 13:52:29 -07:00
Daniel Salazar a04d5fa5e0 fix: package-lock (#3355) 2026-07-07 13:43:15 -07:00
e1280e8cc7 Add custom icon support for web links (#3325)
* feat: add custom web link icon support and context menu option

Introduce the ability to change and display custom icons for .weblink files. Added a "Change Icon" context menu item that invokes the changeWeblinkIcon helper, and updated the icon rendering logic to use getWeblinkIcon instead of the default link icon. This allows users to personalize web link appearance in the file manager.

* Fix security and robustness issues in web link custom icons

 # UIWindowSearch and require the icon data URL body to be pure base64 in
  #isValidWeblinkIcon. A shared/downloaded .weblink is untrusted input and
 # the previous prefix-only check let a crafted icon break out of the
 # <img src> attribute.
 # byte-signature MIME sniffing and capping stored icon size (was storing
 # full-resolution images as base64).
 # without picking a file.
 # weblinks no longer fetches every file's contents on each render.
 # path instead of the target weblink).
 # label, add the AGPL license header, and drop redundant stored icon
 # copies.

* Keep SVG weblink icons as vectors instead of rasterizing

Rasterizing a picked SVG to a 256px PNG threw away its scalability for no
real benefit: an SVG rendered in <img> runs in script-free static mode, and
the base64-body validation already prevents attribute injection. Store SVGs
as data:image/svg+xml;base64 (content-sniffed, size-capped), and keep
rasterizing raster formats and oversized SVGs to bound stored size.

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

---------

Co-authored-by: Daniel Salazar <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-07 13:41:37 -07:00
Aman Rai 70884c2e30 fix: rename start-gui script to start:gui (#3346) 2026-07-07 12:09:49 -07:00
Reynaldi Chernando 917e95e3d7 Add nano banana 2 lite (#3336) 2026-07-07 12:08:36 -07:00
jelvehandClaude Opus 4.8 27222bbb0d Let ctrl/cmd+f use browser find while dashboard is open
The global ctrl/cmd+f shortcut opened UIWindowSearch and preventDefault'd
the keystroke, blocking the browser's native find. Skip that interception
while a dashboard window is open so ctrl/cmd+f falls through to the browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:43:34 -07:00
jelveh 3720034464 Make Apps the default dashboard tab
Treat the Apps tab as the default (root URL) instead of Home. UIDashboard now reads an initial route from window.dashboard_initial_route?.tab and falls back to 'apps' for unknown/absent routes. Sidebar and section active class logic and tab href/hash generation were updated so 'apps' uses the clean root URL while all other tabs (including home) use #tab. The history.pushState logic was adjusted accordingly. parseDashboardRoute in initgui.js was updated to default to 'apps' and its comment clarified the new behavior.
2026-07-07 09:31:43 -07:00
jelveh de6cab1f9a Reduce myapps tile icon size to 56px
Update .myapps-tile-icon in src/gui/src/css/dashboard.css to change width and height from 60px to 56px, adjusting tile icon sizing for improved layout and spacing.
2026-07-06 21:04:32 -07:00
jelveh 2d1fbc2611 Auto-focus My Apps search on activation
Call focusSearch when the Apps dashboard is activated and add a focusSearch helper. The helper imports isTouchPrimaryDevice and skips focusing on touch-primary devices to avoid showing the on-screen keyboard. It retries up to 12 times (30ms interval) until the search input is visible to handle cases where the dashboard is briefly hidden while entering full-page mode.
2026-07-06 20:10:44 -07:00
jelveh 0afc70218f Use input[type=search] and normalize styling 2026-07-06 20:03:48 -07:00
jelveh 8d3abbc5f0 Increase myapps tile icon size to 60px
Update .myapps-tile-icon width and height from 52px to 60px to improve icon visibility and better match the updated UI design. Other styles (overflow, background, transitions) are left unchanged.
2026-07-06 19:47:17 -07:00
jelveh 7ec14bc7db Add audio-editor and ai-image-project to recommended apps 2026-07-06 19:42:20 -07:00
jelveh 2843010a97 Increase myapps tile size to 100px 2026-07-06 19:37:46 -07:00
jelveh ad4e21ee61 Open external apps via index_url from Apps tab
When an app is external and provides an index_url, use its hostname as the tile title and store the index_url in a new data-target-link attribute. Update the click handler to open the target link in a new tab for external apps, and fall back to opening the internal /app/{name} page for others. This aligns Apps tab behavior with the Home tab and ensures external apps navigate to their website.
2026-07-06 19:21:34 -07:00
Nariman Jelveh 4de39ab6f2 Mark external apps and use the flag for dashboard titles (#3354)
* Mark external apps and use the flag for dashboard titles

An app with no owner_user_id (null/empty) isn't owned by a Puter user —
it's an external, origin-bootstrapped app whose uuid/name/title are all the
opaque app-… id. The dashboard used to detect these client-side by comparing
uuid/name/title and a name.startsWith('app-') check.

Expose an authoritative `external` flag from the API instead:

- /installedApps and /get-launch-apps now return `external`, derived from
  owner_user_id, and no longer leak the raw owner id.
- The dashboard (Home + Apps tabs) shows the index_url hostname for external
  apps based on `external` rather than the uuid/name/title heuristic.

Add/extend extension tests to cover the `external` flag and non-leak.

* Use hostname for opaque external app titles

Detect opaque external app IDs (when name === title === uid/uuid) and replace the displayed title with the hostname from index_url. Adds a uid/uuid fallback and tightens the external-app check in both the Apps and Home dashboard tabs so they behave consistently; preserves target_link for Home entries.

* Add external flag for apps without owner

Expose an `external` property in AppSummary (toAppSummary in RecommendedAppsService.ts) to mark apps that are not owned by a Puter user. The flag is set when `owner_user_id` is null or an empty string, allowing clients to identify origin-bootstrapped/external apps.
2026-07-06 18:00:56 -07:00
Nariman Jelveh fbb646b8eb Return index_url from /installedApps for dashboard app titles (#3352)
The dashboard derives a hostname title for anonymous (app-…) apps whose
uuid/name/title are all identical, falling back to the host of index_url.
That worked for launch/recent apps but not for apps that come only from
/installedApps, because the endpoint didn't return index_url.

Add apps.index_url to the SELECT (alongside apps.icon, relying on the
functional dependency on the apps.id primary key) and assert it in the
extension test.
2026-07-06 16:46:31 -07:00
Neal Shah 46f75ecd6f add type for puter.app.authenticated (#3353) 2026-07-06 18:38:30 -04:00
Daniel Salazar f68caa3bbd feat: allow optional card fallback for failed phone verifications (#3351) 2026-07-06 14:59:55 -07:00
jelveh 0109bf5b72 Use index_url hostname for anonymous app title
Detect anonymous apps whose name/title/uid are identical opaque ids (starting with 'app-') and, when index_url is available, replace the displayed title with the index_url hostname (matching the Home tab behavior). Also persist index_url into the mapped app object and make title a mutable variable to support the change.
2026-07-06 14:43:39 -07:00
Nariman Jelveh c69fb52d5c Make the dashboard the default interface at root (#3345)
* feat: make the dashboard the default interface at root

The dashboard now loads at / (with /dashboard kept as an alias), and the
desktop moves to a new /desktop route. An "Open Desktop" button pinned at
the bottom of the dashboard sidebar switches to the desktop.

Root URLs that carry a desktop-only flow keep booting the desktop:
auth popups (?embedded_in_popup=), app deep links (?app=), direct
downloads (?download=), fullpage mode (?puter.fullpage=), and iframe
embeds.

Desktop-context redirects are retargeted so desktop users stay on the
desktop: logout, the QR "continue on phone" link, the auth cover-page
logo click, and window close/hide URL resets now use /desktop. OIDC
login/signup gains a strictly whitelisted return_to param (/desktop or
/dashboard) so social login returns to the interface it started from.

Also: the dashboard strips ?ref= like the desktop does, ?auth_token=
cleanup preserves the path and tab hash, the logged-out session list
gets the wallpaper back, ?download= also works at /desktop, and the GUI
dev server serves /desktop and /dashboard.

* Add Open Desktop bento card and CSS

Replace the previous desktop-switch UI with a full-width "Open Desktop" bento card that links to /desktop. Removes the pinned sidebar Open Desktop button and the old desktop-switch button/handler, and adds new CSS variables and styles for the desktop card (icon background, shadow, surface/header colors), hover shadow and arrow transition, and layout/padding adjustments.

Changed files: TabHome.js (removed old switch, added anchor card), UIDashboard.js (removed sidebar button), dashboard.css (new desktop color vars and styles; removed legacy desktop-switch/button styles).

* Remove box-shadow from myapps search input

Delete the box-shadow declarations from input.myapps-search::placeholder in dashboard.css. This simplifies the placeholder styling (removes subtle and light shadow layers), likely to reduce visual noise or fix rendering inconsistency.

* Show index_url hostname for opaque app IDs

Only replace an app's title with the hostname from index_url when the app appears to use an opaque id. Add a check for names starting with 'app-' so the hostname is used only if title, name and uuid are identical and the name looks like an opaque app id. This avoids overwriting meaningful user-facing names that may coincidentally match the uuid and clarifies the intent in the comment.

* Update dashboard.css
2026-07-06 13:22:27 -07:00
Neal Shah 3a26f1f1c2 add admin moderation events table (#3348) 2026-07-06 11:49:09 -04:00
Daniel Salazar f1b94a5b69 fix: otel upgrade bump (#3328) 2026-07-06 08:42:25 -07:00
Neal Shah e3fce9191a emit puter.app.authenticated event (#3337) 2026-07-06 11:23:27 -04:00
Nariman Jelveh 2c0a1b9bbb Revert "feat: add sign-in flow for crossOriginIsolated contexts (#3253)" (#3335)
This reverts commit 6fff1fd75f.
2026-06-30 23:32:27 -07:00
jelveh 0d7417f858 Add 'contacts' to recommended apps
Include 'contacts' in the RECOMMENDED_APP_NAMES array so the Contacts app appears in the recommended apps list. Updated src/backend/services/apps/RecommendedAppsService.ts.
2026-06-30 19:08:11 -07:00
Neal Shah 2eee098c66 Add suspension reasons (#3331) 2026-06-30 18:09:00 -04:00
Neal Shah 890d4b3c16 remove temperature from sonnet-5 (#3330) 2026-06-30 17:18:29 -04:00
Neal Shah af9a7c6345 add claude sonnet 5 (#3329) 2026-06-30 17:08:42 -04:00
velzie 6fff1fd75f feat: add sign-in flow for crossOriginIsolated contexts (#3253)
* poc

* route login key through broadcastservice

* cleanup

* security validation and prevent additional webhook sends

* block double-broadcast from redis
2026-06-30 20:17:53 +01:00
velzie e6b7b08bda Add forceRelay option to Peer API and document raw peerconnection property (#3313)
* add forceRelay options

* document peerconnection
2026-06-30 12:28:49 -04:00
Daniel Salazar f4c3cbd53d feat: add logs for failed sms (#3318) 2026-06-30 06:50:47 -07:00
Reynaldi ChernandoandCopilot Autofix powered by AI c0c2f35266 Update docs model drift (#3326)
* Update docs model drift

* Potential fix for pull request finding

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

* standardize

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-06-30 16:55:42 +07:00
Nikhil Kumar SinghandDevblaze14 ccea980753 feat: add Ideogram 4.0 Together AI image model (#3309)
Co-authored-by: Devblaze14 <[email protected]>
2026-06-29 15:33:01 -04:00
Neal Shah 2bddebd576 add ability to suspend apps (#3322) 2026-06-29 13:06:08 -04:00
Neal Shah b9866ddbeb add suspended_at col (#3321) 2026-06-29 09:51:00 -04:00
Mochammad Fadhlan Al-Ghiffari 6fc79a7184 fix(docs): highlight code blocks after client-side navigation (#3287) (#3288)
* fix(docs): highlight all code blocks after client-side navigation

The docs site highlights code on full page load via an inline
hljs.highlightAll(), which targets the 'pre code' selector and so
highlights every fenced block, including those without a language tag.

The client-side navigation path (pathchange handler) instead selected
only code[class^="language"], so fenced blocks written without a
language hint were left unhighlighted after in-app navigation while
appearing correctly on a hard reload.

Align the pathchange handler with highlightAll() by selecting 'pre code'
and skipping blocks hljs has already processed (data-highlighted=yes),
keeping the pass idempotent across repeated pathchange events.

Fixes #3287

* fix(docs): hoist hljs.configure and scope highlight to docs content

Address review nits on the pathchange highlight pass:
- Move hljs.configure({ ignoreUnescapedHTML: true }) out of the per-element
  loop since it is global config and only needs to run once.
- Scope the selector to '.docs-content pre code' so the re-highlight pass
  only touches the swapped-in docs body and leaves code blocks elsewhere
  on the page untouched.
2026-06-28 23:27:07 +07:00
Nariman Jelveh 50039571b6 Reorder recommended apps and add vault
Adjust RECOMMENDED_APP_NAMES ordering: place 'editor', 'camera', and 'recorder' immediately after 'builder' and move 'app-center' accordingly. Also add 'vault' to the recommended apps list. This changes the resolved ordering of recommended apps at call time.
2026-06-27 17:55:52 -07:00
Daniel Salazar 1216f20567 fix: package-lock issue (#3316) 2026-06-27 14:33:11 -07:00
Daniel Salazar 0752ea761c feat: remove stych, add prelude client (#3312) 2026-06-27 14:07:52 -07:00
Daniel Salazar 98461088f8 fix: decrease puts for unused stamps (#3310) 2026-06-26 12:33:04 -07:00
Neal Shah d4a3224aa3 force locale en-US (#3311) 2026-06-26 15:31:58 -04:00
Daniel Salazar 197ce6faff fix: LegacyFSController (#3308) 2026-06-25 14:47:29 -07:00
MRB Labs c98e293879 fix: unlink OIDC providers when user updates email address (#3307) 2026-06-25 14:46:41 -07:00
Miika Kuisma ebbe382596 tweak context menu's text labels (#3306) 2026-06-25 09:46:05 -07:00
Neal Shah 01d28eb2c5 Await phone verification event (#3305) 2026-06-24 20:08:30 -04:00
Daniel Salazar 7d1c2e7f0a fix: fsService copy (#3304) 2026-06-24 16:32:34 -07:00
Daniel Salazar 22b499af20 fix: cleanup fs responses (#3303) 2026-06-24 14:35:58 -07:00
Neal Shah 7208f86723 bind btoa and atob in emulated worker globalspace (#3302)
* bind btoa and atob in emulated worker globalspace

* Create working directory for workers
2026-06-24 15:56:26 -04:00
Neal Shah 2974a8b01c store app owner ID even when worker is deployed from root ctx (#3301) 2026-06-24 15:21:48 -04:00
Daniel Salazar 7f05a707bd feat: improve kv store incr/decr costs (#3300) 2026-06-24 11:57:36 -07:00
Miika Kuismaandjelveh 1d056253ef contextmenu design tweak (#3298)
* contextmenu design tweak

* icon gap fix (mobile context menu web component)

* fix submenu item padding

* add dark theme support for puter.ui.contextMenu and puter.ui.setMenubar (works only with puter.env === 'web')

* Add theme test & refactor context menu items

Extract shared mediaMenuItems to remove duplication and simplify context menu calls. Add a light-theme context menu handler and a "Run theme test" button that executes runContextMenuThemeTest(), which programmatically renders menus to verify the theme attribute and .puter-theme-dark class for dark, light, and default cases (mirrors existing Playwright assertions). Update testContextMenu and testContextMenuDark to use the new item factory and clean up rendered menus after checks. UI text is updated to show pass/fail results.

---------

Co-authored-by: jelveh <[email protected]>
2026-06-24 11:30:16 -07:00
Neal Shah a22dc5facb fix whoami user token check (#3299) 2026-06-24 14:28:02 -04:00
Neal Shah aeafd4ac79 txt2img megaupdate (#3294)
* Update OpenAIImageProvider with txt2img with input source

* Grok image provider mega update

* make input_images universal
2026-06-24 10:24:09 -04:00
Daniel Salazar 14861df6b2 fix: bad boot error (#3295) 2026-06-24 00:33:38 -07:00
Daniel Salazar 34a7595a6d fix: bad empty file error handling (#3293) 2026-06-23 16:13:41 -07:00
Daniel Salazar b2108c0216 fix: update email for supprot (#3292) 2026-06-23 15:43:12 -07:00
Philip Ifeanyi 5a5a449210 feat: enhance playground UI with page title and style adjustments (#3289) 2026-06-23 22:45:18 +07:00
Daniel Salazar 0405de30bd update taskbar apps (#3286) 2026-06-22 14:26:35 -07:00
Nariman Jelveh 40c2799b06 Add 'builder' to recommended apps list
Include 'builder' in the RECOMMENDED_APP_NAMES array in src/backend/services/apps/RecommendedAppsService.ts so the RecommendedAppsService treats the builder app as a recommended application and surfaces it accordingly.
2026-06-22 14:22:27 -07:00
Daniel Salazar 4bcdeefe68 chore: bump up deps (#3285) 2026-06-22 13:58:45 -07:00
Daniel Salazar b6ba6dd90e fix: [PUT-1199] more prelude signals (#3282) 2026-06-22 08:16:59 -07:00
Daniel Salazar 954f8b7b8d fix: puter self host guides + default bug (#3281)
* fix: oss dev center html_encode hardening

* fix: puter self host guides + default bug
2026-06-21 22:32:43 -07:00
Neal Shah 24d700673c Compaction support for OpenAI and Anthropic (#3279)
* Alpha: compaction support for OpenAI and Anthropic

* update lock

* fix billing for anthropic compactions

* Fix max_tokens bug in together provider

* Fix responses compaction
2026-06-21 21:57:56 -04:00
c6d64029e0 feat: meter Gemini thinking tokens and grounding requests (#3178)
* feat: meter Gemini thinking tokens and grounding requests

- Thinking tokens: Extracted from standard completion tokens to ensure they are billed accurately at the correct model-specific rate.
- Grounding requests: Added flat-fee metering for Google Search by tracking grounding_metadata across both streaming and non-streaming responses.
- Pricing updates: Corrected stale rates for Gemini 2.5 Flash output, cached tokens, thinking tokens, and grounding requests.

* fix: correct Gemini 2.x metering rates and harden grounding capture

Pricing corrections (verified against ai.google.dev/gemini-api/docs/pricing):
- gemini-2.5-flash output is $2.50/M, not $1.00/M: restore
  completion_tokens to 250 and bill thinking_tokens at the same output
  rate (250). The previous 100 under-billed output ~60%, and this is the
  provider's default model.
- gemini-2.5-flash cache read is $0.03/M: restore cached_tokens to 3
  (the 7.5 value over-billed).
- Grounding with Google Search is $35 / 1,000 requests for Gemini 2.x
  models and $14 / 1,000 for 3.x. Set grounding_requests to 3_500_000
  for gemini-2.0-flash, gemini-2.5-flash, gemini-2.5-flash-lite and
  gemini-2.5-pro; 3.x models keep 1_400_000.

Streaming robustness:
- In create_chat_stream_handler, don't let a later extra_content chunk
  without grounding_metadata overwrite an earlier one that carried it,
  so grounding requests are still metered.

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

---------

Co-authored-by: Daniel Salazar <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-20 15:08:38 -07:00
jelveh 40ff866598 Add 'butler' to recommended apps
Include 'butler' in the RECOMMENDED_APP_NAMES array in RecommendedAppsService so it appears in the recommended apps list. Updates src/backend/services/apps/RecommendedAppsService.ts.
2026-06-19 15:50:57 -07:00
Nariman Jelveh 36888e35d3 Add phone verification UI improvements and helpers (#3274)
Revamps the phone verification dialog and adds reusable phone helper modules.

- Heavily refactors UIWindowPhoneVerificationRequired.js to add a searchable inline country picker, national-number live formatting, country autodetect for "+" input, WebOTP auto-fill support, resend cooldown timer, accessibility improvements, proper teardown/cleanup, and last-used country persistence.
- Introduces helpers/src/helpers/country_codes.js (get_country_list, flag emoji) which builds a localized list of dialable countries from libphonenumber-js and Intl.DisplayNames.
- Introduces helpers/src/helpers/phone.js with thin wrappers around libphonenumber-js: format_phone_as_you_type, inspect_phone, phone_example_for, detect_country_from_input.
- Adds English i18n strings for the new UI in src/gui/src/i18n/translations/en.js.

These changes keep submission canonicalization using libphonenumber-js (E.164), improve UX for selecting/formatting numbers, and make the country list and phone utilities reusable across the app.
2026-06-19 10:28:30 -07:00
Reynaldi Chernando 30432b931a Add PUT and DELETE to preflight allowed methods (#3278) 2026-06-19 12:35:29 -04:00
Reynaldi Chernando e2174a14cf Update router cors preflight docs (#3277) 2026-06-19 20:50:41 +07:00
Daniel Salazar d3cb89d8c9 fix: import in extensions (#3276) 2026-06-18 21:30:00 -07:00
Reynaldi Chernando c8625b3396 Release puterjs npm (#3275)
* Release puterjs npm

* add package lock
2026-06-18 18:26:39 -07:00
Reynaldi Chernando b576f01d12 Sync Puter.js types and docs (#3194)
* AI chat types

* ai voice types

* update tts docs and jsdoc

* stt types and docs

* txt2img docs and types

* txt2vid and img2txt

* apps

* signin

* perms

* fs

* kv

* hosting

* workers

* net

* ui

* jsdoc
2026-06-18 09:34:13 -07:00
Vyasdev a87cafee3c Revise bug report and feature request instructions (#3220)
Updated the wording for reporting bugs and feature requests in Japanese.
2026-06-18 09:27:55 -07:00
Reynaldi Chernando 1c51fde163 Add minimax m3 direct integration + cleanup model list (#3263) 2026-06-18 10:12:44 -04:00
Daniel Salazar b05699db77 fix: AGENTS.md cleanup (#3273) 2026-06-17 17:07:20 -07:00
Daniel Salazar 163e48ab27 feat: viz over user verification types (#3271) 2026-06-17 14:21:55 -07:00
Reynaldi Chernando 0b41d992a4 Add glm 5.2 direct integration (#3269) 2026-06-17 09:40:10 -04:00
Miika Kuisma c2ddfd6867 Merge pull request #3270 from HeyPuter/fix-file-dialog
puter.js - fix issue where content of file dialog wasn't visible
2026-06-17 13:08:43 +03:00
Miika Kuisma 81b9dc1f06 fix issue where content of file dialog wasn't visible 2026-06-17 13:07:13 +03:00
Daniel Salazar 5506ddb2c2 feat: fp cleanup and better devex (#3268) 2026-06-16 22:48:54 -07:00
Neal Shah c74469b01a prelude RCS (#3267) 2026-06-15 19:08:30 -04:00
Daniel Salazar b706693f82 wip: hardening (#3266) 2026-06-15 16:07:44 -07:00
Daniel SalazarandNeal Shah cd800148d5 feat: optional additional verification gates (#3262)
* phone number verificiation initial

* finish phone verification

* feat: add card gate for signups

* chore: npm

* fix: modal order and wording

* fix:wording

---------

Co-authored-by: Neal Shah <[email protected]>
2026-06-15 09:16:00 -07:00
Ricardo Sawir 900b0d7aa6 fix: correct puter container healthcheck URL (#3255)
The healthcheck was using 'puter.localhost:4100/test' which fails for
two reasons:
- 'puter.localhost' does not resolve inside the container; the service
  listens on all interfaces at port 4100, so 'localhost' works
- '/test' endpoint does not exist; use '/' which serves the shell

This caused the container to be perpetually marked as unhealthy.

Fixes #3191
2026-06-13 23:17:44 -07:00
Daniel Salazar 7e21c1f888 fix: fs metadata sanitation (#3257) 2026-06-12 22:56:06 -07:00
Daniel Salazar 00d6f9c230 fix: disallow unverified outlook emails (#3256) 2026-06-12 18:52:24 -07:00
jelveh 4c03080efa Update mcp.md 2026-06-12 16:55:37 +02:00
jelveh 432a11d289 Add Cursor MCP docs and icons 2026-06-12 16:37:53 +02:00
jelveh e9e233404b Add icons for Claude, Codex and OpenCode 2026-06-12 13:20:15 +02:00
jelveh 8101d42604 Capitalize OpenCode in MCP docs 2026-06-12 13:16:41 +02:00
jelveh 88cedd43bc Add opencode MCP example to docs 2026-06-12 13:11:24 +02:00
Daniel Salazar d3a2934d9b feat: fingerprinting for signup checks (#3254) 2026-06-12 00:59:21 -07:00
Daniel Salazar 13a36b6199 fix: secure http calls (#3252) 2026-06-11 13:43:58 -07:00
Daniel Salazar a155464d46 fix: more data for puter site serve event (#3251) 2026-06-10 23:27:13 -07:00
Daniel Salazar 40d1c998bb feat: pass args to all events (#3248)
* feat: pass args to all events

* fix: alias app joining

* fix: actor in event
2026-06-10 14:13:04 -07:00
Daniel Salazar b4837d49b6 perf: some perf gains from fable (#3249) 2026-06-10 13:26:35 -07:00
ProgrammerIn-wonderland e23042b42e add preperation for reputation score persistence (#3250) 2026-06-10 16:09:42 -04:00
Daniel Salazar 240a733285 sec: misc fable hardening (#3244)
* sec: misc fable hardening

* more fixes

* more fixes

* fix: cors issue
2026-06-10 11:19:41 -07:00
Reynaldi Chernando bb202be12c Minor clarification local webserver in deployment (#3247) 2026-06-10 23:04:42 +07:00
Reynaldi Chernando 8c4cb601c9 Add cli docs (#3246) 2026-06-10 20:53:53 +07:00
Reynaldi Chernando 64a7c7b7c4 Add mcp docs (#3245) 2026-06-10 20:31:31 +07:00
wwb1942 2dd2887ff7 ci: add docs build workflow (#3235)
* ci: add docs build workflow

Add CI workflow that runs npm ci and npm run build in src/docs

whenever files under src/docs change in a pull request.

Closes #3225

* ci: also trigger docs build when workflow file changes

* ci: address docs build workflow review
2026-06-10 10:53:40 +07:00
ProgrammerIn-wonderland 4bbe8a2789 add protections for PUT-1094 (#3243) 2026-06-09 22:29:57 -04:00
Dilan Melvin T 3e9ceb673b fix: coerce Mistral image_url parts from object to string (#3177)
* fix: coerce Mistral image_url parts from object to string

Mistral's API expects image_url content parts to be a plain string URL,
not the OpenAI-style { url: string } object that process_input_messages
produces. Without this coercion, sending an image to any Mistral model
that supports vision (mistral-small, mistral-medium, mistral-large,
ministral-*) results in a request error from the Mistral SDK.

Add #coerceImageUrls() as a private method on MistralAIProvider that
maps { type: 'image_url', image_url: { url } } -> { type: 'image_url',
image_url: url } for every content part in every message. Messages with
plain string content are left untouched, as are parts whose image_url is
already a string.

Add four unit tests covering: object-to-string coercion, already-flat
strings, plain string message content, and mixed text+image content.

* style: use plain ASCII dashes in comment section divider
2026-06-09 20:12:44 -04:00
Daniel Salazar 9c4d1ef535 fix: misc bugs + new middleware for before/after routes (#3242) 2026-06-09 17:05:56 -07:00
ProgrammerIn-wonderland bf57518262 MCP: assert worker_exec ends with puter.work (#3241) 2026-06-09 17:43:34 -04:00
Reynaldi Chernando d48fb11de6 Add claude fable 5 (#3238) 2026-06-09 14:05:31 -04:00
ProgrammerIn-wonderland 7ed87f760b make max tokens less lenient (#3240) 2026-06-09 14:03:13 -04:00
Reynaldi Chernando ee37be16f0 Add cli deployment docs (#3239) 2026-06-10 00:40:19 +07:00
Reynaldi Chernando a8cfaeefbd Add subdomain and workers github actions deployment (#3226)
* Add subdomain and workers github actions deployment

* minor adjust
2026-06-09 23:35:34 +07:00
Badri467 58cb8ee048 skip github actions backend for docs only changes (#3236) 2026-06-09 23:28:48 +07:00
Daniel Salazar a72fbd1d47 fix: inform acl of full access flag (#3237) 2026-06-09 08:04:36 -07:00
Daniel Salazar f6408db74e fix: access tokens with full accesses allowed through with new middleware gate (#3234) 2026-06-08 18:47:07 -07:00
ProgrammerIn-wonderland df20993d8b set azure openai as the default (#3233) 2026-06-08 20:33:41 -04:00
ProgrammerIn-wonderland 708fe1bc4e azure ai provider (#3232) 2026-06-08 18:31:49 -04:00
Daniel Salazar e782fa832e fix: kv costs (#3231) 2026-06-08 13:32:36 -07:00
ProgrammerIn-wonderland c947c2c989 fix mcp server cloudfront cache bug (#3230) 2026-06-08 15:42:49 -04:00
Daniel Salazar 3de4613b7d Revert "feat (PUT-1091): have gui tokens labeled and be different than reques…" (#3229)
This reverts commit 1335b46404.
2026-06-08 11:15:24 -07:00
Daniel Salazar def7290c48 feat: emit user.deleted event on user deletion (#3228) 2026-06-08 10:30:24 -07:00
Daniel Salazar 1335b46404 feat (PUT-1091): have gui tokens labeled and be different than requestor ones (#3222)
* feat (PUT-1091): have gui tokens labeled and be different than creator ones

* fix: errors with auth token
2026-06-08 09:03:02 -07:00
Reynaldi Chernando 0e15ac98a9 Update cli readme and set version (#3227) 2026-06-08 22:23:32 +07:00
Reynaldi ChernandoandCopilot Autofix powered by AI f127b2509d Add docs fixes for deployment and workers pages (#3223)
* Add docs fixes for deployment and workers pages

* Potential fix for pull request finding

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

* handle router edge case

* explicit info about wildcard requiring name

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-06-08 17:43:00 +07:00
Sourabh Sharma 15134c99bf Fix mkdir dedupeName for existing directories (#3215) 2026-06-07 12:49:00 -07:00
Daniel Salazar 8262e3193e perf: don't batch get app stats (#3219) 2026-06-06 18:28:59 -07:00
Reynaldi Chernando 98a21c4ac0 More workers docs improvement (#3218)
* More workers docs improvement

* Add mention of workers alongside mention of user scope
2026-06-06 01:27:24 +07:00
ProgrammerIn-wonderland c52eb1b903 Ns/bug fixes 060426 (#3214)
* Harden backend auth

* Puter.js auth hardening
2026-06-05 13:55:23 -04:00
mohit_parmar_007 d6a71e50bb fix: remove unnecessary cache console.log statements (#3216) 2026-06-05 10:01:59 -07:00
Reynaldi Chernando a4b6ee0dd2 [PUT-1090] Improve worker docs (#3217)
* Improve worker docs

* refine
2026-06-05 21:54:56 +07:00
Daniel Salazar bb61103080 feat: add apps api into mcp (#3213) 2026-06-04 17:19:53 -07:00
Daniel Salazar 9bbc76b2dd drop metering alerts to low severity (#3212) 2026-06-04 14:01:44 -07:00
Daniel Salazar 918aba9c80 fix: reduce alarms further (#3211)
* feat; update bug-bounty

* fix: reduce alarms further
2026-06-04 10:43:50 -07:00
Reynaldi ChernandoandCopilot Autofix powered by AI 3ceba9e3b3 Minor fix deployment docs (#3210)
* Minor fix deployment docs

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-06-04 23:48:25 +07:00
Daniel Salazar a480679a2d vis: make metering service alarm only go off on actual abuse (#3208) 2026-06-03 22:45:18 -07:00
Daniel Salazar e99ae40e93 chore: bump puter-js (#3207) 2026-06-03 18:59:07 -07:00
Daniel Salazar 3cd50fbaf3 move the token migration to root (#3206) 2026-06-03 18:54:22 -07:00
ProgrammerIn-wonderland 60dd1453d4 Fix issues (#3205) 2026-06-03 21:19:33 -04:00
ProgrammerIn-wonderland 07781cd681 allow overriding origin from workers.dev on mcp server (#3204) 2026-06-03 17:48:37 -04:00
ProgrammerIn-wonderland 2387d8dcc9 MCP whoami (#3203) 2026-06-03 17:13:13 -04:00
Reynaldi Chernando 723608f4db Add new puter cli (#3199)
* Add new cli

* update package lock

* fix readme and license
2026-06-04 01:51:31 +07:00
Reynaldi Chernando bd1dcde7b0 Add info for puterjs deployments (#3202)
* Add info for puterjs deployments

* update external links

* hide github actions method
2026-06-04 01:25:57 +07:00
ProgrammerIn-wonderland 5b8e41e66d MCP Server (#3197)
* beginnings of mcp

* oauth support

* add workers and better prompt engineering
2026-06-03 12:23:06 -04:00
Nariman Jelveh acc4796b99 Allow Cache-Control and Pragma headers (#3198) 2026-06-03 14:30:33 +02:00
jelveh 106e85d6ca Force revalidation on FileSystem reads 2026-06-03 14:15:22 +02:00
ProgrammerIn-wonderland 0ffc32f19e add whitelist for signed batch uploads (#3196) 2026-06-02 21:30:34 -04:00
Daniel Salazar d897cc5dd0 refactor: move subscriptions to webhook system (#3193)
* fix: dont sign write urls if they don't have write perm

* wip: new subscriptions

* refactor: move subscriptions to
2026-06-02 17:58:56 -07:00
Daniel Salazar 6d2f277ce2 fix: some error handling (#3190) 2026-05-31 18:58:27 -07:00
ProgrammerIn-wonderland 86f80b29c0 tighten RAO policy (#3188) 2026-05-29 20:52:21 -04:00
Daniel SalazarandProgrammerIn-wonderland 39243735e8 feat (put-1049): worker types (#3185)
* feat (put-1049): worket types

* Implement changes to worker types

---------

Co-authored-by: ProgrammerIn-wonderland <[email protected]>
2026-05-29 20:51:57 -04:00
ProgrammerIn-wonderland b3ff9d74b7 add browserjs to taskbar and RecommendedAppService (#3189) 2026-05-29 20:32:53 -04:00
ProgrammerIn-wonderland 7b7604440f Add serverless only filtering for togetherai chat models (#3187) 2026-05-29 14:33:55 -04:00
ProgrammerIn-wonderland 382d2b3c14 add minimax provider (#3171)
* add minimax provider
2026-05-29 13:36:10 -04:00
Anurag Pappula eda5379c17 fix(docs/search): handle search index loading and failure states (#3184)
* fix(docs/search): show loading state and replay query when index isn't ready

Previously performSearch rendered "No results found" while the
MiniSearch index was still loading, which is misleading on slow
networks. Track the last query as pendingQuery and replay it from
fetchSearchIndex once the index is ready; render a distinct
"Failed to load search index" message on fetch error so the
loading message doesn't persist indefinitely.

Fixes #3180.

* fix(docs/search): close stale-replay race and consolidate status messages

Drop pendingQuery synchronously in the input handler when the query
falls below the minimum length, so a fetchSearchIndex that resolves
within the 300ms debounce window after the user clears the field can
no longer replay the cleared query into an empty input.

Move the idle, loading, and failed copy into a single
renderSearchStatus(kind) helper so the four .search-no-results
renderings can only drift in one place.
2026-05-29 16:00:59 +07:00
Ron HernausandDaniel Salazar d2fee51844 Support PostgreSQL database backend (#3167)
* feat(database): add postgres database client

Adds the PostgreSQL database client, native bootstrap migration, SQL preparation helpers, and database config/factory wiring.\n\nRefs #3165.

* feat(database): make backend queries postgres-aware

Updates runtime SQL call sites for database-specific booleans, identifiers, insert-ignore, upserts, JSON extraction, intervals, and Postgres insert ids.\n\nRefs #3165.

* test(database): cover postgres client behavior

Adds unit coverage for SQL preparation, factory selection, write-result mapping, and transaction rollback/commit ordering, plus an env-gated PostgreSQL integration flow.\n\nRefs #3165.

* docs(self-hosting): document postgres database setup

Adds PostgreSQL configuration examples and migration path guidance for self-hosted deployments.\n\nRefs #3165.

* fix: harden postgres oidc tests

* fix(postgres): normalize query results and SQL prep

* fix(user): preserve normalized cache booleans

* test(postgres): run integration coverage with pgmock

* tests: add way to run all tests with postgres though slow

Also adding note that postgres is not in active use so might not work out the box

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-05-28 14:12:17 -07:00
Daniel Salazar 8ac4bad196 fix: readUrl perm check (#3183) 2026-05-28 11:44:16 -07:00
Reynaldi Chernando f140d7221c Add opus 4.8 (#3182)
* Add opus 4.8

* update alias

* handling opus 48 omit temperature
2026-05-28 13:27:46 -04:00
Daniel Salazar 631f0bda1b chore:deploy latest puter.js (#3181) 2026-05-28 09:58:33 -07:00
Reynaldi Chernando 1121314582 Docs search improvement (#3179)
* Init search improvement

* Add fuzzy implementation

* update package lock

* package lock matched with runner 24.16.0
2026-05-28 23:11:34 +07:00
Daniel Salazar dda6b82f0f fix: missing english i18n (#3176) 2026-05-27 16:31:40 -07:00
ProgrammerIn-wonderland 6c757c3fc8 Don't use passed in authorization in workers (#3175) 2026-05-27 18:09:59 -04:00
Daniel Salazar eba194936e tests: more tests for session and auth (#3174) 2026-05-27 12:32:15 -07:00
Daniel Salazar a7bdac16da tests: Add unit tests for XAISpeechToTextDriver (#3173)
closes #3002
2026-05-27 11:49:16 -07:00
Daniel Salazar 716a8d06e2 fix: session store kind column + duplicate error handling (#3172) 2026-05-27 11:44:08 -07:00
Daniel Salazar e95cf44fec fix: small fixes for perf and username checks (#3169) 2026-05-27 03:10:56 -07:00
Daniel Salazar 8f8efcb349 feat (PUT-1025 PUT-1026 and PUT-1017): better handling old token auth required in gui (#3166)
* feat (PUT-1025 PUT-1026 and PUT-1017): better handling old token forcing in gui

* fix: misc issues with token inval project
2026-05-27 02:26:35 -07:00
Daniel Salazar b188942436 feat (PUT-1016 & PUT-1020) (#3164)
* feat (PUT-1016 & PUT-1020)
temp account preservation on forced relogin
hosted asset cookies to v2 token too

* fix: remove llm dashes and ugly comments

* update agents
2026-05-26 23:35:16 -07:00
Daniel Salazar 9b7e9a98c8 fix: session revocation (#3163) 2026-05-26 22:13:12 -07:00
Daniel Salazar d6f7fb6155 chore: package-lock update (#3161) 2026-05-26 20:33:11 -07:00
Daniel Salazar bd91f5e192 feat: worker sessions get their own kind + per-(user, app, worker_name) row (#3160)
* feat: worker sessions get their own kind + per-(user, app, worker_name) row

Schema
------
- mysql_mig_11.sql + sqlite 0054: add idx_sessions_user_worker_active,
  a partial unique index over (user_id, app_uid, meta.worker_name) for
  kind='worker' rows. Active worker sessions are deduped by that triple
  so each named worker gets its own session row and they don't fight
  the existing idx_sessions_user_app_active (which still constrains
  kind='app' only). app_uid is allowed NULL for user-scoped workers
  with no app binding.

SessionStore
------------
- getOrCreateWorker(userId, { appUid, workerName, ... }): mirrors the
  getOrCreateApp pattern — cache lookup, partial-unique re-SELECT on
  insert-ignore, all keyed on the worker triple. expires_at lands at
  WORKER_WINDOW_SECONDS (~99y) so the worker doesn't have to re-mint
  on any cadence.
- #cacheKeyWorker + #allCacheKeysForRow worker branch so revoke /
  update invalidates the worker cache view alongside the by-uuid one.

AuthService
-----------
- createWorkerSessionToken(user, workerName, meta?) now takes the
  workerName explicitly and routes through getOrCreateWorker. Emits
  the same { session, token, gui_token } shape but both JWTs carry
  { worker: true, worker_name }.
- createWorkerAppToken(actor, appUid, workerName) likewise — JWT
  carries the worker_name claim so a verifier can tell two workers
  under the same app apart without a DB round-trip.
- Both methods 400 on empty workerName.

WorkerDriver
------------
- Five auth-mint call sites swapped over: app-bound deploy (3x:
  appId branch, actor.app fallback, hot-reload redeploy), user-bound
  fallback (2x: cold deploy, hot-reload). All pass `workerName` so
  the worker's session row is naturally idempotent across redeploys.

GUI manage-sessions
-------------------
- sessionTitle adds a kind='worker' branch ("name (app)" for
  app-scoped workers, just "name" for user-scoped), pulling worker_name
  from the meta-spread that listSessions already surfaces.
- en.js adds ui_session_kind_worker.

* fix(workers): MySQL JSON_EXTRACT quoting + revoke cache invalidation +
SQLite NULL-distinct in worker index

Three real bugs in the worker session plumbing from the prior commit,
plus a misleading comment. Schema design kept (worker_name lives in
`meta` rather than a dedicated column) per offline review:

1. `#selectWorkerRow` compared `JSON_EXTRACT(meta, '$.worker_name')`
   directly to a bind parameter. MySQL's `JSON_EXTRACT` returns a
   JSON-typed value with embedded quotes (`"name"`, not `name`), so
   the comparison never matched. After the first INSERT, every
   follow-up getOrCreateWorker call missed the existing row in the
   SELECT, hit INSERT-IGNORE, then missed again in the re-SELECT —
   the caller would receive whatever the INSERT-IGNORE returned (a
   no-op row in conflict cases). Wrap with `JSON_UNQUOTE` on MySQL
   via `db.case`; SQLite's `json_extract` already returns the
   unwrapped scalar so it keeps the literal form.

2. mig_11's generated `worker_unique_key` had the same JSON-quoting
   bug. Mirror the fix: `IFNULL(JSON_UNQUOTE(JSON_EXTRACT(...)), '')`
   so the concatenated unique key is a plain string that lines up
   with what `#selectWorkerRow` now binds against.

3. SQLite UNIQUE indexes treat NULL columns as distinct (per the SQL
   standard), so two user-scoped workers (app_uid NULL) with the same
   worker_name would both insert. Wrap the index expression with
   `IFNULL(app_uid, '')` so they correctly conflict — matches the
   MySQL side's `IFNULL` in the generated column.

4. `removeByUuid` / `revokeCascade` SELECTed only the identity
   columns (no `meta`), so `#allCacheKeysForRow`'s worker branch
   couldn't read `meta.worker_name` and the composite
   `sessions:v2:worker:<user>:<app>:<name>` cache key survived
   revocation. Up to CACHE_TTL_SECONDS (15min) afterwards,
   getOrCreateWorker would short-circuit to the cached (revoked) row.
   Add `meta` to both SELECTs; the existing meta-parsing logic in
   `#allCacheKeysForRow` handles the rest.
2026-05-26 20:05:31 -07:00
Daniel SalazarandClaude Opus 4.7 971a1b5071 chore: drop linear/issue ticket references from source comments (#3159)
54 references across 18 files (PUT-1010, PUT-1014, PUT-1019, PUT-1021,
PUT-1022, PUT-1023, PUT-1024 + sub-tags AUTH-2/4/5, SDK-1, PJS-1/2,
GUI-1/2, ROLLOUT-1) removed from inline comments, doc comments,
test describe blocks, and SQL migration headers. The substantive
explanations stay; only the ticket pointers go.

No behavior change. Full backend test suite: 2172 passed / 16 skipped.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 18:50:43 -07:00
Daniel SalazarandClaude Opus 4.7 ac5eecb7f3 feat (put-1019 put-1021): v2 auth revoke endpoints + silent v1->v2 to… (#3158)
* feat (put-1019 put-1021): v2 auth revoke endpoints + silent v1->v2 token migration

PUT-1019 (AUTH-5): full revoke-endpoint coverage
- /logout: soft-revoke web session + its asset cookies via revokeCascade
  (app sessions and access tokens survive)
- POST /auth/revoke-session: cascade per row kind (web/app/access_token/asset)
- POST /auth/revoke-all-sessions: revoke all web rows for user; optional
  include_apps=true nuclear option; gated by userProtected (cookie-only)
- revokeAccessToken: soft-revoke matching access_token row in addition to
  removing access_token_permissions
- All revokes are UPDATE revoked_at = now(); no DELETE statements remain

PUT-1021 (SDK-1): backend POST /auth/migrate-token
- v1 access_token/app -> mint matching-kind v2 token, idempotent on
  (auth_id, kind, token_uid)
- v1 web/session -> 409 { code: "reauth_required" } (interactive relogin only)
- Same-origin / signed-referer hardening; rate-limited per IP and auth_id
- Gated by auth.allow_v1_tokens; emits puter_token_v2 cookie for app-in-browser

DB migrations: mysql_mig_10, sqlite 0053 (sessions.access_token_uid column)

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

* fix(put-1019): reject self-revoke; enrich list-sessions response

- handleRevokeSession refuses uuid === req.actor.session.uid (use /logout
  instead). The cookie that authenticated the call should never be the
  target of a self-revoke — the response can't write fresh auth state
  and the client ends up with an ambiguous identity. revoke-all-sessions
  still has the explicit include_current opt-in for the nuclear case.

- AuthService.listSessions now joins the apps table for kind='app' rows
  (returning { uid, name, title, icon } so the manage-sessions UI can
  render the authorizing app without a second round trip), surfaces
  kind / expires_at / label / last_ip / created_via, and filters out
  asset rows (per-cookie children of web rows, revoked transitively via
  cascade — surfacing them as standalone entries would be confusing).

- Sort order: current session first, then most-recently-active. UI
  relies on this to anchor "you are here" at the top.

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

* fix(put-1019 put-1021): review nits — origin normalization, cookie fallback, types, migration order

B1: createAccessToken.options.expiresIn widened to string | number.
    The impl (#hardExpiryFromExpiresIn) and existing callers/tests use
    jsonwebtoken-style strings ('1h', '30d'); narrowing to number forced
    unsafe casts at every call site. Cast at the single sign() boundary
    where jsonwebtoken's typed template-literal SignOptions clashes
    with the wider runtime contract.

B2: Inline comment on the DELETE in access_token_permissions. The
    AUTH-5 "no DELETE on revoke" rule scoped to the `sessions` table
    (where the cascade graph + audit trail matter). Permissions rows
    are the grant manifest for an active token — once its session is
    soft-revoked they're dead-weight cache entries. A future audit
    requirement would land as a `revoked_at` column on this table,
    not a behavior change in this PR.

B3: handleMigrateToken now sets the puter_token_v2 cookie (with the
    shared sessionCookieFlags + httpOnly) when the migration result
    is kind='app'. The endpoint is already gated on Origin so the
    caller is by definition in a browser; access tokens deliberately
    skip the cookie since they're programmatic.

B4: #isMigrateTokenOriginAllowed normalizes both incoming origin and
    config.origin / allowlist entries (trim + strip trailing slash +
    lowercase) before equality. A misconfigured `config.origin =
    "https://puter.com/"` would otherwise reject every same-origin
    browser call.

B5: Replaced 4x `this.config.cookie_name!` non-null assertions in
    AuthController with `(this.config.cookie_name ?? 'puter_token')`.
    IConfig is `Partial<IConfigOptional>` so cookie_name is undefined
    at runtime in some deployments / test setups; the fallback matches
    the pattern in userProtected / OIDCController / puterSite.

B6: MySQLDatabaseClient sorts migrations numerically by trailing
    integer instead of lexically. Existing files use unpadded names
    (`mysql_mig_<N>.sql`), so plain `.sort()` ran mysql_mig_10 before
    mysql_mig_2 — a future migration that depended on _2..9 running
    first would break. Non-numeric filenames fall through to
    localeCompare for determinism.

B7: Restored the docstring for SessionStore.getOrCreateApp's
    `opts.auth_id` ("Stable per-user identity (survives re-login);
    carried on every v2 JWT so manage-sessions can group by identity")
    — the previous edit truncated it to a fragment.

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

* test + feat: backend test coverage for PUT-1019/1021 review fixes + worker session methods

Tests
-----
- compareMigrationFilenames (new) covering B6 numeric-sort: confirms
  mysql_mig_10.sql lands after mysql_mig_9.sql; non-numeric files sort
  after numbered ones; stable for already-ordered input.
- listSessions (AuthService.test.ts): excludes kind="asset" rows;
  enriches with kind/expires_at/last_ip/created_via; joins kind="app"
  rows with the apps table; sorts current first then by last_activity
  desc.
- handleRevokeSession (AuthController.test.ts): refuses self-revoke
  with 400; still allows revoking a sibling session.
- handleMigrateToken (AuthController.test.ts): rejects missing/
  disallowed Origin; tolerates trailing slash and uppercase Origin
  (B4 normalization); returns 409 reauth_required for v1 web tokens;
  does NOT set the cookie for access-token migration; DOES set the
  puter_token_v2 cookie (httpOnly + sessionCookieFlags) for
  app-under-user migration.
- SessionStore tests updated to import APP_WINDOW_SECONDS /
  WEB_WINDOW_SECONDS rather than hardcoded 30/90 day values — the
  windows just got bumped to 1y and the assertions need to follow
  the constant.

Refactor
--------
- MySQLDatabaseClient exports compareMigrationFilenames so the sort
  logic is unit-testable in isolation.

Worker tokens
-------------
- AuthService.createWorkerSessionToken(user, meta?): mints a new
  kind="web" row tagged meta.worker=true, expires_at =
  WORKER_WINDOW_SECONDS, returns { session, token, gui_token }
  with worker: true on each JWT.
- AuthService.createWorkerAppToken(actor, appUid): mints a new
  kind="app" row tagged meta.worker=true, expires_at =
  WORKER_WINDOW_SECONDS, returns an app-under-user JWT with
  worker: true. Note the existing idx_sessions_user_app_active
  unique index will collide with an existing non-worker app
  session for the same (user, app) — future schema work can
  carve workers out of that uniqueness.

SessionStore.js: WEB/APP_WINDOW_SECONDS now 1y;
WORKER_WINDOW_SECONDS = 99y added for the worker path.

Full backend suite: 2172 passed / 16 skipped / 0 failed.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 18:49:36 -07:00
Daniel SalazarandClaude Opus 4.7 3ae076b73e feat (put-1022 put-1023 put-1024): puter.js reauth handling + GUI v2 modal + silent v1 migration (#3157)
* feat (put-1022 put-1023 put-1024): puter.js reauth handling + GUI v2 modal + silent v1 migration

PUT-1022 (PJS-1): puter.js handles 401 reauth_required
- Single apiCall wrapper intercepts 401 { code: "reauth_required" }
- Clears local token, emits puter.auth.reauth_required { reason, auth_id }
- Queues in-flight requests, re-issues after re-auth completes
- web/app: opens puter.com login popup forwarding auth_id
- gui: no-op (handled by GUI-1 modal)
- workers: surfaces as structured exception
- All token writes go through setAuthToken() (future-proofed for PJS-2)

PUT-1023 (GUI-1): web GUI reauth modal + v2 token storage
- Detects 401 reauth_required across http + websocket connect paths
- Soft modal preserves URL/window state; auth_id forwarded into login form
- Storage key migrated: auth_token -> auth_token_v2
- Cookie cleared on logout: puter_token (legacy) cleared, backend writes puter_token_v2
- Cross-tab propagation via localStorage storage events
- WebSocket revocation surfaces as same modal (no silent failure)

PUT-1024 (PJS-2): puter.js storage versioning + silent v1->v2 migration
- New storage key puter.auth.token.v2
- On SDK init: prefer v2 key; else legacy v1 -> silent POST /auth/migrate-token (SDK-1)
- On success: store v2, clear v1. On failure: fall back to PJS-1 reauth flow
- URL-param tokens (?puter.auth.token=, ?auth_token=) also run through silent migration
- Logout clears both keys; setAuthToken writes v2 only

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

* fix(put-1023): manage-sessions polish — hide self-revoke, fix modal stack, richer rows

Three issues surfaced during local testing of the manage-sessions UI:

1. The session row representing the caller's own cookie had a Revoke
   button that, if used, left the client in an ambiguous identity state
   (backend now rejects it too — see put-1019 backend fix). The button
   is now omitted entirely when session.current=true; /logout remains
   the right path for ending the active session.

2. The confirm-revoke prompt rendered behind the manage-sessions
   window because both share the dominant z-index pool. UIAlert calls
   now pass parent_uuid (the manage-sessions window's data-element_uuid)
   plus stay_on_top, so the prompt stacks above its parent.

3. Each row only showed the bare uuid. Now renders: title (app
   title for app sessions, label / "Browser session" / "Access token"
   otherwise), app icon when applicable, kind / current badges,
   created / last-active / expires (timeago, with absolute on hover),
   and last_ip. App metadata is joined server-side per the backend
   listSessions change.

Adds en.js strings for the new labels (ui_session_*).

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

* fix(put-1022): tighten reauth replay + iframe postMessage hygiene

driverCall_ replay (G1): on 401 reauth_required, re-enter driverCall_
instead of calling replayXhrAfterReauth. The generic helper wires the
retried XHR through setupXhrEventHandlers which resolves with the
parsed response and silently skips driverCall_'s streaming detection,
usage-limit / email-confirmation handling, settings.transform, and
resp.result unwrapping — i.e. it would change the driver call API
contract on retry. One-shot via settings._reauthReplayed so a
fresh-token rejection bubbles up instead of looping.

responseType drift (G2): driverCall_ sets xhr.responseType from
settings AFTER initXhr (which captured the stale value into
xhr._puterReq). Mirror the mutation onto _puterReq so any replay path
builds the retry with the live config rather than the snapshot.

targetOrigin lockdown (G3): triggerReauth's parent.postMessage now
targets this.defaultGUIOrigin instead of '*'. The payload carries
reauth metadata + auth_id and is only meaningful to the GUI; '*'
would leak the signal to whatever frame happened to be embedding us.

event.source pinning (G4): the reauth wait listener also matches
event.source against globalThis.parent, not just event.origin —
origin alone admits any same-origin frame on the GUI domain.

event name alignment (G5): the SDK now emits and listens for
'puter.auth.reauth_required' (matches the documented name in the
PR / commit description). The old 'auth.reauth_required' key isn't
yet consumed externally so this is a safe rename.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-26 18:49:17 -07:00
Daniel Salazar 5394ccc45c feat: add verification for v2 auth (#3155) 2026-05-26 15:34:54 -07:00
Daniel Salazar 3649109f40 fix: add back AGENTS.md (#3154) 2026-05-25 16:01:35 -07:00
1104 changed files with 149723 additions and 27602 deletions

No files matched your search

+2 -2
View File
@@ -2,9 +2,9 @@
# docker-compose.full.yml up -d`. None of the defaults below are safe for
# anything beyond a local laptop test.
# ── Public-facing ports (nginx) ---------------------------------------
# ── Public-facing ports (Caddy) ---------------------------------------
HTTP_PORT=80
# HTTPS_PORT=443 # uncomment after you enable TLS in nginx/nginx.conf
# HTTPS_PORT=443 # uncomment after you enable TLS in caddy/Caddyfile
# ── MariaDB ------------------------------------------------------------
MARIADB_ROOT_PASSWORD=replace-with-strong-password
+11
View File
@@ -1,8 +1,19 @@
name: Backend Tests
# Runs on backend and extension changes. Tests are co-located with the
# code (src/backend/**/*.test.ts, extensions/**/*.test.ts), so test-only
# changes match the same globs. puter.js SDK changes are covered by
# puterjs-tests.yaml instead.
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'src/backend/**'
- 'extensions/**'
- 'tools/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/backend-tests.yaml'
permissions:
contents: read
+2
View File
@@ -87,6 +87,8 @@ jobs:
platforms: linux/amd64,linux/arm64
context: .
push: true
provenance: mode=max
sbom: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
+27
View File
@@ -0,0 +1,27 @@
name: Docs Build
on:
pull_request:
paths:
- 'src/docs/**'
jobs:
build-docs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build docs
run: npm run build
working-directory: src/docs
+145
View File
@@ -0,0 +1,145 @@
name: Puter.js API Tests
# Runs on puter.js SDK changes — the suites live in src/puter-js/tests,
# so test changes are covered by the same glob — plus the pieces the
# runners depend on: the worker preamble, the in-memory test env in
# testUtil, and the backend vitest config the API-tests config extends.
# Backend and extension changes run backend-tests.yaml instead.
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'src/puter-js/**'
- 'src/worker/**'
- 'src/backend/testUtil.ts'
- 'src/backend/vitest.config.ts'
- 'tools/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/puterjs-tests.yaml'
permissions:
contents: read
pull-requests: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies
run: npm ci
# The runners exercise the built artifacts: the SDK bundle
# (src/puter-js/dist) for node + browser, and the worker preamble
# (src/worker/dist) for workerd.
- name: Build puter.js SDK and worker preamble
run: npm run build:workerLib
- name: Install Playwright chromium
run: npx playwright install --with-deps chromium
- name: Run puter.js API tests (node, browser, workerd)
run: npm run test:puterjs
env:
CI: 'true'
# SDK coverage on both refs, reported as a PR comment — same shape as
# backend-tests.yaml. This run uses the istanbul-instrumented bundle
# (the `test` job above keeps exercising the production build).
coverage:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- ref: ${{ github.base_ref }}
artifact: base
- ref: ${{ github.head_ref }}
artifact: pr
steps:
- name: Checkout ${{ matrix.ref }}
uses: actions/checkout@v4
with:
ref: ${{ matrix.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright chromium
run: npx playwright install --with-deps chromium
# The base leg is best-effort — it only enriches the PR comment with
# a comparison, and the base ref may predate the coverage script.
- name: Run puter.js API tests with coverage
continue-on-error: ${{ matrix.artifact == 'base' }}
run: npm run test:puterjs:coverage
env:
CI: 'true'
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: puterjs-coverage-${{ matrix.artifact }}
path: src/puter-js/coverage/coverage-*.json
retention-days: 14
if-no-files-found: ignore
report-coverage:
needs: coverage
if: always()
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download PR coverage
uses: actions/download-artifact@v4
with:
name: puterjs-coverage-pr
path: src/puter-js/coverage
# May not exist (base ref without the coverage script, or a failed
# base run) — then report without the comparison column.
- name: Download base coverage
id: base-coverage
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: puterjs-coverage-base
path: coverage-base
- name: Report coverage on PR
if: steps.base-coverage.outcome == 'success'
uses: davelosert/vitest-coverage-report-action@v2
with:
name: puter.js SDK
json-summary-path: src/puter-js/coverage/coverage-summary.json
json-final-path: src/puter-js/coverage/coverage-final.json
json-summary-compare-path: coverage-base/coverage-summary.json
- name: Report coverage on PR (no base comparison)
if: steps.base-coverage.outcome != 'success'
uses: davelosert/vitest-coverage-report-action@v2
with:
name: puter.js SDK
json-summary-path: src/puter-js/coverage/coverage-summary.json
json-final-path: src/puter-js/coverage/coverage-final.json
+2
View File
@@ -70,3 +70,5 @@ coverage/
undefined
servers.json
config.*.json
volatile/
+2 -1
View File
@@ -1,4 +1,5 @@
{
"tabWidth": 4,
"singleQuote": true
"singleQuote": true,
"plugins": ["prettier-plugin-jsdoc"]
}
+153
View File
@@ -0,0 +1,153 @@
# AGENTS.md
Guidance for AI coding agents working in this repository. Read this before making changes. FOLLOW GUIDANCE AS CLOSELY AS POSSIBLE. If you think the guidance is wrong, raise an issue or flag a maintainer — don't just do what you think is right. This is the source of truth for how we write code, tests, and docs in this repo.
## Documentation Index
Use these as the source of truth before exploring further:
- [README.md](README.md) — project overview and quickstart.
- [doc/architecture.md](doc/architecture.md) — backend layered stack (controllers → drivers → services → stores → clients), `PuterServer` wiring, `Context` (ALS), and extensions.
- [doc/contributing-apis.md](doc/contributing-apis.md) — adding and maintaining public APIs end to end (backend surface → puter.js → types → docs → tests). Follow it for any API work.
- [doc/pagination.md](doc/pagination.md) — the one pagination convention for list APIs (limit/cursor/offset/includeTotal, envelope shape, cursor semantics).
- [doc/alarms.md](doc/alarms.md) — raising alarms and picking a severity (what pages, what only gets recorded), plus the config that routes them.
- [doc/self-hosting.md](doc/self-hosting.md) — running Puter outside hosted infra.
- [CONTRIBUTING.md](CONTRIBUTING.md) — testing, security, AI-assisted code, PR conventions, Boy Scout Rule.
- [SECURITY.md](SECURITY.md) — how to report vulnerabilities (do not file them publicly).
- [BUG-BOUNTY.md](BUG-BOUNTY.md) — bounty program scope.
- [TRADEMARK.md](TRADEMARK.md) — trademark usage.
---
## Repo-wide conventions
These apply everywhere — backend, puter.js, and GUI.
### Language & files
- Write ES modules, not CommonJS — we transpile and build as needed.
- TypeScript preferred for new files in the backend and extensions; convert existing JS there opportunistically when you're already touching a file. The GUI and puter.js are plain JavaScript — don't introduce TypeScript files in them. In puter.js, type with JSDoc instead (see the puter.js section for what must be typed).
- **Reuse before adding.** Search for an existing type, helper, or implementation and extend it; only add a new one when nothing suitable exists.
- Make new types findable: descriptive `PascalCase` names, exported from the obvious entry point. A type used from many places belongs in the owning module's `types.ts` (e.g. [src/backend/controllers/types.ts](src/backend/controllers/types.ts)) rather than bloating a logic file; a type with a single consumer can stay next to it.
- Naming: `camelCase` for variables/functions, `PascalCase` for classes and files containing a class (`AuthService.ts`). **Prefer `camelCase` for all new code** — new local variables, parameters, functions, and internal object properties. `snake_case` is reserved for identifiers that are part of an external contract: wire/JSON keys sent to or received from the backend, stable API error codes, public option names that already ship as `snake_case`, and established `puter_*` namespaced fields. Don't rename those (it's a breaking change), but don't introduce new `snake_case` alongside them either — a camelCase local can carry a value into a `snake_case` wire key (`{ operation_id: operationId }`).
### Comments
Keep comments light; prefer self-documenting code. Comment only when the _why_ is non-obvious or a usage detail would trip the next reader. Use `//` for single lines and `/** ... */` JSDoc when it genuinely needs more — if a comment runs long, it's probably too long. Don't restate the code and don't reference the current task, PR, or version — those rot. **No ticket references** (`PUT-1234`, `// fix for FOO-99`) in code, comments, or test names; describe the why in domain terms, not project-management terms. Use plain ASCII `-` in comment section dividers (`// -- Section --`), never box-drawing characters.
### Security & privacy
Before opening a PR, scan the diff for:
- Logs, error messages, or responses leaking internal paths, secrets, tokens, env vars, or other users' data.
- Debug routes, test credentials, commented-out auth checks.
- Endpoints returning more than the caller actually needs.
When in doubt, return less. Auth-, permission-, or data-export-related changes deserve an explicit callout in the PR description.
### Working rules of thumb
- **Run it, don't just compile it.** "It type-checks" is not "it works." Exercise the code path end-to-end at least once.
- **Read the neighbors before writing.** Match the shape of similar things already in the tree. If you think the existing pattern is wrong, raise it — don't quietly diverge.
- **Test new behavior.** Every new function, endpoint, driver method, or logic branch gets a test; every bug fix gets a regression test that fails before the fix. If something is genuinely hard to test, skip it but say so in the PR.
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo or dead import in files you're already touching; don't ride a refactor along with a bug fix.
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't defend in review is not.
---
## Backend
A layered stack with explicit dependency injection: each layer depends only on the layers beneath it, receives them through its constructor, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together. [doc/architecture.md](doc/architecture.md) is the full reference.
| Layer (top → bottom) | Lives in | Responsibility |
| -------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Controllers** | [src/backend/controllers/](src/backend/controllers/) | Route handlers: parse/validate input, per-route gates via `RouteOptions` (auth, subdomain, rate limit, body parsers), call services, format responses. |
| **Drivers** | [src/backend/drivers/](src/backend/drivers/) | Optional RPC-style handlers on `/drivers/*`; thin shells that validate inputs and call services/stores. |
| **Services** | [src/backend/services/](src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized. |
| **Stores** | [src/backend/stores/](src/backend/stores/) | Persistence; wraps clients in the domain shapes services consume. |
| **Clients** | [src/backend/clients/](src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, email, event bus). Protocols, not domain concepts. |
| **Config** | `config.*.json` → `IConfig` | Flat, typed config object every layer receives at construction. |
A public API can be exposed through either a controller or a driver — both are supported; prefer a controller when you need fine-grained control over routes and gates. [doc/contributing-apis.md](doc/contributing-apis.md) has the decision guide with links to the decorators and middleware for each.
Cross-layer rules:
- **Don't reach across layers.** Controllers don't poke clients; services don't register routes. If you want to, the abstraction is wrong — fix the abstraction.
- **Don't call sideways within a layer for code reuse.** Two services needing the same logic means a util/helper, not a service-to-service dependency.
- **Prefer explicit arguments over `Context` (ALS).** Reach for [Context](src/backend/core/context.ts) only for genuinely request-scoped values that would otherwise thread through many layers — today mostly `actor` and `req`.
### Extensions
[extensions/](extensions/) parallels the layered stack and is for **non-crucial parts of the system** — Puter still works with the extension removed. If core needs to call it, it belongs in core, not an extension (see [whoami](extensions/whoami.ts) as the cautionary example). The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts)) exposes:
- `extension.registerClient/Store/Service/Driver/Controller(name, Class)` for first-class additions.
- `extension.on(event, handler)` and `extension.get/post/put/delete/patch/use(path, opts?, handler)` for the lightweight common case. `opts` is the same `RouteOptions` controllers use.
- `extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` — lazy proxy to instantiated core objects. `extension.config` exposes live config.
Follow the same layered structure inside an extension — unless it only needs a few route handlers, in which case the lightweight helpers are enough on their own.
### Backend tests
- Vitest; test files sit next to the code they test (`*.test.ts` / `*.test.js`). Run with `npm run test:backend`.
- **Mock data, not methods.** Stub inputs (fixtures, fake rows, payloads), not the function under test or the layer beneath it — over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service).
- **Prefer the test server over mocking deps.** `setupPuterTestEnv()` in [src/backend/testUtil.ts](src/backend/testUtil.ts) boots a fully in-memory backend; hit a real database/client shape where reasonable — integration shapes catch what mocked unit tests miss.
---
## puter.js (the SDK)
[src/puter-js/](src/puter-js/) is the public SDK. It ships live from `https://js.puter.com/v2/` with no version pinning — every existing app picks up changes immediately. Treat every observable behavior (signatures, response fields, error codes) as something a production app depends on.
Layout: SDK modules in [src/puter-js/src/modules/](src/puter-js/src/modules/) (one file or directory per area — `FileSystem/`, `KV.js`, `ai/`, …), shared plumbing in [src/puter-js/src/lib/](src/puter-js/src/lib/), hand-maintained type declarations in [src/puter-js/types/](src/puter-js/types/), API tests in [src/puter-js/tests/api/](src/puter-js/tests/api/), UI e2e tests in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/), developer docs in [src/docs/](src/docs/).
Language & typing: puter.js source is plain JavaScript — never TypeScript files — typed via JSDoc. Reference the hand-maintained declarations in [src/puter-js/types/](src/puter-js/types/) with `import(...)` types rather than re-declaring shapes:
```js
/** @typedef {import('../../types/modules/ai').ChatOptions} ChatOptions */
/** @type {ChatOptions} */
const options = { model: 'gpt-5-nano' };
```
When a shape only exists locally, declare it as an inline object literal — not the `@typedef {Object}` + `@property` list form — and use `unknown` over `*`:
```js
/** @typedef {{ key: string, value: unknown }} KVEntry */
```
Public (exposed) methods must carry JSDoc types — parameters and return value, matching the `.d.ts` declarations exactly. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean.
Typing in JS files is encouraged: annotate with JSDoc `@type`/`@param`/`@returns` using the TypeScript type system, and define shared shapes with `@typedef`. API types must not be `unknown` or untyped `...args` — spell out the real parameter and return shapes; the only exception is values passed through transparently to an upstream layer that owns their type. For example:
```js
/**
* @typedef {{key:string, value: unknown}} KVEntry
*/
/** @type {KVEntry[]} */
let entries = [];
```
Every SDK change carries all five of the following — a puter.js PR missing one is incomplete:
1. **Backward compatibility.** Mandatory unless a maintainer explicitly signs off on a break. Existing call signatures keep working (including both positional and options-object forms where a method supports them); new parameters are optional with defaults that preserve old behavior; never rename or repurpose existing params, response fields, or error codes. New parameter names are `camelCase` (existing `snake_case` stays for compatibility). Say in the PR how existing callers are unaffected.
2. **Tests.** Add or extend a suite in [tests/api/suites/](src/puter-js/tests/api/suites/) (`<area>.suite.ts`; register new suites in `suites/index.ts` — no globbing). One suite runs unchanged on node, browser, and workerd via `npm run test:puterjs`; never write a per-platform test. The runners execute the **built** bundle — run `npm run build:workerLib` after SDK changes or the suite silently tests stale code. For `puter.ui.*` methods rendered by the desktop, use the Playwright e2e harness instead — see [src/puter-js/TESTING.md](src/puter-js/TESTING.md).
3. **Docs.** New or changed APIs update [src/docs/src/](src/docs/src/): the method page (`<Area>/<method>.md`, with frontmatter and a runnable example) and the area overview when the surface changes. Docs are the contract users code against — signatures, defaults, and return shapes must match the implementation exactly.
4. **Types.** Update [src/puter-js/types/modules/](src/puter-js/types/modules/)`<module>.d.ts` and re-export new types through `index.d.ts` / `types/puter.d.ts`. Declarations must match runtime behavior exactly — a wrong type is worse than a missing one.
5. **Error handling.** Reject/throw `{ message, code }` objects with stable `snake_case` codes, matching the existing modules (see `KV.js`). Validate cheap preconditions client-side before making the network call; pass backend errors through unchanged rather than swallowing or re-wrapping them. Error codes are API surface — changing one is a breaking change.
[doc/contributing-apis.md](doc/contributing-apis.md) walks the full lifecycle of adding an API across backend + SDK.
---
## GUI
[src/gui/](src/gui/) is the Puter desktop: deliberately plain JavaScript + jQuery with HTML-string templates. Don't introduce a UI framework or a new rendering pattern.
The guiding rule here is **conformity over novelty** — match the existing design and code structure even where you'd personally choose differently. A visually or structurally divergent addition is a defect even when it works.
- **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). 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.
+34 -1
View File
@@ -17,18 +17,51 @@ Out-of-scope:
* Denial of Service (DoS), spam, or volumetric attacks.
* Physical security issues.
## Known Non-Issues (Please Check Before Submitting)
The following have already been reviewed and determined **not to be vulnerabilities**. Reports that only re-describe one of these are **not eligible for a reward** and will be closed as non-issues — even if they include new code references. Please review this list before submitting:
* **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we *do* evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
* **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
* **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
* **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
* **App metadata or app user-count "leaks".** This information is currently public by design.
* **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
* **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
* **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
* **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
* **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
* **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
* **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
If you believe you have a **genuinely new** exploit chain that defeats one of these rationales (for example, demonstrating a sensitive credential that really is reachable on `api.puter.com`), say so explicitly and show why the reasoning above does not apply.
## Rules of Engagement
To participate, you must:
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code or screenshots where applicable.
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see *Proof of Reproduction* below).
2. **Do no harm**: Do not exfiltrate, modify, or delete data. Only access your own account or test data.
3. **Respect availability**: Do not perform denial-of-service attacks or automated scans that degrade service.
4. **Follow disclosure policy**: Do not publicly disclose vulnerabilities until we have confirmed and patched the issue.
5. **Act in good faith**: Make every effort to avoid privacy violations, destruction of data, and interruption or degradation of services.
6. **Check the Known Non-Issues list**: Reports matching an item in the "Known Non-Issues" section above are not eligible and will be closed.
Reports that do not meet these guidelines may not be eligible for a reward.
## Proof of Reproduction
Reports must demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
* Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
* The **observed** result versus the **expected** result.
* For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
* For server-side bugs: a runnable proof-of-concept.
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and are generally not eligible**. If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
Please submit **one issue per report**. Bundled "audit packs" of many speculative findings will be declined; send each confirmed issue separately.
## Reporting Process
To report a vulnerability, email us at: **[[email protected]](mailto:[email protected])**.
+1
View File
@@ -0,0 +1 @@
FOLLOW ./AGENTS.md
+4 -2
View File
@@ -16,6 +16,8 @@ Add tests for new behavior, endpoints, or bug fixes. If something's genuinely ha
Match the shape of similar code already in the repo. [doc/architecture.md](doc/architecture.md) is the source of truth for layers, wiring, and naming. If you think a pattern is wrong, raise it — don't quietly diverge.
In plain-JS files, typing is encouraged via JSDoc `@type` annotations using the TypeScript type system, with `@typedef` for shared shapes. Don't type API surfaces as `unknown` or untyped `...args` unless the values are passed through transparently to an upstream layer that owns their type.
## 3. Don't expose system or user information
Scan your diff for stray logs, debug routes, internal paths, secrets, tokens, or user data in errors/responses. When in doubt, return less. Flag any auth, permission, or data-export changes in the PR description.
@@ -26,9 +28,9 @@ For private security reports, see [SECURITY.md](SECURITY.md).
Don't commit code you couldn't have written, debugged, or defended yourself. Read the diff, run it, and be ready to explain it in review.
## 5. Update docs for API changes
## 5. Adding or changing APIs
If you change puter-js APIs (drivers or endpoints used by puter-js), update [developer docs](src/docs/). puter-js itself may also need updating.
If you add or change a public API (an endpoint, driver method, or puter-js method), follow [doc/contributing-apis.md](doc/contributing-apis.md) — backward compatibility, [developer docs](src/docs/), types, and tests all move in the same PR.
## 6. Boy Scout Rule — leave it 1% better
+73
View File
@@ -0,0 +1,73 @@
# Reverse proxy in front of Puter — mirrors what the prod ALB does:
# accepts every Host header, forwards to the Puter container, and lets
# the Puter app handle subdomain-based routing internally (api.*,
# site.*, app.*, dev.*, plus the per-user subdomains under those).
#
# To enable TLS:
# 1. Drop a wildcard fullchain.pem + privkey.pem into ./puter/tls/
# (see "Step 3 — TLS" in doc/self-hosting.md).
# 2. Uncomment the `:443` block at the bottom of this file, and swap the
# `:80` block for the redirect shown alongside it.
# 3. Uncomment the `443:443` port mapping under the `caddy` service in
# docker-compose.yml, and HTTPS_PORT in .env.
# 4. Set `"protocol": "https"` and `"pub_port": 443` in config.json.
{
# Certs are supplied by the operator, not issued by Caddy. Puter serves
# per-user sites and apps on dynamic subdomains (<name>.site.<domain>,
# <name>.app.<domain>) — only a DNS-01 wildcard cert covers those, and
# DNS-01 needs a provider plugin that isn't in the stock caddy image.
# `off` stops Caddy attempting ACME on boot (it would fail and leave the
# site unreachable) and stops it inventing its own http→https redirects.
auto_https off
}
# Shared handling, imported by the HTTP and HTTPS site blocks below so the
# two can't drift apart.
(puter_routes) {
# Rough size cap that mirrors prod ALB defaults; tune for your uploads.
# Puter chunks large uploads, so 1 GiB per request is plenty.
request_body {
max_size 1024MiB
}
# RustFS — see the `s3` service in docker-compose.yml. Browsers PUT/GET
# here for presigned-URL uploads / downloads. Matched on the `s3.`
# subdomain of whatever the install domain is, so signature verification
# works (Caddy preserves the original Host end-to-end) and HTTPS stays
# clean — no mixed content from a port-9000 host publish.
@s3 header_regexp Host ^s3\.
handle @s3 {
reverse_proxy s3:9000
}
# Everything else, on every Host, goes to Puter — which routes on that
# Host internally. Caddy forwards it unchanged and adds X-Forwarded-For
# / -Proto / -Host, which is what `trust_proxy` in config.json counts.
handle {
reverse_proxy puter:4100 {
# Stream responses through unbuffered — Puter uses SSE and
# socket.io. WebSocket upgrades need no config of their own;
# Caddy proxies them by default.
flush_interval -1
}
}
}
# ── HTTP (port 80) ─────────────────────────────────────────────────────
# A site address with no hostname is the catch-all: it answers for every
# Host, which is what Puter's subdomain routing requires.
:80 {
import puter_routes
}
# ── HTTPS (port 443) — uncomment after dropping certs in ./puter/tls/ ──
# Replace the `:80` block above with a redirect to force HTTPS everywhere:
#
# :80 {
# redir https://{host}{uri} permanent
# }
#
# :443 {
# tls /etc/caddy/tls/fullchain.pem /etc/caddy/tls/privkey.pem
# import puter_routes
# }
+1 -2
View File
@@ -5,9 +5,7 @@
"protocol": "http",
"domain": "puter.localhost",
"cookie_name": "puter_auth_token",
"jwt_secret": "dev-jwt-secret-change-me",
"jwt_secret_v2": "dev-jwt-secret-v2-change-me",
"allow_v1_tokens": true,
"url_signature_secret": "dev-url-signature-secret-change-me",
"allow_all_host_values": true,
"allow_no_host_header": true,
@@ -23,6 +21,7 @@
"default_user_group": "78b1b1dd-c959-44d2-b02c-8735671f9997",
"default_temp_group": "b7220104-7905-4985-b996-649fdcdb3c8f",
"storage_capacity": 104857600,
"disable_user_signup": false,
"strict_email_verification_required": false,
"gui_assets_root": "./src/gui",
"puterjs_root": "./src/puter-js/dist",
+94 -8
View File
@@ -16,6 +16,12 @@
// `dev` opens a browser on boot, skips blocked-email checks, and runs the
// dev-time webpack watcher; `prod` serves pre-built bundles.
"env": "dev",
// Console output format. `json` replaces the global console so every call
// emits one structured JSON line (level, timestamp, msg, and the active
// request's trace id) — one event per call, so a line-oriented log
// collector can't split stack traces across events and level filtering
// works. Unset (the default) leaves console output human-readable.
"log_format": "text",
"version": "0.0.0",
// Stable identity for this server node — used by pager alerts and
// graceful-shutdown coordination.
@@ -67,14 +73,13 @@
// ── Auth / session ──────────────────────────────────────────────────
// ALWAYS replace these for any public install — `openssl rand -hex 64`.
// `jwt_secret` is legacy verify-only; new tokens sign with `jwt_secret_v2`.
"jwt_secret": "change-me",
"jwt_secret_v2": "change-me",
// Set false to retire v1 tokens entirely (ROLLOUT-1).
"allow_v1_tokens": true,
"url_signature_secret": "change-me",
"cookie_name": "puter_auth_token",
"min_pass_length": 6,
// When true, anonymous users must log in instead of creating temp or
// permanent accounts.
"disable_user_signup": false,
"allow_system_login": false,
"strict_email_verification_required": false,
"captcha": {
@@ -159,17 +164,30 @@
// ── Database ────────────────────────────────────────────────────────
"database": {
// `sqlite` for single-node / dev; `mysql` for self-host with MariaDB.
// `sqlite` for single-node/dev, `mysql` for MariaDB/MySQL,
// or `postgres` for PostgreSQL.
//
// NOTE: `postgres` support is a community contribution and is not
// exercised by Puter.com production. It boots, passes its own
// integration tests against pgmock, and runs the common user/app/
// fsentry/session/permission/OIDC flows — but less-traveled code
// paths may surface MySQL/SQLite-isms that haven't been ported yet.
// Expect rough edges and please file issues if you hit one. For
// production self-hosting today, `mysql` (MariaDB) and `sqlite` are
// the supported defaults.
"engine": "sqlite",
// sqlite — file path on disk
"path": "volatile/runtime/puter-database.sqlite",
"targetVersion": 0,
// mysql — connection details
// mysql/postgres — connection details. PostgreSQL defaults to port
// 5432 when `engine` is `postgres`; MySQL/MariaDB normally use 3306.
"host": "",
"port": 3306,
"user": "",
"password": "",
"database": "",
// postgres may also use a URL instead of host/user/password fields:
// "connectionString": "postgres://puter:secret@localhost:5432/puter",
// Optional read-replica pool. Reads route here when populated.
"replica": {
"host": "",
@@ -178,9 +196,10 @@
"password": "",
"database": ""
}
// mysql self-host bootstrap: set `migrationPaths` to apply the bundled
// schema on first boot. Idempotent — safe to leave on.
// mysql/postgres self-host bootstrap: set `migrationPaths` to apply the
// bundled schema on first boot. Idempotent — safe to leave on.
// "migrationPaths": ["./src/backend/clients/database/migrations/mysql"]
// "migrationPaths": ["./src/backend/clients/database/migrations/postgres"]
},
// ── DynamoDB (KV store) ─────────────────────────────────────────────
@@ -232,6 +251,60 @@
}
},
// ── Alarms / alerting ───────────────────────────────────────────────
// Where system alarms go. Severity is the routing decision — each
// transport takes everything at or above its own `minSeverity`:
//
// critical — an unhandled server error; pages on-call.
// error — pages as well; prefer critical or warning.
// warning — look at it today; no page.
// info — a record in the chat channel only.
//
// Both transports are off unless enabled, so a self-hosted node just
// logs its alarms to the console.
"pager": {
// Severity for call sites that don't pick one. Default "critical".
"defaultSeverity": "critical",
// Retier or silence an alarm without a deploy. Keys are alarm ids,
// or a prefix ending in `*`; the exact id wins over a pattern, and
// the longest matching pattern wins among patterns. Values are a
// severity or "mute". This is applied last, so it overrides both the
// call site and any known-error rule.
"severityOverrides": {
// "cronMonitor:*": "info",
// "http_500:GET:/some/flapping/route:*": "mute"
},
"pagerduty": {
"enabled": false,
"routingKey": "",
// Lowest severity that reaches PagerDuty. Default "warning",
// which keeps `info` out of the paging system entirely.
"minSeverity": "warning"
},
// Slack incoming webhook — the low-noise destination for everything
// that shouldn't page.
"slack": {
"enabled": false,
"webhookUrl": "",
// Optional; defaults to the channel the webhook was created for.
"channel": "#alerts",
"username": "puter-alarms",
// Severity window posted to Slack. The ceiling defaults to
// "info" when PagerDuty is configured — what pages belongs in
// the pager, not in chat — and to "critical" when Slack is the
// only transport.
"minSeverity": "info",
"maxSeverity": "info",
// Don't repost the same alarm id within this window. The first
// occurrence always posts; the next post that gets through
// reports how many occurrences piled up. 0 disables throttling.
"repeatThrottleMs": 900000
}
},
// ── Rate limiting ───────────────────────────────────────────────────
// `memory` for single-node, `redis` for multi-node (default), `kv` for
// dynamo-backed counters.
@@ -246,16 +319,28 @@
// ─ Chat / completion ─
"claude": { "apiKey": "" },
"openai-completion": { "apiKey": "" },
"azure-openai": {
"apiKey": "",
"apiURL": ""
},
"gemini": { "apiKey": "" },
"groq": { "apiKey": "" },
"deepseek": { "apiKey": "" },
"mistral": { "apiKey": "" },
"xai": { "apiKey": "" },
"moonshot": { "apiKey": "" },
"minimax": {
"apiKey": "",
"apiBaseUrl": "https://api.minimax.io/v1"
},
"openrouter": {
"apiKey": "",
"apiBaseUrl": "https://openrouter.ai/api/v1"
},
"infron": {
"apiKey": "",
"apiBaseUrl": "https://llm.onerouter.pro/v1"
},
"together-ai": { "apiKey": "" },
// Local Ollama. `enabled: false` skips the auto-probe at startup
// (otherwise Puter logs ECONNREFUSED on every boot when no Ollama
@@ -294,6 +379,7 @@
"secret_key": "",
"region": "us-west-2"
},
"speechify": { "apiKey": "" },
"aws-textract": {
"access_key": "",
"secret_key": "",
+113
View File
@@ -0,0 +1,113 @@
# Alarms
`clients.alarm` is the one way code reports that something is wrong. It
de-dupes by alarm id, counts occurrences, and routes each alarm to alert
transports by **severity**.
```ts
this.clients.alarm.create(
`driver_rate_limit_hit:${iface}:${method}`, // de-dupe key
`Driver rate limit hit on ${iface}:${method}`, // what a human reads
{ iface, method, userUuid }, // context fields
'info', // severity
);
```
## Severity is the routing decision
| Severity | Meaning | Goes to |
| ---------- | ------------------------------------------------------ | ----------- |
| `critical` | An unhandled server error. Someone gets woken up. | Pager |
| `error` | Same urgency as critical; prefer one of the other two. | Pager |
| `warning` | Worth a look today. Nobody is paged. | Pager (low) |
| `info` | A record of something expected-but-notable. | Chat |
Each transport declares the severity window it accepts, so the value a call
site passes is what decides where the alarm lands. The two windows don't
overlap by default: anything that pages lives in the paging system, and chat
is the record of what didn't. The bar for `critical` is
deliberately high: an unhandled 5xx out of the HTTP error handler is the main
thing that still pages. Anything a human can look at tomorrow is `warning`,
and anything that's just worth recording is `info`.
Omitting the severity takes `pager.defaultSeverity` (itself `critical`), so
pass one explicitly unless you really mean "page someone".
### Choosing one
- Did the server fail to do its job in a way nobody expected? → `critical`
- Is a background job, rate, or dependency degraded? → `warning`
- Is this a user doing something notable (hitting a limit, tripping an abuse
heuristic, overspending)? → `info`
An extension whose signals are all one tier can default its own local
`raiseAlarm` helper to that tier instead of repeating it at every call site —
see [extensions/cronMonitor](../../../extensions/cronMonitor/index.js).
## One incident per occurrence, unless you say otherwise
Alarms always de-dupe *in process* — repeats of an id bump its occurrence
count rather than creating a second alarm. What that means for the pager is a
separate decision, and by default every occurrence opens its own PagerDuty
incident: two failed scans an hour apart are two things that happened, and
closing one shouldn't hide the other.
Pass `{ dedup: true }` as a fifth argument when repeats of the id really are
one recurring fault, and they collapse onto a single incident carrying the
occurrence count:
```ts
this.clients.alarm.create(alarmId, message, fields, 'critical', {
dedup: true,
});
```
The HTTP error handler uses it: its id is route + error signature, so a hot
loop of the same crash is one incident with N occurrences instead of N pages.
Reach for it anywhere else only when the id is that specific — otherwise a
per-request alarm can flood the pager.
## Configuration
Everything lives under `pager` in config (see
[config.template.jsonc](../config.template.jsonc) for the annotated version).
Both transports are off unless enabled, so a self-hosted node just logs
alarms to the console.
```jsonc
"pager": {
"defaultSeverity": "critical",
"severityOverrides": { "cronMonitor:*": "info" },
"pagerduty": { "enabled": true, "routingKey": "…", "minSeverity": "warning" },
"slack": {
"enabled": true,
"webhookUrl": "…",
"channel": "#alerts",
"minSeverity": "info",
"maxSeverity": "info",
"repeatThrottleMs": 900000,
},
}
```
Slack's `maxSeverity` defaults to `info` whenever PagerDuty is configured, and
to `critical` when it isn't — a node with only a webhook still sees
everything. Raise it to have chat mirror the paging tiers as well.
### Retiering without a deploy
`severityOverrides` is the escape hatch for an alarm that turns out to be
noisier or more serious than its call site assumed. Keys are alarm ids or a
prefix ending in `*`; the exact id beats a pattern, and the longest matching
prefix wins among patterns. Values are a severity, or `mute` to drop the
alarm before any transport sees it.
It is applied *after* the call site's severity and any known-error rule, so
config always has the last word.
### Repeat throttling
The chat transport won't repost the same alarm id within
`repeatThrottleMs` (default 15 minutes). The first occurrence always posts,
and the next one that gets through reports how many piled up in between —
so a hot loop reads as one message with a count, not a wall of them.
+108
View File
@@ -0,0 +1,108 @@
# Contributing APIs
How to add a new public API to Puter, and how to maintain one that already exists. "Public API" means anything applications can call: HTTP endpoints, `/drivers/*` methods, and the puter.js methods that wrap them. This guide is written for human contributors and AI agents alike — [AGENTS.md](../AGENTS.md) defers to it for API work.
Companion docs: [architecture.md](architecture.md) (backend layering), [pagination.md](pagination.md) (list APIs), [src/puter-js/tests/api/README.md](../src/puter-js/tests/api/README.md) (SDK test environment).
## Rule zero: don't break callers
puter.js is served live from `https://js.puter.com/v2/` with no version pinning — every app in existence picks up your change the moment it deploys, and the backend endpoints underneath have the same property. Assume every observable behavior (parameter handling, response fields, error codes, ordering) has someone depending on it.
Every change is backward compatible unless a maintainer has explicitly agreed to a break beforehand.
## Core or extension?
The first decision is where the API lives.
If the API is **not crucial to core functionality — nothing in core will call it — prefer an extension** over wiring it into core. Extensions live in [extensions/](../extensions/), parallel the core layered stack, and reach core through `extension.import(...)`:
```js
const services = extension.import('service');
const stores = extension.import('store');
extension.registerDriver('myFeature', MyFeatureDriver); // first-class driver
extension.post('/my-feature/frobnicate', opts, handler); // or plain routes
```
Follow the same layered structure inside the extension (driver/controller → service → store) unless it genuinely only needs a couple of route handlers — then the lightweight `extension.get/post/...` helpers are enough on their own.
The test is the direction of dependency: Puter must still work with the extension removed. The moment core needs to call your API, it belongs in core — see [whoami](../extensions/whoami.ts) for the cautionary example of a load-bearing "extension".
## Adding a new API
Work through all seven steps; the PR is complete when every one is.
### 1. Design the surface first
- Sketch the signature, options, return shape, and error cases before writing code. Find the two or three most similar existing APIs and match their conventions.
- New parameter and field names are `camelCase`. (Existing `snake_case` names stay where they already exist.)
- Anything returning a list follows the [pagination convention](pagination.md): `limit`/`cursor` in, `{ items, cursor, total }` envelope out.
- Prefer an options object over a growing list of positional parameters, but keep the common case callable with a single argument where siblings do.
### 2. Backend
Follow the layered stack ([architecture.md](architecture.md)): a controller or driver at the edge, business logic in a service, persistence in a store. The edge parses and validates input and applies gates; services assume the caller is already authorized. Return exactly what the caller needs and no more — every response field you ship is permanent — and use stable `snake_case` error codes.
#### Controller or driver?
Both are supported ways to define an API. **Prefer a controller when you need fine-grained control** — URL shape, HTTP verbs, per-route gates, response and streaming formats. A **driver** fits when the API is a set of RPC methods implementing one of the named interfaces on `/drivers/*` (`puter-kvstore`, `puter-chat-completion`, …) and the generic driver plumbing — one call envelope, interchangeable implementations, per-method policies — covers what you need.
- **Controller:** extend `PuterController` ([src/backend/controllers/types.ts](../src/backend/controllers/types.ts)) and declare routes with the `@Controller(prefix)` class decorator plus `@Get`/`@Post`/… method decorators ([src/backend/core/http/decorators.ts](../src/backend/core/http/decorators.ts)), each taking `(path, routeOptions)` — or override `registerRoutes(router)` imperatively. Core controllers register in [src/backend/controllers/index.ts](../src/backend/controllers/index.ts); extensions use `extension.registerController(...)` or the plain route helpers.
- **Driver:** extend `PuterDriver` ([src/backend/drivers/types.ts](../src/backend/drivers/types.ts)) and mark it with the `@Driver(interfaceName, opts)` decorator ([src/backend/drivers/decorators.ts](../src/backend/drivers/decorators.ts)), which also declares its policies. Core drivers register in [src/backend/drivers/index.ts](../src/backend/drivers/index.ts); extensions use `extension.registerDriver(...)`.
#### Middleware and gates
- **Controller routes** (and extension routes — same options) take [`RouteOptions`](../src/backend/core/http/types.ts): auth gates (`requireAuth`, `requireUserActor`, `noUserSession`, `adminOnly`, `allowedAppIds`, and the access-token controls), `subdomain` routing, per-route `rateLimit`, body parsers, and arbitrary extra `middleware`. The auth flavors are subtle and default-deny — read the JSDoc on each field before picking.
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), and `noUserSession`. The `/drivers/call` surface enforces them.
### 3. puter.js
- Add the method to the matching module in [src/puter-js/src/modules/](../src/puter-js/src/modules/), matching the calling conventions of its siblings (promise-returning; positional shortcut plus options form where that's the local pattern).
- Validate cheap preconditions client-side and throw `{ message, code }` objects; pass backend errors through unchanged rather than swallowing or re-wrapping them.
### 4. Types
- Add or extend the declaration in [src/puter-js/types/modules/](../src/puter-js/types/modules/)`<module>.d.ts` and re-export new types through `index.d.ts`. Declarations must match the runtime exactly — optionality, defaults, and return types included.
### 5. Docs
- Add the method page at [src/docs/src/](../src/docs/src/)`<Area>/<method>.md` — frontmatter (`title`, `description`, `platforms`), syntax, parameters, return value, and at least one runnable example — and update the area overview (`<Area>.md`). Copy the structure of an existing page.
### 6. Tests
- **Backend:** colocated Vitest tests; prefer the in-memory test server (`setupPuterTestEnv` in [src/backend/testUtil.ts](../src/backend/testUtil.ts)) over mocking.
- **SDK:** add cases to [src/puter-js/tests/api/suites/](../src/puter-js/tests/api/suites/)`<area>.suite.ts` (register new suites in `suites/index.ts`). One suite runs on node, browser, and workerd via `npm run test:puterjs` — never write per-platform tests, and rebuild first with `npm run build:workerLib` since the runners execute the built bundle.
- **Desktop-rendered UI** (`puter.ui.*`): add a Playwright spec per [src/puter-js/TESTING.md](../src/puter-js/TESTING.md).
### 7. Security pass
Scan the diff before opening the PR: no internals leaked in errors or logs, no over-broad responses, auth gates present. Flag anything auth-, permission-, or data-export-related in the PR description.
## Maintaining an existing API
Changes are **additive by default**:
- New parameters are optional, with defaults that reproduce the old behavior exactly.
- Never rename, repurpose, or remove existing parameters, response fields, or error codes. Don't change types, ordering guarantees, or which fields are present when.
- New behavior that could surprise existing callers goes behind an opt-in flag.
- Docs, types, and tests move in the same PR as the behavior. A signature change with stale docs is a bug — the docs are the contract users code against.
- Bug fixes come with a regression test that fails before the fix. Be suspicious of fixes that change observable behavior: someone may depend on the bug. When in doubt, ask a maintainer.
### Breaking changes
Rare and deliberate. In order: explicit maintainer sign-off, a documented migration path, and a rollout plan — typically new surface added first, old surface deprecated, old surface removed much later, if ever. Never break as a side effect of a refactor.
### Deprecating
The old surface keeps working. Mark it `@deprecated` in the type declarations, note the replacement on its docs page, and stop using it in examples. Removal is a separate, maintainer-approved decision.
## Definition of done
- [ ] Backward compatible (or the break was explicitly approved)
- [ ] Right home: extension if core never calls it; layered structure either way
- [ ] puter.js method matches sibling conventions; errors are `{ message, code }` with stable codes
- [ ] Types updated and matching runtime behavior
- [ ] Docs page + area overview updated, with a runnable example
- [ ] Tests: backend + three-platform SDK suite (+ e2e for desktop-rendered UI)
- [ ] Security pass on the diff
- [ ] You ran it end to end
+1 -1
View File
@@ -104,7 +104,7 @@ Puterは[**puter.com**](https://puter.com)でホストサービスとして利
メンテナーやコミュニティと以下のチャンネルを通じてつながりましょう:
- バグ報告や機能リクエストがありますか? [issueを開く](https://github.com/HeyPuter/puter/issues/new/choose) してください。
- バグ報告や機能リクエストは [新規Issue](https://github.com/HeyPuter/puter/issues/new/choose) まで
- Discord: [discord.com/invite/PQcx7Teh8u](https://discord.com/invite/PQcx7Teh8u)
- X (Twitter): [x.com/HeyPuter](https://x.com/HeyPuter)
- Reddit: [reddit.com/r/puter/](https://www.reddit.com/r/puter/)
+100
View File
@@ -0,0 +1,100 @@
# Pagination convention for list APIs
Every list endpoint follows one wire contract. This document is the source of
truth for adding pagination to a new or existing list surface.
## Wire contract
Requests accept:
| Param | Type | Meaning |
| --- | --- | --- |
| `limit` | number | Maximum items per page. Each endpoint documents its cap and default. |
| `cursor` | string \| null | Opaque continuation token. `null` (or any presence of the key) requests the first page. |
| `offset` | number | Skip N items. Legacy/discouraged — see below. Cannot be combined with `cursor`. |
| `includeTotal` | boolean | Adds `total` to the response. |
Paginated responses are an envelope:
```json
{ "items": [...], "cursor": "…", "total": 123 }
```
- `cursor` is present only while more pages exist. Clients iterate until it
is absent.
- `total` is present only when the request set `includeTotal`.
- **Pages may be short.** Post-query filtering (TTL expiry, permission
checks) can shrink a page below `limit` — or even to zero — while a
`cursor` is still returned. Never use `items.length < limit` as an
end-of-list signal.
New request params are camelCase (`includeTotal`, `fetchUntilFull`).
Pre-existing snake_case params stay for compatibility.
## Backward compatibility
Requests without pagination params keep returning the full result in the
legacy shape (bare array) forever — old clients never break.
The envelope trigger depends on the endpoint's history:
- Endpoints where `limit` pre-dates the convention (readdir, subdomain
`select`) return the envelope only when the request contains `cursor`
(including `null`) or `includeTotal`; `limit`/`offset`-only requests keep
the bare array.
- Endpoints where every pagination param is new (kv `list`, workers
`getFilePaths`) return the envelope when any pagination param is present.
## Cursors
Cursors are opaque base64-encoded JSON, produced and consumed only by the
backend (`src/backend/util/pagination.ts`). What a cursor wraps is an
implementation detail per store:
- 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`).
Cursors that carry a sort also pin it: a request that passes a cursor plus a
conflicting sort is rejected with 400.
## Offset
SQL-backed endpoints support `offset` natively. DynamoDB-backed endpoints
(kv) emulate it by advancing past skipped items with `Select: COUNT` queries
— no item data is transferred, but read capacity is still consumed for
everything skipped, and the caller is metered for it. **Offset is supported
for parity, not recommended** — cost grows linearly with the offset, so it is
capped (kv: 5000). Use cursors.
## Totals (`includeTotal`)
- SQL: `SELECT COUNT(*)` with the same WHERE clause as the listing.
- DynamoDB: a `Select: COUNT` loop over the query (TTL-filtered), metered to
the caller. Cost is proportional to the total item count — request the
total on the first page only, not every page.
- Where visibility is decided per-actor after the query (protected apps in
the catalog listing), `total` approximates the visible set: it counts
non-protected plus caller-owned rows, and misses rows visible only through
explicit permission grants.
## fetchUntilFull (kv only)
DynamoDB applies `Limit` before its filter expression, so TTL-filtered pages
are structurally short. `fetchUntilFull: true` makes the backend keep
fetching (bounded number of continuation queries) until the page holds
`limit` items or the keyset is exhausted. Requires `limit`. If the bound is
hit, the response simply carries a cursor — still convention-legal.
## Adding pagination to a new endpoint
1. Use `encodeCursor`/`decodeCursor`/`normalizeLimit`/`normalizeOffset` from
`src/backend/util/pagination.ts`.
2. Push equality filters into the query so pages and counts operate on the
true result set; only genuinely per-actor filtering may remain post-query
(short-pages rule covers it).
3. Fetch `limit + 1` rows to detect whether a next page exists (SQL), or use
`LastEvaluatedKey` (DynamoDB).
4. Keep the no-params request returning the legacy full result.
+75 -19
View File
@@ -1,6 +1,6 @@
# Self-Hosting Puter
`docker-compose.yml` brings up Puter **plus every external service it needs** — MariaDB, Valkey, DynamoDB-local, RustFS S3, nginx — wired together. Closest thing to a production deployment you can self-manage on a single host.
`docker-compose.yml` brings up Puter **plus every external service it needs** — MariaDB, Valkey, DynamoDB-local, RustFS S3, Caddy — wired together. Closest thing to a production deployment you can self-manage on a single host.
## One-line installer (recommended)
@@ -8,7 +8,7 @@
curl -fsSL https://raw.githubusercontent.com/HeyPuter/puter/main/install.sh | sh
```
Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker-compose.yml` from the OSS repo, and runs `docker compose up -d`. Re-running is safe — it won't overwrite existing config (set `PUTER_FORCE=1` to rotate). Use the manual steps below if you want to inspect or tweak each step yourself.
Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker-compose.yml` + `caddy/Caddyfile` from the OSS repo, and runs `docker compose up -d`. Re-running is safe — it won't overwrite existing config (set `PUTER_FORCE=1` to rotate). Use the manual steps below if you want to inspect or tweak each step yourself.
## Requirements
@@ -20,7 +20,7 @@ Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker
| Container | Image | Role |
| --------------- | ------------------------ | ---------------------------------------------------------- |
| `puter-nginx` | `nginx:1.27-alpine` | Reverse proxy on 80 (and 443 if TLS); forwards to Puter |
| `puter-caddy` | `caddy:2.11-alpine` | Reverse proxy on 80 (and 443 if TLS); forwards to Puter |
| `puter` | `ghcr.io/heyputer/puter` | The app |
| `puter-mariadb` | `mariadb:11` | SQL database — schema applied automatically on first boot |
| `puter-valkey` | `valkey/valkey:8-alpine` | Redis-compatible cache + rate-limiter |
@@ -47,7 +47,6 @@ State lives under `./puter/data/<service>/`.
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
MARIADB_PASSWORD=$(openssl rand -hex 32)
S3_SECRET_KEY=$(openssl rand -hex 32)
JWT_SECRET=$(openssl rand -hex 64)
JWT_SECRET_V2=$(openssl rand -hex 64)
URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
@@ -65,7 +64,7 @@ S3_SECRET_KEY=$S3_SECRET_KEY
S3_BUCKET=puter-local
EOF
mkdir -p puter/config puter/data puter/tls
mkdir -p puter/config puter/data puter/data/caddy puter/tls
cat > puter/config/config.json <<EOF
{
"domain": "puter.localhost",
@@ -78,9 +77,7 @@ cat > puter/config/config.json <<EOF
"private_app_hosting_domain": "app.puter.localhost",
"private_app_hosting_domain_alt": "dev.puter.localhost",
"jwt_secret": "$JWT_SECRET",
"jwt_secret_v2": "$JWT_SECRET_V2",
"allow_v1_tokens": true,
"url_signature_secret": "$URL_SIGNATURE_SECRET",
"database": {
@@ -134,21 +131,21 @@ Replace `puter.localhost`, `site.puter.localhost`, `host.puter.localhost`, `dev.
Why these knobs:
- `jwt_secret` + `jwt_secret_v2` — Puter signs new auth tokens with `jwt_secret_v2` (v2 token format, `kid: 'v2'` JWT header). `jwt_secret` is verify-only and lets tokens minted before this version (cookies still in users' browsers, stored API tokens) keep working. `allow_v1_tokens: true` keeps that fallback enabled. Fresh installs can leave it as-is; future versions will flip the flag to `false` and retire `jwt_secret` entirely once legacy tokens have drained.
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
- `database.migrationPaths` — Puter applies the bundled MySQL schema on boot. `mysql_mig_1.sql` (tables) and `mysql_mig_2.sql` (default apps: editor, viewer, pdf, camera, player, recorder, git, dev-center, puter-linux). Idempotent — safe to re-run.
- `jwt_secret_v2` — the HMAC secret Puter signs and verifies auth tokens with (`kid: 'v2'` JWT header). The pre-v2 token format is retired: a token signed with the old `jwt_secret` no longer verifies, and holders are asked to sign in again. If you are upgrading from a release that had `jwt_secret`, drop it from your config — it is ignored.
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start:gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
- `database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
- `dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
- `providers.ollama.enabled: false` — Puter auto-probes a local Ollama at `127.0.0.1:11434` by default; without one running you'd see `ECONNREFUSED` on every boot. To run a bundled Ollama, see [Optional: local LLM (Ollama)](#optional-local-llm-ollama) below.
- `s3.s3Config.forcePathStyle: true` — RustFS / MinIO / fauxqs need path-style URLs (`<endpoint>/<bucket>`). Real AWS S3 wants virtual-hosted (`<bucket>.<endpoint>`) — drop this flag (or set `false`) when you swap to real S3.
- `s3.s3Config.publicEndpoint` — `endpoint` (`http://s3:9000`) only resolves inside the docker network; presigned upload/download URLs handed to the browser need a host-reachable URL. nginx routes the `s3.<domain>` subdomain to RustFS internally and preserves the Host header end-to-end (required for S3 signature validation), so the browser hits the same port/protocol as the rest of the app — no separate published port, no mixed-content surprises when you turn on TLS. Switch to `https://s3.<your-domain>` once you enable TLS in Step 3. Real AWS S3 doesn't need this — its endpoint is already public; drop the field entirely.
- `trust_proxy: 1` — nginx terminates TLS and forwards `X-Forwarded-For`. Without this, `req.ip` is the docker-network address of the nginx container instead of the real client IP, which breaks rate limiting and IP-based audit logs. `1` = one trusted hop (nginx). Bump to `2` if you put Cloudflare in front of nginx; never set `true` (it trusts every hop and makes XFF forgeable).
- `s3.s3Config.publicEndpoint` — `endpoint` (`http://s3:9000`) only resolves inside the docker network; presigned upload/download URLs handed to the browser need a host-reachable URL. Caddy routes the `s3.<domain>` subdomain to RustFS internally and preserves the Host header end-to-end (required for S3 signature validation), so the browser hits the same port/protocol as the rest of the app — no separate published port, no mixed-content surprises when you turn on TLS. Switch to `https://s3.<your-domain>` once you enable TLS in Step 3. Real AWS S3 doesn't need this — its endpoint is already public; drop the field entirely.
- `trust_proxy: 1` — Caddy terminates TLS and forwards `X-Forwarded-For`. Without this, `req.ip` is the docker-network address of the Caddy container instead of the real client IP, which breaks rate limiting and IP-based audit logs. `1` = one trusted hop (Caddy). Bump to `2` if you put Cloudflare in front of Caddy; never set `true` (it trusts every hop and makes XFF forgeable).
> If you ever change `MARIADB_PASSWORD` after first boot, `.env` alone won't update MariaDB — its credentials are baked into `./puter/data/mariadb/` on first init. Either rotate the password inside MariaDB by hand or `docker compose down && rm -rf ./puter/data/mariadb` to start fresh.
## Step 2 — Point DNS at the server \[Optional\]
In your DNS provider, add records for the main domain plus the subdomains Puter and nginx route on (`api.*`, `site.*`, `app.*`, `s3.*`):
In your DNS provider, add records for the main domain plus the subdomains Puter and Caddy route on (`api.*`, `site.*`, `app.*`, `s3.*`):
```
A puter.localhost → <your server's public IP>
@@ -163,7 +160,7 @@ A dev.puter.localhost → <your server's public IP>
A *.dev.puter.localhost → <your server's public IP>
```
The wildcards are required — Puter routes via subdomains (`api.*`, `app.*`, etc.) and nginx routes browser S3 traffic via `s3.*` to RustFS.
The wildcards are required — Puter routes via subdomains (`api.*`, `app.*`, etc.) and Caddy routes browser S3 traffic via `s3.*` to RustFS.
## Step 3 — TLS (recommended for public installs) \[Optional\]
@@ -182,13 +179,15 @@ sudo certbot certonly --manual --preferred-challenges dns \
The cert needs to cover `*.puter.localhost` so that `s3.puter.localhost` (browser S3 endpoint), plus Puter's own `api.*` / `app.*` subdomains, all validate.
> **Why not Caddy's automatic HTTPS?** Caddy issues certs over ACME by itself, but only for hostnames it knows up front, and only over HTTP-01 with the stock image. Puter serves every user's site and app on a subdomain it invents at runtime (`<name>.site.<domain>`, `<name>.app.<domain>`), which needs a **wildcard** cert — and wildcards require DNS-01, which requires a DNS-provider plugin that isn't in `caddy:2.11-alpine`. So `caddy/Caddyfile` sets `auto_https off` and reads the cert you supply below. If you'd rather have Caddy manage certs, build an image with the plugin for your DNS provider (`xcaddy build --with github.com/caddy-dns/<provider>`) and swap the `tls` line for a `tls { dns <provider> … }` block.
Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
**Wire nginx to use them:**
**Wire Caddy to use them:**
1. Open [nginx/nginx.conf](../nginx/nginx.conf), uncomment **both** `# server { listen 443 ssl … }` blocks (one for `s3.*`, one for the catch-all).
2. (Optional) Replace the body of the port-80 blocks with `return 301 https://$host$request_uri;` to force HTTPS everywhere.
3. In [docker-compose.yml](../docker-compose.yml), uncomment the `443:443` port mapping under the `nginx` service.
1. Open [caddy/Caddyfile](../caddy/Caddyfile) and uncomment the `# :443 { … }` block at the bottom.
2. (Optional but recommended) Replace the plain `:80 { import puter_routes }` block with the `redir` version shown alongside it, to force HTTPS everywhere.
3. In [docker-compose.yml](../docker-compose.yml), uncomment the `443:443` port mapping under the `caddy` service.
4. In `.env`, uncomment `HTTPS_PORT=443`.
5. In `config.json`, switch:
```json
@@ -199,8 +198,36 @@ Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
"s3": { "s3Config": { "publicEndpoint": "https://s3.puter.local", ... } }
```
## Running behind your own reverse proxy
The bundled `puter-caddy` mirrors production and is the supported default — it terminates TLS (Step 3) and forwards every Host to Puter. But plenty of self-hosters already run their own edge proxy (Traefik, nginx, HAProxy, a cloud load balancer, another Caddy). You can put Puter behind it instead; Puter doesn't terminate TLS itself in any setup, so "your proxy does TLS" is just a matter of pointing it at the Puter container and getting a handful of details right.
You can keep the bundled Caddy as the single hop your proxy talks to, or bypass it and forward straight to the Puter container on port `4100` (uncomment the `4100:4100` mapping under the `puter` service in [docker-compose.yml](../docker-compose.yml), or attach your proxy to the compose network). Either works — what matters is the rules below.
**The rules that actually matter** (getting any of these wrong is what causes the redirect loops and "Invalid Host header" failures people hit):
1. **Don't rewrite the `Host` header.** Puter routes entirely on Host — `api.<domain>`, `site.<domain>`, `app.<domain>` are all distinguished by the incoming host. Forward the original host through unchanged. Do **not** rewrite external hostnames to an internal name like `puter.local`; that forces you to remap every subdomain by hand and still breaks signed-URL and CORS checks. Most proxies preserve Host by default — just don't override it.
2. **The external domain must equal `domain` in your `config.json`.** If users reach the box at `puter.example.com`, then `domain` must be `puter.example.com` and the hosting domains must be its real subdomains (`site.puter.example.com`, `app.puter.example.com`, …) — exactly as the installer/`config.json` lays them out. Puter rejects hosts it doesn't recognize with `Invalid Host header`.
3. **Set `protocol` to match the public scheme.** When your proxy terminates TLS, set `"protocol": "https"` (installer: `PUTER_PROTOCOL=https`). Puter builds its origins, redirects, signed S3 URLs, and OIDC callback URLs from this — leave it `http` behind an HTTPS proxy and you get redirect loops, broken logins, and mixed-content errors.
4. **Forward the standard proxy headers.** Puter needs:
- `Host` — the original request host (rule 1).
- `X-Forwarded-Proto` — the **external** scheme (`https`), so Puter knows TLS was terminated upstream.
- `X-Forwarded-For` — the real client IP (rate limiting + audit logs).
- `Upgrade` / `Connection` — passed through for WebSocket / socket.io upgrades, or the realtime connection silently fails.
5. **Set `trust_proxy` to the number of hops in front of Puter.** One external proxy talking directly to Puter → `"trust_proxy": 1` (installer: `PUTER_TRUST_PROXY=1`). A second proxy in front (e.g. Cloudflare → your proxy → Puter) → `2`. This is the count of proxies between the client and Puter, **including** the bundled Caddy if you keep it. Too low and `req.ip` becomes a proxy address (breaks rate limiting); never set `true` (it trusts every hop and makes `X-Forwarded-For` forgeable).
6. **Route the wildcard to Puter.** Your proxy must forward `*.<domain>` (covering `api.*`, `app.*`, and `s3.<domain>`) and `*.site.<domain>` (and any other hosting domains) to Puter — same wildcard DNS as Step 2. Puter does the per-subdomain routing internally; the proxy just needs to hand it the traffic with the Host intact.
With those in place the rest of the stack is unchanged — `docker compose up -d` as below.
## Step 4 — Bring it up
Both [docker-compose.yml](../docker-compose.yml) and [caddy/Caddyfile](../caddy/Caddyfile) need to sit next to your `.env` — clone the repo, or copy those two paths out of it. The Caddyfile is bind-mounted read-only; if it's missing, Docker creates a *directory* at that path and `puter-caddy` dies with "not a directory".
```bash
docker compose up -d
```
@@ -232,6 +259,26 @@ Change it in Settings after first login.
All optional. Drop any of the blocks below into `puter/config/config.json` and `docker compose restart puter`. See [config.template.jsonc](../config.template.jsonc) for the full list. Per-key documentation lives in [src/backend/types.ts](../src/backend/types.ts).
### PostgreSQL database
> **Community contribution — expect rough edges.** PostgreSQL support was contributed by the community and is not run by Puter.com production. It boots, applies the bundled schema, and exercises the common user/app/fsentry/session/permission/OIDC flows in the integration tests, but less-traveled SQL call sites may still need porting. If you hit a query that doesn't work on Postgres, please open an issue. For production self-hosting today, MariaDB/MySQL and SQLite are the supported defaults.
The bundled Docker Compose stack still defaults to MariaDB. To use PostgreSQL instead, run a PostgreSQL service yourself, point Puter at it, and use the PostgreSQL migration path:
```json
"database": {
"engine": "postgres",
"host": "postgres",
"port": 5432,
"user": "puter",
"password": "...",
"database": "puter",
"migrationPaths": ["/opt/puter/dist/src/backend/clients/database/migrations/postgres"]
}
```
You may use `"connectionString": "postgres://puter:...@postgres:5432/puter"` instead of the host/user/password fields. Puter only needs normal PostgreSQL connection details and the migration path; CloudNativePG is one possible Kubernetes operator, but no operator-specific manifests are required by Puter.
### Email (SMTP)
Used for password resets, email confirmation, and notifications. Without it those flows silently fail.
@@ -333,6 +380,15 @@ Built-in proof-of-work captcha — no external service needed.
`difficulty` is one of `easy` / `medium` / `hard`.
### Disable new signups
Force visitors to log in with an existing account instead of creating a
temporary or permanent one.
```json
"disable_user_signup": true
```
### Block disposable email TLDs
Only enforced when `env: "prod"`.
@@ -414,7 +470,7 @@ Migrations re-apply idempotently across pulls. Volumes are preserved.
## Troubleshooting
**Site loads but I get "Bad Gateway" / nginx errors.**
**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`).
**Login screen says "admin password not set".**
+23 -13
View File
@@ -2,7 +2,7 @@
# Self-hosted Puter — full stack.
#
# Brings up Puter + every external service it needs:
# - nginx : reverse proxy (mirrors prod ALB; handles TLS + Host fan-out)
# - caddy : reverse proxy (mirrors prod ALB; handles TLS + Host fan-out)
# - valkey : redis-compatible cache / rate-limiter backend
# - mariadb : SQL database (Puter applies its schema on first boot)
# - dynamo : DynamoDB-local (KV store; Puter creates the table itself)
@@ -23,7 +23,8 @@
#
# Production:
# - Always replace the default passwords / S3 keys / Puter secrets.
# - Front Puter with TLS-terminating reverse proxy (Caddy / nginx).
# - Enable TLS on the bundled Caddy (see caddy/Caddyfile) or front the
# stack with your own TLS-terminating proxy.
# - Move state-bearing volumes to a backed-up location.
services:
@@ -118,7 +119,7 @@ services:
RUSTFS_SECRET_KEY: ${S3_SECRET_KEY:-puter-secret-change-me}
volumes:
- ./puter/data/s3:/data:z
# Internal-only — browsers reach RustFS via nginx (`s3.<domain>`),
# Internal-only — browsers reach RustFS via Caddy (`s3.<domain>`),
# which preserves the Host header for S3 signature validation and
# rides the same TLS termination as Puter. Uncomment to also expose
# 9000 directly on the host for `aws-cli` / debugging.
@@ -249,7 +250,7 @@ services:
condition: service_started
s3-init:
condition: service_completed_successfully
# Internal-only: nginx reaches it on the compose network. Uncomment
# Internal-only: Caddy reaches it on the compose network. Uncomment
# to also expose port 4100 directly on the host (useful for debugging).
# ports:
# - "4100:4100"
@@ -264,29 +265,38 @@ services:
# Persistent runtime data (anything your config points at /var/puter).
- ./puter/data/puter:/var/puter:z
healthcheck:
test: wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
test: wget --no-verbose --tries=1 --spider http://localhost:4100/ || exit 1
interval: 30s
timeout: 3s
retries: 3
start_period: 30s
nginx:
image: nginx:1.27-alpine
container_name: puter-nginx
caddy:
image: caddy:2.11-alpine
container_name: puter-caddy
restart: unless-stopped
depends_on:
puter:
condition: service_started
ports:
- "${HTTP_PORT:-80}:80"
# Uncomment when you enable TLS in nginx/nginx.conf:
# Uncomment when you enable TLS in caddy/Caddyfile:
# - "${HTTPS_PORT:-443}:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro,z
# TLS certs (fullchain.pem + privkey.pem). Read-only inside.
- ./puter/tls:/etc/nginx/tls:ro,z
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro,z
# TLS certs (wildcard fullchain.pem + privkey.pem). Read-only inside.
- ./puter/tls:/etc/caddy/tls:ro,z
# Caddy's own state — the cert store it would use if you swap this
# config over to ACME. Kept under ./puter/data with everything else
# so one directory is the whole backup.
- ./puter/data/caddy:/data:z
healthcheck:
test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://localhost/ || exit 1"]
# Hits Caddy's local-only admin API rather than proxying through to
# Puter: this reports whether the proxy itself is up and configured,
# and keeps working once the `:80` block becomes an HTTPS redirect.
# 127.0.0.1, not localhost — the admin endpoint is IPv4-only and
# busybox wget tries ::1 first.
test: ["CMD-SHELL", "wget -qO- --tries=1 --timeout=2 http://127.0.0.1:2019/config/ >/dev/null || exit 1"]
interval: 10s
timeout: 3s
retries: 5
+146 -142
View File
@@ -1,75 +1,92 @@
import type { Request, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import {
afterAll,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { runWithContext } from '../src/backend/core/context.ts';
import { PuterServer } from '../src/backend/server.ts';
import { setupTestServer } from '../src/backend/testUtil.ts';
import {
handleAppTelemetryUserCount,
handleAppTelemetryUsers,
} from './appTelemetry.ts';
interface CapturedResponse {
body: unknown;
}
const makeReq = (query: Record<string, unknown> = {}): Request =>
({ query }) as unknown as Request;
const makeRes = () => {
const captured: CapturedResponse = { body: undefined };
const res = {
json: vi.fn((value: unknown) => {
captured.body = value;
return res;
}),
status: vi.fn(() => res),
};
return { res: res as unknown as Response, captured };
};
// `handleAppTelemetryUsers` calls `Context.get('actor')` for the permission
// gate. Wrap test calls in `runWithContext` so the ALS lookup resolves.
const callWithActor = async (
actor: { user: { uuid: string; id?: number }; app?: null } | undefined,
fn: () => Promise<void>,
) => runWithContext({ actor }, fn);
// Importing the module registers the `appTelemetry` driver into the shared
// extensionStore, so `setupTestServer` instantiates it on `server.drivers`.
import { AppTelemetryDriver } from './appTelemetry.ts';
let server: PuterServer;
let driver: AppTelemetryDriver;
// `get_users` reads `Context.get('actor')` for the ownership gate. Wrap
// actor-dependent calls in `runWithContext` so the ALS lookup resolves.
const callWithActor = <T>(
actor: { user: { uuid: string; id?: number } } | undefined,
fn: () => Promise<T>,
) => runWithContext({ actor }, fn);
beforeAll(async () => {
server = await setupTestServer();
driver = (server.drivers as unknown as Record<string, AppTelemetryDriver>)
.appTelemetry;
// Guard: the driver must have been wired onto the server. If this is
// undefined the extension didn't register, and every test below would
// fail with a confusing "cannot read property of undefined".
expect(driver).toBeInstanceOf(AppTelemetryDriver);
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
describe('appTelemetry extension — handleAppTelemetryUsers', () => {
const seedOwnedApp = async (prefix: string) => {
const owner = await server.stores.user.create({
username: `${prefix}_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'x',
email: null,
});
const slug = Math.random().toString(36).slice(2, 8);
const app = await server.stores.app.create(
{
name: `${prefix}_${slug}`,
title: `${prefix} ${slug}`,
index_url: `https://example.com/${slug}`,
},
{ ownerUserId: owner.id as number },
);
return { owner, app: app! };
};
const seedUser = async (prefix: string, email: string | null = null) =>
server.stores.user.create({
username: `${prefix}_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'x',
email,
});
const grantAuthenticated = async (appId: number, userId: number) => {
await server.clients.db.write(
`INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`,
[userId, appId, 'flag:app-is-authenticated', null],
);
};
const grantEmailRead = async (
appId: number,
userId: number,
userUuid: string,
) => {
await server.clients.db.write(
`INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`,
[userId, appId, `user:${userUuid}:email:read`, null],
);
};
describe('appTelemetry driver — get_users', () => {
it('throws HttpError(400) when app_uuid is missing', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUsers(makeReq({}), res),
).rejects.toMatchObject({
await expect(driver.get_users({})).rejects.toMatchObject({
statusCode: 400,
message: expect.stringContaining('app_uuid'),
});
});
it('throws HttpError(400) for a non-numeric limit', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUsers(
makeReq({ app_uuid: 'app-anything', limit: 'banana' }),
res,
),
driver.get_users({ app_uuid: 'app-anything', limit: 'banana' }),
).rejects.toMatchObject({
statusCode: 400,
message: expect.stringContaining('limit'),
@@ -77,22 +94,14 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
});
it('throws HttpError(400) when offset exceeds the allowed maximum', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUsers(
makeReq({ app_uuid: 'app-anything', offset: 1_000_001 }),
res,
),
driver.get_users({ app_uuid: 'app-anything', offset: 1_000_001 }),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws HttpError(404) when the app cannot be found', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUsers(
makeReq({ app_uuid: 'app-does-not-exist' }),
res,
),
driver.get_users({ app_uuid: 'app-does-not-exist' }),
).rejects.toMatchObject({
statusCode: 404,
message: 'App not found',
@@ -100,25 +109,10 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
});
it('throws HttpError(403) when the caller does not own the app', async () => {
// Seed an owner user + an app belonging to them, then call the
// handler as a *different* user. The real permission service should
// reject since the caller has no `apps-of-user:<owner>:write` perm.
const owner = await server.stores.user.create({
username: `owner_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'x',
email: null,
});
const slug = Math.random().toString(36).slice(2, 8);
const app = await server.stores.app.create(
{
name: `app_${slug}`,
title: `App ${slug}`,
index_url: `https://example.com/${slug}`,
},
{ ownerUserId: owner.id as number },
);
// Seed an owner + their app, then call as a *different* user. The
// real permission service should reject: the caller has no
// `apps-of-user:<owner>:write`.
const { app } = await seedOwnedApp('owner');
const intruder = await server.stores.user.create({
username: `intruder_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
@@ -126,15 +120,11 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
email: null,
});
const { res } = makeRes();
await callWithActor(
{ user: { uuid: intruder.uuid, id: intruder.id as number } },
async () => {
await expect(
handleAppTelemetryUsers(
makeReq({ app_uuid: app!.uid }),
res,
),
driver.get_users({ app_uuid: app.uid }),
).rejects.toMatchObject({
statusCode: 403,
message: 'Permission denied',
@@ -144,78 +134,92 @@ describe('appTelemetry extension — handleAppTelemetryUsers', () => {
});
it('returns an empty list for an owned app with no authenticated users', async () => {
const owner = await server.stores.user.create({
username: `owner2_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'x',
email: null,
});
const slug = Math.random().toString(36).slice(2, 8);
const app = await server.stores.app.create(
{
name: `app2_${slug}`,
title: `App2 ${slug}`,
index_url: `https://example.com/${slug}`,
},
{ ownerUserId: owner.id as number },
);
const { owner, app } = await seedOwnedApp('owner2');
const { res, captured } = makeRes();
await callWithActor(
const result = await callWithActor(
{ user: { uuid: owner.uuid, id: owner.id as number } },
async () => {
await handleAppTelemetryUsers(
makeReq({ app_uuid: app!.uid }),
res,
);
},
() => driver.get_users({ app_uuid: app.uid }),
);
expect(captured.body).toEqual([]);
expect(result).toEqual([]);
});
it('omits user_email for a user who did not grant email:read', async () => {
const { owner, app } = await seedOwnedApp('noemail');
const member = await seedUser('member', '[email protected]');
await grantAuthenticated(app.id as number, member.id as number);
const [row] = (await callWithActor(
{ user: { uuid: owner.uuid, id: owner.id as number } },
() => driver.get_users({ app_uuid: app.uid }),
)) as Array<Record<string, unknown>>;
expect(row.user).toBe(member.username);
expect(row.user_uuid).toBe(member.uuid);
// No grant → the field must be absent (not just null), so the email
// never leaks to the app owner.
expect(Object.prototype.hasOwnProperty.call(row, 'user_email')).toBe(
false,
);
});
it('returns user_email when the user granted email:read to the app', async () => {
const { owner, app } = await seedOwnedApp('withemail');
const member = await seedUser('member', '[email protected]');
await grantAuthenticated(app.id as number, member.id as number);
await grantEmailRead(
app.id as number,
member.id as number,
member.uuid,
);
const [row] = (await callWithActor(
{ user: { uuid: owner.uuid, id: owner.id as number } },
() => driver.get_users({ app_uuid: app.uid }),
)) as Array<Record<string, unknown>>;
expect(row.user_uuid).toBe(member.uuid);
expect(row.user_email).toBe('[email protected]');
});
it('does not leak email granted to a *different* app', async () => {
const { owner, app } = await seedOwnedApp('appA');
const { app: otherApp } = await seedOwnedApp('appB');
const member = await seedUser('member', '[email protected]');
await grantAuthenticated(app.id as number, member.id as number);
// Grant email:read against the OTHER app only.
await grantEmailRead(
otherApp.id as number,
member.id as number,
member.uuid,
);
const [row] = (await callWithActor(
{ user: { uuid: owner.uuid, id: owner.id as number } },
() => driver.get_users({ app_uuid: app.uid }),
)) as Array<Record<string, unknown>>;
expect(Object.prototype.hasOwnProperty.call(row, 'user_email')).toBe(
false,
);
});
});
describe('appTelemetry extension — handleAppTelemetryUserCount', () => {
describe('appTelemetry driver — user_count', () => {
it('throws HttpError(400) when app_uuid is missing', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUserCount(makeReq({}), res),
).rejects.toMatchObject({ statusCode: 400 });
await expect(driver.user_count({})).rejects.toMatchObject({
statusCode: 400,
});
});
it('throws HttpError(404) for an unknown app_uuid', async () => {
const { res } = makeRes();
await expect(
handleAppTelemetryUserCount(
makeReq({ app_uuid: 'app-not-here' }),
res,
),
driver.user_count({ app_uuid: 'app-not-here' }),
).rejects.toMatchObject({ statusCode: 404 });
});
it('returns { count: 0 } for an app with no authenticated users', async () => {
const owner = await server.stores.user.create({
username: `counto_${Math.random().toString(36).slice(2, 8)}`,
uuid: uuidv4(),
password: 'x',
email: null,
});
const slug = Math.random().toString(36).slice(2, 8);
const app = await server.stores.app.create(
{
name: `appcount_${slug}`,
title: `AppCount ${slug}`,
index_url: `https://example.com/${slug}`,
},
{ ownerUserId: owner.id as number },
);
const { res, captured } = makeRes();
await handleAppTelemetryUserCount(
makeReq({ app_uuid: app!.uid }),
res,
);
expect(captured.body).toEqual({ count: 0 });
it('returns 0 for an app with no authenticated users', async () => {
const { app } = await seedOwnedApp('appcount');
await expect(driver.user_count({ app_uuid: app.uid })).resolves.toBe(0);
});
});
+119 -80
View File
@@ -1,11 +1,16 @@
import type { Request, Response } from 'express';
import { Context } from '@heyputer/backend/src/core';
import type { Actor } from '@heyputer/backend/src/core/actor';
import { HttpError } from '@heyputer/backend/src/core/http';
import { PuterDriver } from '@heyputer/backend/src/drivers/types';
import { extension } from '@heyputer/backend/src/extensions';
const clients = extension.import('client');
const stores = extension.import('store');
const services = extension.import('service');
// App-telemetry lets an app owner enumerate the users who have
// authenticated into their app. v1 shipped this as a driver on the
// `app-telemetry` interface (methods `get_users` / `user_count`) and
// puter-js's `puter.apps(...).getUsers()` still calls it that way
// (`puter.drivers.call('app-telemetry', 'app-telemetry', 'get_users', …)`).
// This is the v2 port of that driver — same interface/method/return shapes
// so existing puter-js callers work unchanged.
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
@@ -41,91 +46,125 @@ const parseIntParam = (
return parsed;
};
export const handleAppTelemetryUsers = async (
req: Request,
res: Response,
): Promise<void> => {
const { app_uuid } = req.query as Record<string, string>;
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
/**
* Driver exposing the `app-telemetry` interface.
*
* The `/drivers/call` permission gate checks
* `service:app-telemetry:ii:app-telemetry`, which every actor already holds
* via the blanket `service` grant (hardcoded-permissions.js +
* `default_implicit_user_app_permissions`). The real authorization — "is the
* caller the app owner?" — is enforced inside `get_users` below, exactly as
* v1 did.
*/
export class AppTelemetryDriver extends PuterDriver {
readonly driverInterface = 'app-telemetry';
readonly driverName = 'app-telemetry';
readonly isDefault = true;
const safeLimit = parseIntParam(req.query.limit, {
key: 'limit',
min: 1,
max: MAX_LIMIT,
fallback: DEFAULT_LIMIT,
});
const safeOffset = parseIntParam(req.query.offset, {
key: 'offset',
min: 0,
max: MAX_OFFSET,
fallback: 0,
});
/** Users who have authenticated into the given app (owner-only). */
async get_users({
app_uuid,
limit,
offset,
}: {
app_uuid?: string;
limit?: unknown;
offset?: unknown;
} = {}): Promise<
Array<{ user: string; user_uuid: string; user_email?: string | null }>
> {
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
const app = await stores.app.getByUid(app_uuid);
if (!app) throw new HttpError(404, 'App not found');
const safeLimit = parseIntParam(limit, {
key: 'limit',
min: 1,
max: MAX_LIMIT,
fallback: DEFAULT_LIMIT,
});
const safeOffset = parseIntParam(offset, {
key: 'offset',
min: 0,
max: MAX_OFFSET,
fallback: 0,
});
// `apps-of-user:<uuid>:write` — the implicator keys on the owner's
// UUID, not the numeric id. Look up the owner explicitly. v1 got
// this for free because its entity-storage layer eager-joined the
// owner row; v2's AppStore.getByUid returns the raw row with only
// `owner_user_id` populated.
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
if (!ownerId) throw new HttpError(404, 'App owner not found');
const owner = (await stores.user.getById(ownerId)) as {
uuid?: string;
} | null;
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
const app = await this.stores.app.getByUid(app_uuid);
if (!app) throw new HttpError(404, 'App not found');
const actor = Context.get('actor');
const ownsApp = await services.permission
.check(actor!, `apps-of-user:${owner.uuid}:write`)
.catch(() => false);
if (!ownsApp) throw new HttpError(403, 'Permission denied');
// The `apps-of-user:<uuid>:write` implicator keys on the owner's
// UUID, not the numeric id. Look up the owner explicitly — the raw
// app row only carries `owner_user_id`. (v1 got the owner for free
// because its entity-storage layer eager-joined the owner row.)
const ownerId = (app as { owner_user_id?: number }).owner_user_id;
if (!ownerId) throw new HttpError(404, 'App owner not found');
const owner = await this.stores.user.getById(ownerId);
if (!owner?.uuid) throw new HttpError(404, 'App owner not found');
const users = await clients.db.read(
`SELECT u.username, u.uuid FROM user_to_app_permissions p
INNER JOIN user u ON p.user_id = u.id
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
LIMIT ? OFFSET ?`,
[(app as Record<string, unknown>).id, safeLimit, safeOffset],
);
const actor = Context.get('actor');
if (!actor) throw new HttpError(401, 'Authentication required');
const ownsApp = await this.services.permission
.check(actor as Actor, `apps-of-user:${owner.uuid}:write`)
.catch(() => false);
if (!ownsApp) throw new HttpError(403, 'Permission denied');
res.json(
(users as Array<{ username: string; uuid: string }>).map((e) => ({
user: e.username,
user_uuid: e.uuid,
})),
);
};
const appId = (app as { id: number }).id;
export const handleAppTelemetryUserCount = async (
req: Request,
res: Response,
): Promise<void> => {
const { app_uuid } = req.query as Record<string, string>;
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
const users = (await this.clients.db.read(
`SELECT u.id, u.username, u.uuid, u.email FROM user_to_app_permissions p
INNER JOIN ${this.clients.db.quoteIdentifier('user')} u ON p.user_id = u.id
WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ?
ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id
LIMIT ? OFFSET ?`,
[appId, safeLimit, safeOffset],
)) as Array<{
id: number;
username: string;
uuid: string;
email: string | null;
}>;
const app = await stores.app.getByUid(app_uuid);
if (!app) throw new HttpError(404, 'App not found');
// Only surface a user's email if *that user* granted this app the
// `user:<their-uuid>:email:read` permission — the same grant
// `puter.perms.requestEmail()` obtains and `whoami` honours. This is a
// per-user check keyed on the app (not the calling owner-actor): a
// user may have authenticated into the app without sharing their
// email. Resolve the whole page in one query.
const emailPermitted = new Set<number>();
if (users.length > 0) {
const permStrings = users.map((u) => `user:${u.uuid}:email:read`);
const placeholders = permStrings.map(() => '?').join(', ');
const grants = (await this.clients.db.read(
`SELECT user_id FROM user_to_app_permissions
WHERE app_id = ? AND permission IN (${placeholders})`,
[appId, ...permStrings],
)) as Array<{ user_id: number }>;
for (const g of grants) emailPermitted.add(g.user_id);
}
const [row] = (await clients.db.read(
`SELECT COUNT(*) AS n FROM user_to_app_permissions
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
[(app as Record<string, unknown>).id],
)) as Array<{ n: number }>;
return users.map((e) =>
emailPermitted.has(e.id)
? { user: e.username, user_uuid: e.uuid, user_email: e.email }
: { user: e.username, user_uuid: e.uuid },
);
}
res.json({ count: row?.n ?? 0 });
};
/** Count of users who have authenticated into the given app. */
async user_count({
app_uuid,
}: { app_uuid?: string } = {}): Promise<number> {
if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`');
extension.get(
'/app-telemetry/users',
{ subdomain: 'api', requireAuth: true },
handleAppTelemetryUsers,
);
const app = await this.stores.app.getByUid(app_uuid);
if (!app) throw new HttpError(404, 'App not found');
extension.get(
'/app-telemetry/user-count',
{ subdomain: 'api', requireAuth: true },
handleAppTelemetryUserCount,
);
const [row] = (await this.clients.db.read(
`SELECT COUNT(*) AS n FROM user_to_app_permissions
WHERE permission = 'flag:app-is-authenticated' AND app_id = ?`,
[(app as { id: number }).id],
)) as Array<{ n: number }>;
return row?.n ?? 0;
}
}
extension.registerDriver('appTelemetry', AppTelemetryDriver);
+555
View File
@@ -0,0 +1,555 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import { extensionStore } from '../src/backend/extensions.ts';
import type { IConfig } from '../src/backend/types';
import './devWatcher.ts';
type Lifecycle = {
onServerStart: () => Promise<void>;
onServerShutdown: () => Promise<void>;
};
// The extension registers itself on import; that registry entry is the
// only handle on the service class.
const DevWatcherService = extensionStore.services.devWatcher as unknown as new (
config: IConfig,
clients: unknown,
stores: unknown,
services: unknown,
) => Lifecycle;
const makeService = (config: Record<string, unknown>): Lifecycle =>
new DevWatcherService(config as unknown as IConfig, {}, {}, {});
let workdir: string;
const write = (relative: string, contents: string): string => {
const abs = path.join(workdir, relative);
writeFileSync(abs, contents);
return abs;
};
/** Poll until `predicate` holds or the budget runs out. */
const waitFor = async (
predicate: () => boolean,
label: string,
timeoutMs = 15_000,
): Promise<void> => {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (predicate()) return;
await new Promise((r) => setTimeout(r, 20));
}
throw new Error(`timed out waiting for ${label}`);
};
const logLines = (spy: { mock: { calls: unknown[][] } }): string[] =>
spy.mock.calls.map((call) => call.map(String).join(' '));
describe('devWatcher extension', () => {
let log: ReturnType<typeof vi.spyOn>;
let warn: ReturnType<typeof vi.spyOn>;
let error: ReturnType<typeof vi.spyOn>;
beforeAll(() => {
workdir = mkdtempSync(path.join(tmpdir(), 'devwatch-test-'));
});
afterAll(() => {
rmSync(workdir, { recursive: true, force: true });
delete (extensionStore.services as Record<string, unknown>).devWatcher;
});
beforeEach(() => {
log = vi.spyOn(console, 'log').mockImplementation(() => {});
warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
error = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
log.mockRestore();
warn.mockRestore();
error.mockRestore();
});
const started = () =>
logLines(log).some((line) =>
line.startsWith('[devwatch] starting watchers from'),
);
// -- start gating -------------------------------------------------
it('does nothing when the server is not running in dev', async () => {
await makeService({ env: 'production' }).onServerStart();
expect(started()).toBe(false);
});
it('does nothing when devwatch is explicitly disabled', async () => {
await makeService({
env: 'dev',
devwatch: { enabled: false },
}).onServerStart();
expect(started()).toBe(false);
});
it('does nothing when the server opts out with no_devwatch', async () => {
await makeService({
env: 'dev',
no_devwatch: true,
devwatch: { enabled: true },
}).onServerStart();
expect(started()).toBe(false);
});
it('starts in dev even with no explicit devwatch block', async () => {
const service = makeService({
env: 'dev',
devwatch: {
root: workdir,
commands: [],
webpack: [],
ready_delay_ms: 0,
},
});
await service.onServerStart();
expect(started()).toBe(true);
await service.onServerShutdown();
});
it('starts outside dev when devwatch is explicitly enabled', async () => {
const service = makeService({
env: 'production',
devwatch: {
enabled: true,
root: workdir,
commands: [],
webpack: [],
ready_delay_ms: 0,
},
});
await service.onServerStart();
expect(started()).toBe(true);
await service.onServerShutdown();
});
it('ignores a devwatch config that is not an object', async () => {
// Falls back to `{}`, so the dev-env rule decides — and the
// default webpack entries would be used, so pin it off instead.
await makeService({
env: 'production',
devwatch: 'yes',
}).onServerStart();
expect(started()).toBe(false);
});
it('only starts once even if the lifecycle hook fires again', async () => {
const service = makeService({
env: 'dev',
devwatch: {
root: workdir,
commands: [],
webpack: [],
ready_delay_ms: 0,
},
});
await service.onServerStart();
await service.onServerStart();
expect(
logLines(log).filter((line) =>
line.startsWith('[devwatch] starting watchers from'),
),
).toHaveLength(1);
await service.onServerShutdown();
});
it('waits out the configured ready delay before resolving', async () => {
const service = makeService({
env: 'dev',
devwatch: {
root: workdir,
commands: [],
webpack: [],
ready_delay_ms: 60,
},
});
const start = Date.now();
await service.onServerStart();
expect(Date.now() - start).toBeGreaterThanOrEqual(50);
await service.onServerShutdown();
});
// -- child commands -----------------------------------------------
it('spawns a command, line-buffers its output and flushes the tail', async () => {
write(
'chatty.js',
[
"process.stdout.write('one\\ntwo\\n');",
"process.stderr.write('bad thing\\n');",
// No trailing newline: only the stream `end` flushes it.
"process.stdout.write('tail-no-newline');",
].join('\n'),
);
const service = makeService({
env: 'dev',
devwatch: {
root: workdir,
commands: [
{
name: 'chatty',
directory: '.',
command: 'node',
args: ['chatty.js'],
},
],
webpack: [],
ready_delay_ms: 0,
},
});
await service.onServerStart();
await waitFor(
() =>
logLines(log).some((l) =>
l.includes('[devwatch:chatty:1] tail-no-newline'),
),
'child stdout',
);
const lines = logLines(log);
expect(lines).toContain('[devwatch:chatty:1] one');
expect(lines).toContain('[devwatch:chatty:1] two');
expect(logLines(warn)).toContain('[devwatch:chatty:2] bad thing');
await waitFor(
() =>
logLines(log).some((l) =>
l.includes('[devwatch:chatty:exit] process exited'),
),
'child exit',
);
await service.onServerShutdown();
});
it('passes literal and computed env values to the child', async () => {
write(
'env.js',
'process.stdout.write(`STATIC=${process.env.STATIC_VALUE} ORIGIN=${process.env.FROM_CONFIG} MISSING=${process.env.BLOWS_UP}\\n`);',
);
const service = makeService({
env: 'dev',
origin: 'http://puter.localhost:4100',
devwatch: {
root: workdir,
commands: [
{
name: 'envtest',
directory: '.',
command: 'node',
args: ['env.js'],
env: {
STATIC_VALUE: 'literal',
FROM_CONFIG: ({
global_config,
}: {
global_config: Record<string, unknown> | null;
}) => String(global_config?.origin ?? ''),
// Reading through a null is the "config not
// loaded yet" shape the extension deliberately
// stays quiet about.
BLOWS_UP: () => {
const nothing = null as unknown as {
x: string;
};
return nothing.x;
},
NOISY: () => {
throw new Error('unexpected failure');
},
},
},
],
webpack: [],
ready_delay_ms: 0,
},
});
await service.onServerStart();
await waitFor(
() => logLines(log).some((l) => l.includes('STATIC=literal')),
'env output',
);
const line = logLines(log).find((l) => l.includes('STATIC=literal'))!;
expect(line).toContain('ORIGIN=http://puter.localhost:4100');
expect(line).toContain('MISSING=undefined');
// A null-property read is expected noise and stays silent; any
// other failure is reported.
const warnings = logLines(warn);
expect(
warnings.some((w) => w.includes('could not evaluate env function')),
).toBe(true);
expect(warnings.some((w) => w.includes('for BLOWS_UP'))).toBe(false);
expect(warnings.some((w) => w.includes('for NOISY'))).toBe(true);
await service.onServerShutdown();
});
it('kills a still-running child on shutdown', async () => {
write('forever.js', 'setInterval(() => {}, 1000);');
const service = makeService({
env: 'dev',
devwatch: {
root: workdir,
commands: [
{
name: 'forever',
directory: '.',
command: 'node',
args: ['forever.js'],
},
],
webpack: [],
ready_delay_ms: 0,
},
});
await service.onServerStart();
await service.onServerShutdown();
expect(logLines(log)).toContain('[devwatch:forever] stopping process');
await waitFor(
() =>
logLines(log).some((l) =>
l.includes('[devwatch:forever:exit] process exited'),
),
'child killed',
);
});
// -- webpack watchers ---------------------------------------------
const makeWebpackProject = (
name: string,
configFile: string,
contents: string,
packageJson?: string,
): string => {
const dir = path.join(workdir, name);
rmSync(dir, { recursive: true, force: true });
writeFileSync(
path.join(
(() => {
const { mkdirSync } =
require('node:fs') as typeof import('node:fs');
mkdirSync(path.join(dir, 'src'), { recursive: true });
return dir;
})(),
'src/entry.js',
),
"console.log('hello');\n",
);
writeFileSync(path.join(dir, configFile), contents);
if (packageJson) {
writeFileSync(path.join(dir, 'package.json'), packageJson);
}
return name;
};
it('compiles a CommonJS webpack config and reports later rebuilds', async () => {
const dir = makeWebpackProject(
'cjs-project',
'webpack.config.cjs',
`module.exports = {
mode: 'development',
entry: './src/entry.js',
output: { path: __dirname + '/out', filename: 'bundle.js' },
};`,
);
let onConfigSaw: Record<string, unknown> | undefined;
const service = makeService({
env: 'dev',
devwatch: {
root: workdir,
commands: [],
webpack: [
{
name: 'cjs',
directory: dir,
onConfig: (cfg: Record<string, unknown>) => {
onConfigSaw = cfg;
},
},
],
ready_delay_ms: 0,
},
});
await service.onServerStart();
expect(onConfigSaw).toBeDefined();
// The watcher context is anchored at <root>/<directory> so relative
// entries resolve regardless of the server's cwd.
expect(onConfigSaw!.context).toBe(path.join(workdir, dir));
// First build is silent by design; force a rebuild and assert the
// update line shows up.
await new Promise((r) => setTimeout(r, 500));
writeFileSync(
path.join(workdir, dir, 'src/entry.js'),
"console.log('hello again');\n",
);
await waitFor(
() =>
logLines(log).some((l) =>
l.includes('[devwatch] updated cjs using Webpack'),
),
'webpack rebuild',
);
await service.onServerShutdown();
});
it('resolves an ESM webpack config declared through package.json type', async () => {
const dir = makeWebpackProject(
'esm-project',
'webpack.config.js',
`export default {
mode: 'development',
entry: './src/entry.js',
output: { filename: 'bundle.js' },
};`,
JSON.stringify({ name: 'esm-project', type: 'module' }),
);
const service = makeService({
env: 'dev',
devwatch: {
root: workdir,
commands: [],
webpack: [{ directory: dir }],
ready_delay_ms: 0,
},
});
await service.onServerStart();
await service.onServerShutdown();
// Falling back to the directory as the display name is the
// documented behaviour when `name` is omitted.
expect(error).not.toHaveBeenCalled();
});
it('calls a config exported as a function and honours an explicit context', async () => {
const dir = makeWebpackProject(
'fn-project',
'webpack.config.cjs',
`module.exports = () => ({
mode: 'development',
context: 'src',
entry: './entry.js',
name: process.env.WEBPACK_MARKER,
output: { path: __dirname + '/out', filename: 'bundle.js' },
});`,
);
let seen: Record<string, unknown> | undefined;
const service = makeService({
env: 'dev',
devwatch: {
root: workdir,
commands: [],
webpack: [
{
name: 'fn',
directory: dir,
env: { WEBPACK_MARKER: 'set-during-load' },
onConfig: (cfg: Record<string, unknown>) => {
seen = cfg;
},
},
],
ready_delay_ms: 0,
},
});
await service.onServerStart();
// Relative `context` resolves against <root>/<directory>.
expect(seen!.context).toBe(path.join(workdir, dir, 'src'));
// The entry's env map is applied while the config module runs, and
// restored afterwards.
expect(seen!.name).toBe('set-during-load');
expect(process.env.WEBPACK_MARKER).toBeUndefined();
await service.onServerShutdown();
});
it('reports a failing compilation instead of crashing the server', async () => {
const dir = makeWebpackProject(
'broken-project',
'webpack.config.cjs',
`module.exports = {
mode: 'development',
entry: './src/does-not-exist.js',
output: { path: __dirname + '/out', filename: 'bundle.js' },
};`,
);
const service = makeService({
env: 'dev',
devwatch: {
root: workdir,
commands: [],
webpack: [{ name: 'broken', directory: dir }],
ready_delay_ms: 0,
},
});
await service.onServerStart();
await waitFor(
() =>
logLines(error).some((l) =>
l.includes('[devwatch] failed to update broken'),
),
'webpack failure',
);
expect(
logLines(error).some((l) =>
l.includes('[devwatch] error information: broken'),
),
).toBe(true);
await service.onServerShutdown();
});
it('fails loudly when a directory has no webpack config at all', async () => {
const dir = path.join('no-config');
const { mkdirSync } = await import('node:fs');
mkdirSync(path.join(workdir, dir), { recursive: true });
const service = makeService({
env: 'dev',
devwatch: {
root: workdir,
commands: [],
webpack: [{ name: 'missing', directory: dir }],
ready_delay_ms: 0,
},
});
await expect(service.onServerStart()).rejects.toThrow(
'could not find webpack config for: no-config',
);
await service.onServerShutdown();
});
});
+33 -1
View File
@@ -38,7 +38,7 @@ beforeAll(async () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
const seedUser = async () => {
@@ -132,9 +132,41 @@ describe('installedApps extension — handleInstalledApps', () => {
expect(list[0].uid).toBe(app!.uid);
expect(list[0].name).toBe(app!.name);
expect(list[0].title).toBe(app!.title);
// index_url is required so the dashboard can derive a hostname title
// for anonymous (app-…) apps.
expect(list[0].index_url).toBe(app!.index_url);
expect(Object.prototype.hasOwnProperty.call(list[0], 'iconUrl')).toBe(
true,
);
// An owned app is not external, and the raw owner id must not leak.
expect(list[0].external).toBe(false);
expect(
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
).toBe(false);
});
it('flags apps with no owner_user_id as external', async () => {
const user = await seedUser();
const slug = Math.random().toString(36).slice(2, 8);
// createFromOrigin bootstraps an app with owner_user_id = null.
const app = await server.stores.app.createFromOrigin(
`app-${slug}`,
`https://external-${slug}.example.com`,
);
await grantInstalled(app!.id as number, user.id as number);
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleInstalledApps(makeReq({}), res),
);
const list = captured.body as Array<Record<string, unknown>>;
expect(list).toHaveLength(1);
expect(list[0].external).toBe(true);
expect(
Object.prototype.hasOwnProperty.call(list[0], 'owner_user_id'),
).toBe(false);
});
it('clamps page/limit to safe ranges (page>=1, 1<=limit<=100)', async () => {
+15 -5
View File
@@ -49,6 +49,8 @@ export const handleInstalledApps = async (
apps.title,
apps.description,
apps.icon,
apps.index_url,
apps.owner_user_id,
MIN(perm.dt) AS installed_at
FROM apps
LEFT JOIN user_to_app_permissions AS perm ON apps.id = perm.app_id
@@ -62,15 +64,23 @@ export const handleInstalledApps = async (
const apiBaseUrl = extension.config.api_base_url as string | undefined;
res.json(
installedApps.map((app) => ({
...app,
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
})),
installedApps.map((app) => {
// An app with no owner_user_id (null/empty) isn't owned by a Puter
// user — it's an "external" app. Derive a flag and don't leak the
// raw owner id to the client.
const { owner_user_id, ...rest } = app;
const external = owner_user_id == null || owner_user_id === '';
return {
...rest,
iconUrl: getAppIconUrl(app, { apiBaseUrl }),
external,
};
}),
);
};
extension.get(
'/installedApps',
{ subdomain: 'api', requireUserActor: true },
{ subdomain: 'api', requireUserActor: true, allowFullAccessToken: true },
handleInstalledApps,
);
+1 -1
View File
@@ -48,7 +48,7 @@ beforeAll(async () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
const seedUser = async () => {
+1 -1
View File
@@ -1,4 +1,3 @@
import type { Request, Response } from 'express';
import { Context } from '@heyputer/backend/src/core';
import { HttpError } from '@heyputer/backend/src/core/http';
import {
@@ -6,6 +5,7 @@ import {
driversContainers,
} from '@heyputer/backend/src/exports';
import { extension } from '@heyputer/backend/src/extensions';
import type { Request, Response } from 'express';
const services = extension.import('service');
const clients = extension.import('client');
+15 -3
View File
@@ -71,9 +71,7 @@ describe('serverInfo extension — handleServerInfo', () => {
const statfsSpy = vi
.spyOn(fs.default, 'statfs')
.mockRejectedValue(new Error('statfs unsupported'));
const errSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const { res, captured } = makeRes();
await handleServerInfo({} as Request, res);
@@ -88,4 +86,18 @@ describe('serverInfo extension — handleServerInfo', () => {
statfsSpy.mockRestore();
errSpy.mockRestore();
});
it('reports an unknown CPU model when os.cpus() comes back empty', async () => {
const os = await import('node:os');
const cpusSpy = vi.spyOn(os.default, 'cpus').mockReturnValue([]);
const { res, captured } = makeRes();
await handleServerInfo({} as Request, res);
const cpu = captured.body!.cpu as Record<string, unknown>;
expect(cpu.model).toBe('Unknown');
expect(cpu.cores).toBe(0);
cpusSpy.mockRestore();
});
});
+197 -9
View File
@@ -3,6 +3,7 @@ import {
PutObjectCommand,
type S3Client,
} from '@aws-sdk/client-s3';
import crypto from 'node:crypto';
import {
afterAll,
beforeAll,
@@ -14,6 +15,7 @@ import {
import { PuterServer } from '../src/backend/server.ts';
import { setupTestServer } from '../src/backend/testUtil.ts';
import {
handleFsCopyNodeThumbnail,
handleFsRemoveNodeThumbnail,
handleThumbnailCreated,
handleThumbnailRead,
@@ -26,6 +28,9 @@ const TINY_PNG_BASE64 =
const BUCKET = 'puter-local';
// Keys the extension will accept back: its own `thumbnails/<uuid>` namespace.
const mintedKey = () => `thumbnails/${crypto.randomUUID()}`;
const streamToBuffer = async (
body: { transformToByteArray: () => Promise<Uint8Array> } | undefined,
): Promise<Buffer> => {
@@ -41,7 +46,7 @@ describe('thumbnails extension — handleThumbnailCreated', () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
it('uploads a valid data: URL thumbnail to S3 and rewrites event.url to an s3:// pointer', async () => {
@@ -108,7 +113,7 @@ describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
it('returns early when event has no items array', async () => {
@@ -188,14 +193,14 @@ describe('thumbnails extension — handleThumbnailRead', () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
it('rewrites an s3:// thumbnail into a presigned https URL', async () => {
// Seed an object so the presigned URL points at something real
// (the signer itself doesn't validate existence, but this keeps
// the test honest).
const key = 'thumb-read-test';
const key = mintedKey();
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
@@ -220,6 +225,57 @@ describe('thumbnails extension — handleThumbnailRead', () => {
expect((entry.thumbnail as string).startsWith('http')).toBe(true);
});
// `fsentries.thumbnail` is writable through the FS API, so a stored
// pointer is attacker input. Signing one the extension didn't mint would
// hand out a presigned read of an arbitrary object — including another
// user's file, whose key is its fsentry uuid in this same bucket.
it.each([
// Shaped exactly like an fs object key (and like a legacy thumbnail
// row) — indistinguishable from a planted pointer, so it fails closed.
[
"another user's file object",
`s3://${BUCKET}/${crypto.randomUUID()}`,
],
[
'a key outside the thumbnails namespace',
`s3://${BUCKET}/secrets/dump`,
],
[
'a namespace-lookalike key',
`s3://${BUCKET}/thumbnails/../${crypto.randomUUID()}`,
],
['a non-uuid inside the namespace', `s3://${BUCKET}/thumbnails/etc`],
])('refuses to presign a pointer naming %s', async (_label, thumbnail) => {
const entry: Record<string, unknown> = { thumbnail };
await handleThumbnailRead(entry, {
s3,
s3Presign,
bucketName: BUCKET,
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
db: stubDb,
});
expect(entry.thumbnail).toBeNull();
});
it('signs against its own bucket, ignoring the one in the pointer', async () => {
const key = mintedKey();
const entry: Record<string, unknown> = {
thumbnail: `s3://attacker-named-bucket/${key}`,
};
await handleThumbnailRead(entry, {
s3,
s3Presign,
bucketName: BUCKET,
bucketEndpoint: 'http://127.0.0.1:4566/puter-local/',
db: stubDb,
});
// Signed for OUR bucket; `attacker-named-bucket` never reached S3.
const signed = entry.thumbnail as string;
expect(signed.startsWith('http')).toBe(true);
expect(signed).not.toContain('attacker-named-bucket');
expect(signed).toContain(BUCKET);
});
it('leaves the thumbnail untouched when not s3/https/data', async () => {
const entry: Record<string, unknown> = { thumbnail: 'about:blank' };
await handleThumbnailRead(entry, {
@@ -281,11 +337,11 @@ describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
it('deletes the S3 object referenced by an s3:// thumbnail URL', async () => {
const key = 'thumb-remove-test';
const key = mintedKey();
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
@@ -297,7 +353,7 @@ describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
await handleFsRemoveNodeThumbnail(
{ target: { thumbnail: `s3://${BUCKET}/${key}` } },
{ s3 },
{ s3, bucketName: BUCKET },
);
// GetObject should now error because the key was deleted.
@@ -306,15 +362,147 @@ describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
).rejects.toThrow();
});
// The destructive half of the same confused deputy: the stored pointer
// decides which object is deleted, so a key the extension didn't mint
// must never reach DeleteObject.
it('does not delete an object the pointer names but we did not mint', async () => {
const victimKey = crypto.randomUUID(); // shaped like an fs object key
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: victimKey,
Body: Buffer.from(TINY_PNG_BASE64, 'base64'),
ContentType: 'image/png',
}),
);
await handleFsRemoveNodeThumbnail(
{ target: { thumbnail: `s3://${BUCKET}/${victimKey}` } },
{ s3, bucketName: BUCKET },
);
const survivor = await s3.send(
new GetObjectCommand({ Bucket: BUCKET, Key: victimKey }),
);
expect(survivor.ContentType).toBe('image/png');
});
it('is a no-op when the target has no thumbnail', async () => {
// Should not throw or attempt a delete.
await handleFsRemoveNodeThumbnail({ target: {} }, { s3 });
await handleFsRemoveNodeThumbnail(
{ target: {} },
{ s3, bucketName: BUCKET },
);
});
it('is a no-op when the thumbnail URL is not an s3:// pointer', async () => {
await handleFsRemoveNodeThumbnail(
{ target: { thumbnail: 'https://cdn.example.com/x.png' } },
{ s3 },
{ s3, bucketName: BUCKET },
);
});
});
describe('thumbnails extension — handleFsCopyNodeThumbnail', () => {
let server: PuterServer;
let s3: S3Client;
beforeAll(async () => {
server = await setupTestServer();
s3 = server.clients.s3.get();
});
afterAll(async () => {
await server?.shutdown();
});
it('duplicates the shared thumbnail object and repoints the copied row', async () => {
const sourceKey = mintedKey();
const body = Buffer.from(TINY_PNG_BASE64, 'base64');
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: sourceKey,
Body: body,
ContentType: 'image/png',
}),
);
const copyUuid = crypto.randomUUID();
const db = { write: vi.fn().mockResolvedValue(undefined) };
await handleFsCopyNodeThumbnail(
{
copy: {
thumbnail: `s3://${BUCKET}/${sourceKey}`,
uuid: copyUuid,
},
},
{ s3, bucketName: BUCKET, db },
);
// The copied row was repointed at a fresh object...
expect(db.write).toHaveBeenCalledTimes(1);
const [, params] = db.write.mock.calls[0] as [string, [string, string]];
const [newPointer, updatedUuid] = params;
expect(updatedUuid).toBe(copyUuid);
expect(newPointer.startsWith(`s3://${BUCKET}/thumbnails/`)).toBe(true);
expect(newPointer).not.toBe(`s3://${BUCKET}/${sourceKey}`);
// ...whose content matches, while the source object survives — so
// deleting either entry can no longer break the other's thumbnail.
const newKey = newPointer.slice(`s3://${BUCKET}/`.length);
const duplicated = await s3.send(
new GetObjectCommand({ Bucket: BUCKET, Key: newKey }),
);
expect(
(await streamToBuffer(duplicated.Body as never)).equals(body),
).toBe(true);
const original = await s3.send(
new GetObjectCommand({ Bucket: BUCKET, Key: sourceKey }),
);
expect(original.ContentType).toBe('image/png');
});
it('drops the pointer when the shared object is already gone', async () => {
const copyUuid = crypto.randomUUID();
const db = { write: vi.fn().mockResolvedValue(undefined) };
await handleFsCopyNodeThumbnail(
{
copy: {
thumbnail: `s3://${BUCKET}/${mintedKey()}`, // never uploaded
uuid: copyUuid,
},
},
{ s3, bucketName: BUCKET, db },
);
expect(db.write).toHaveBeenCalledTimes(1);
const [sql, params] = db.write.mock.calls[0] as [string, [string]];
expect(sql).toContain('NULL');
expect(params).toEqual([copyUuid]);
});
it('does not duplicate an object the pointer names but we did not mint', async () => {
const foreignKey = crypto.randomUUID(); // shaped like an fs object key
const db = { write: vi.fn().mockResolvedValue(undefined) };
await handleFsCopyNodeThumbnail(
{
copy: {
thumbnail: `s3://${BUCKET}/${foreignKey}`,
uuid: crypto.randomUUID(),
},
},
{ s3, bucketName: BUCKET, db },
);
expect(db.write).not.toHaveBeenCalled();
});
it('is a no-op when the copy has no thumbnail', async () => {
const db = { write: vi.fn().mockResolvedValue(undefined) };
await handleFsCopyNodeThumbnail(
{ copy: { thumbnail: null, uuid: crypto.randomUUID() } },
{ s3, bucketName: BUCKET, db },
);
expect(db.write).not.toHaveBeenCalled();
});
});
+156 -33
View File
@@ -1,4 +1,5 @@
import {
CopyObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
@@ -13,6 +14,54 @@ const clients = extension.import('client');
const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024;
const MAX_THUMBNAIL_PIXELS = 64e6;
// Namespace every object this extension writes. An fs object's key is its
// bare fsentry uuid and the default config points `thumbnailStore.name` at
// the same bucket as `s3_bucket`, so without a prefix of our own there is no
// way to tell a thumbnail we minted from any other object in the deployment.
const THUMBNAIL_KEY_PREFIX = 'thumbnails/';
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const mintThumbnailKey = (): string =>
`${THUMBNAIL_KEY_PREFIX}${crypto.randomUUID()}`;
/**
* Extract the object key from a stored thumbnail pointer, or null when the
* pointer isn't one this extension minted.
*
* `fsentries.thumbnail` is writable through the FS API, so neither half of the
* stored string is trusted: the bucket is discarded (callers always pass their
* own) and the key must sit under {@link THUMBNAIL_KEY_PREFIX} with a random
* uuid. Honouring an arbitrary key would lend this extension's storage
* credentials to whatever object the caller named — in the shared-bucket layout
* that is every user's file, since an fs object's key is its fsentry uuid.
* Legacy bare-uuid thumbnails fail the check and are treated as absent; they
* are indistinguishable from a planted pointer, so there is nothing safer to do
* with them than stop signing them.
*/
const resolveThumbnailKey = (pointer: string): string | null => {
let key: string;
if (pointer.startsWith('s3://')) {
const rest = pointer.slice('s3://'.length);
const slash = rest.indexOf('/');
if (slash === -1) return null;
key = rest.slice(slash + 1);
} else {
let pathname: string;
try {
pathname = new URL(pointer).pathname;
} catch {
return null;
}
const segments = pathname.replace(/^\/+/, '').split('/');
segments.shift(); // bucket
key = segments.join('/');
}
if (!key.startsWith(THUMBNAIL_KEY_PREFIX)) return null;
const id = key.slice(THUMBNAIL_KEY_PREFIX.length);
return UUID_PATTERN.test(id) ? key : null;
};
// S3 client + bucket config — lazily resolved after boot from config.
let s3Client: S3Client | null = null;
let s3PresignClient: S3Client | null = null;
@@ -95,7 +144,7 @@ async function decodeAndValidateThumbnail(
return { mimeType, data };
}
// ── thumbnail.created ───────────────────────────────────────────────
// -- thumbnail.created -----------------------------------------------
// Intercept data-URL thumbnails before they hit the DB: upload to S3
// and replace the URL with an s3:// pointer.
@@ -112,7 +161,7 @@ export async function handleThumbnailCreated(
return;
}
const key = crypto.randomUUID();
const key = mintThumbnailKey();
event.url = `s3://${deps.bucketName}/${key}`;
await deps.s3.send(
@@ -151,7 +200,7 @@ export const handleThumbnailUploadPrepare = async (
continue;
}
const key = crypto.randomUUID();
const key = mintThumbnailKey();
const command = new PutObjectCommand({
Bucket: deps.bucketName,
Key: key,
@@ -178,27 +227,27 @@ export const handleThumbnailRead = async (
if (typeof thumb !== 'string' || !thumb) return;
const presignClient = deps.s3Presign;
if (thumb.startsWith('s3://')) {
const [bucket, key] = thumb.slice(5).split('/');
entry.thumbnail = await getSignedUrl(
presignClient,
new GetObjectCommand({ Bucket: bucket, Key: key }),
{ expiresIn: 604800 },
);
} else if (
thumb.startsWith('https') &&
thumb.includes(new URL(deps.bucketEndpoint).hostname)
) {
if (
thumb.startsWith('s3://') ||
// Legacy format — remove after full migration
const [bucket, key] = new URL(thumb).pathname.slice(1).split('/');
(thumb.startsWith('https') &&
thumb.includes(new URL(deps.bucketEndpoint).hostname))
) {
const key = resolveThumbnailKey(thumb);
if (!key) {
// Not a pointer we minted — refuse to sign it rather than hand
// out a presigned read of whatever object it names.
entry.thumbnail = null;
return;
}
entry.thumbnail = await getSignedUrl(
presignClient,
new GetObjectCommand({ Bucket: bucket, Key: key }),
new GetObjectCommand({ Bucket: deps.bucketName, Key: key }),
{ expiresIn: 604800 },
);
} else if (thumb.startsWith('data')) {
// Inline data-URL migration: upload to S3 and update the DB entry.
const key = crypto.randomUUID();
const key = mintThumbnailKey();
const { mimeType, data } = base64ParseDataUrl(thumb);
const newUrl = `s3://${deps.bucketName}/${key}`;
@@ -232,15 +281,72 @@ export const handleThumbnailRead = async (
}
};
export const handleFsRemoveNodeThumbnail = async (
payload: { target: Record<string, unknown> },
deps: { s3: S3Client },
export const handleFsCopyNodeThumbnail = async (
payload: { copy?: { thumbnail?: string | null; uuid?: string } | null },
deps: {
s3: S3Client;
bucketName: string;
db: { write: (sql: string, params: unknown[]) => Promise<unknown> };
},
): Promise<void> => {
const thumbnailUrl = payload.target.thumbnail as string | undefined;
if (!thumbnailUrl || !thumbnailUrl.startsWith('s3://')) return;
const copy = payload.copy;
const thumbnailUrl = copy?.thumbnail;
if (!copy || !copy.uuid || typeof thumbnailUrl !== 'string') return;
const [bucket, key] = thumbnailUrl.slice(5).split('/');
await deps.s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
// Same trust rule as the read and remove paths: only touch objects this
// extension minted.
const sourceKey = resolveThumbnailKey(thumbnailUrl);
if (!sourceKey) return;
// The copied row points at the SAME S3 object as its source, and
// fs.remove.node deletes the pointed-to object — so the first removal
// among the sharers (an overwrite, a trash purge) would break every
// other sharer's thumbnail. Give the copy an object of its own.
const newKey = mintThumbnailKey();
try {
await deps.s3.send(
new CopyObjectCommand({
Bucket: deps.bucketName,
CopySource: `${deps.bucketName}/${sourceKey}`,
Key: newKey,
}),
);
} catch (err) {
// The shared object is already gone (e.g. a sharer was removed
// before this fix existed) — the pointer is dead either way, so
// drop it rather than leave the row advertising a thumbnail it
// doesn't have.
await deps.db.write(
'UPDATE `fsentries` SET `thumbnail` = NULL WHERE `uuid` = ?',
[copy.uuid],
);
console.warn('[thumbnails] failed to duplicate thumbnail on copy', err);
return;
}
await deps.db.write(
'UPDATE `fsentries` SET `thumbnail` = ? WHERE `uuid` = ?',
[`s3://${deps.bucketName}/${newKey}`, copy.uuid],
);
};
export const handleFsRemoveNodeThumbnail = async (
payload: { target: { thumbnail?: string | null } },
deps: { s3: S3Client; bucketName: string },
): Promise<void> => {
const thumbnailUrl = payload.target.thumbnail;
if (!thumbnailUrl) return;
// Same trust rule as the read path, and load-bearing for the same reason:
// the pointer decides which object gets deleted, so a key we didn't mint
// would let the owner of one file destroy an object belonging to someone
// else just by naming it here.
const key = resolveThumbnailKey(thumbnailUrl);
if (!key) return;
await deps.s3.send(
new DeleteObjectCommand({ Bucket: deps.bucketName, Key: key }),
);
};
extension.on(
@@ -253,7 +359,7 @@ extension.on(
},
);
// ── thumbnail.upload.prepare ────────────────────────────────────────
// -- thumbnail.upload.prepare ----------------------------------------
// Generate pre-signed upload URLs so the client can PUT directly to S3.
extension.on(
@@ -266,7 +372,7 @@ extension.on(
},
);
// ── thumbnail.read ──────────────────────────────────────────────────
// -- thumbnail.read --------------------------------------------------
// Convert s3:// or legacy https:// thumbnails to signed URLs.
extension.on('thumbnail.read', async (_key, entry: Record<string, unknown>) => {
@@ -279,12 +385,29 @@ extension.on('thumbnail.read', async (_key, entry: Record<string, unknown>) => {
});
});
// ── fs.remove.node ──────────────────────────────────────────────────
// -- fs.copy.node ----------------------------------------------------
// A copied entry initially shares its source's thumbnail object; duplicate
// it so removing either entry can't break the other's thumbnail.
extension.on('fs.copy.node', async (_key, payload) => {
await handleFsCopyNodeThumbnail(
payload as {
copy?: { thumbnail?: string | null; uuid?: string } | null;
},
{
s3: getClient(),
bucketName: thumbnailBucketName,
db: clients.db,
},
);
});
// -- fs.remove.node --------------------------------------------------
// Delete S3 thumbnail when the file is removed.
extension.on(
'fs.remove.node',
async (_key, payload: { target: Record<string, unknown> }) => {
await handleFsRemoveNodeThumbnail(payload, { s3: getClient() });
},
);
extension.on('fs.remove.node', async (_key, payload) => {
await handleFsRemoveNodeThumbnail(payload, {
s3: getClient(),
bucketName: thumbnailBucketName,
});
});
+48 -1
View File
@@ -51,7 +51,7 @@ beforeAll(async () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
const seedUser = async () => {
@@ -107,6 +107,8 @@ describe('whoami extension — handleWhoami', () => {
expect(body.email).toBe(user.email);
expect(body.is_temp).toBe(false);
expect(body.oidc_only).toBe(false);
// is_user_token is present (true) for user actors.
expect(body.is_user_token).toBe(true);
// `directories` is only sent to user actors — confirm it’s present.
expect(body.directories).toBeDefined();
// taskbar_items is only sent to user actors.
@@ -146,6 +148,8 @@ describe('whoami extension — handleWhoami', () => {
const body = captured.body as Record<string, unknown>;
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();
@@ -154,6 +158,49 @@ describe('whoami extension — handleWhoami', () => {
expect(body.directories).toBeUndefined();
});
it('redacts tmp_password from metadata for user actors', async () => {
const user = await seedUser();
await server.stores.user.updateMetadata(user.id as number, {
tmp_password: 'bootstrap-secret',
hasDevAccountAccess: true,
});
const { res, captured } = makeRes();
await runWithContext(
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
() => handleWhoami(makeReq(), res),
);
const body = captured.body as Record<string, unknown>;
const metadata = body.metadata as Record<string, unknown>;
expect(metadata.tmp_password).toBeUndefined();
// Other metadata keys still reach the user's own client.
expect(metadata.hasDevAccountAccess).toBe(true);
expect(body.hasDevAccountAccess).toBe(true);
});
it('never sends user metadata to app actors', async () => {
const user = await seedUser();
await server.stores.user.updateMetadata(user.id as number, {
tmp_password: 'bootstrap-secret',
});
const { res, captured } = makeRes();
await runWithContext(
{
actor: {
user: { uuid: user.uuid, id: user.id as number },
app: { uid: 'app-test-actor' },
},
},
() => handleWhoami(makeReq(), res),
);
const body = captured.body as Record<string, unknown>;
expect(body.metadata).toBeUndefined();
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
});
it('marks the user as oidc_only when password is null', async () => {
const slug = Math.random().toString(36).slice(2, 8);
const oidcUser = await server.stores.user.create({
+10 -1
View File
@@ -65,6 +65,9 @@ export const handleWhoami = async (
}
}
const metadata = user.metadata ? { ...user.metadata } : user.metadata;
if (metadata) delete metadata.tmp_password;
const details: Record<string, unknown> = {
username: user.username,
uuid: user.uuid,
@@ -72,10 +75,14 @@ export const handleWhoami = async (
unconfirmed_email: user.email,
email_confirmed: user.email_confirmed || user.username === 'admin',
requires_email_confirmation: user.requires_email_confirmation,
phone: user.phone,
requires_phone_verification: user.requires_phone_verification,
requires_card_verification: user.requires_card_verification,
desktop_bg_url: user.desktop_bg_url,
desktop_bg_color: user.desktop_bg_color,
desktop_bg_fit: user.desktop_bg_fit,
is_temp: user.password === null && user.email === null,
is_user_token: true,
oidc_only: oidcOnly,
taskbar_items: isUser
? await getTaskbarItems(
@@ -94,7 +101,7 @@ export const handleWhoami = async (
human_readable_age: user.timestamp
? timeago.format(new Date(user.timestamp as string))
: null,
metadata: user.metadata,
metadata,
hasDevAccountAccess: !!user.metadata?.hasDevAccountAccess,
};
@@ -157,6 +164,8 @@ export const handleWhoami = async (
delete details.desktop_bg_color;
delete details.desktop_bg_fit;
delete details.human_readable_age;
delete details.is_user_token;
delete details.metadata;
}
if (actor.app) {
+15 -12
View File
@@ -11,7 +11,7 @@
# What this does, in order:
# 1. Checks that docker (with the compose plugin) exists.
# 2. Creates ./puter-selfhosted/ (override with $env:PUTER_DIR).
# 3. Downloads docker-compose.yml + nginx.conf from the OSS repo.
# 3. Downloads docker-compose.yml + caddy/Caddyfile from the OSS repo.
# 4. Generates fresh secrets and writes .env + puter/config/config.json.
# 5. Runs `docker compose up -d` and prints how to find the admin password.
#
@@ -23,7 +23,7 @@
# PUTER_DIR install directory (default: ./puter-selfhosted)
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
# PUTER_PORT HTTP port for nginx (default: 80)
# PUTER_PORT HTTP port for Caddy (default: 80)
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
[CmdletBinding()]
@@ -77,10 +77,10 @@ if ($LASTEXITCODE -ne 0) {
# ── Step 2: install dir ─────────────────────────────────────────────
$null = New-Item -ItemType Directory -Force -Path $PuterDir
Set-Location $PuterDir
$null = New-Item -ItemType Directory -Force -Path 'puter/config', 'puter/data', 'puter/tls'
$null = New-Item -ItemType Directory -Force -Path 'puter/config', 'puter/data', 'puter/data/caddy', 'puter/tls'
Write-Log "install dir: $((Get-Location).Path)"
# ── Step 3: docker-compose.yml + nginx config ──────────────────────
# ── Step 3: docker-compose.yml + Caddy config ──────────────────────
Write-Log "downloading docker-compose.yml from $PuterUrl"
try {
Invoke-WebRequest -Uri "$PuterUrl/docker-compose.yml" -OutFile 'docker-compose.yml' -UseBasicParsing
@@ -88,17 +88,19 @@ try {
Die "could not fetch $PuterUrl/docker-compose.yml — $_"
}
Write-Log "downloading nginx/nginx.conf from $PuterUrl"
$null = New-Item -ItemType Directory -Force -Path 'nginx'
# The Caddyfile is domain-agnostic — it answers on every Host and leaves
# the subdomain routing to Puter — so there's nothing to template in.
Write-Log "downloading caddy/Caddyfile from $PuterUrl"
$null = New-Item -ItemType Directory -Force -Path 'caddy'
# If the path was previously auto-created as a directory by a failed
# `compose up`, remove it so we can write the file there.
if (Test-Path 'nginx/nginx.conf' -PathType Container) {
Remove-Item 'nginx/nginx.conf' -Recurse -Force
if (Test-Path 'caddy/Caddyfile' -PathType Container) {
Remove-Item 'caddy/Caddyfile' -Recurse -Force
}
try {
Invoke-WebRequest -Uri "$PuterUrl/nginx/nginx.conf" -OutFile 'nginx/nginx.conf' -UseBasicParsing
Invoke-WebRequest -Uri "$PuterUrl/caddy/Caddyfile" -OutFile 'caddy/Caddyfile' -UseBasicParsing
} catch {
Die "could not fetch $PuterUrl/nginx/nginx.conf — $_"
Die "could not fetch $PuterUrl/caddy/Caddyfile — $_"
}
# ── Step 4: secrets, .env, config.json ──────────────────────────────
@@ -121,7 +123,8 @@ if ($writeConfig) {
$envContent = @"
HTTP_PORT=$PuterPort
# HTTPS_PORT=443 # uncomment after enabling TLS (see doc/selfhosting/full-stack.md)
# HTTPS_PORT=443 # uncomment after enabling TLS in caddy/Caddyfile
# # (see "Step 3 — TLS" in doc/self-hosting.md)
MARIADB_ROOT_PASSWORD=$mariadbRootPw
MARIADB_DATABASE=puter
@@ -205,4 +208,4 @@ Write-Log " cd $PuterDir; docker compose logs -f puter"
Write-Log ''
Write-Log "open http://${PuterDomain}:${PuterPort} once the puter container is healthy."
Write-Log 'first-boot admin password is logged once — grab it with:'
Write-Log " cd $PuterDir; docker compose logs puter | Select-String password"
Write-Log " cd $PuterDir; docker compose logs puter | Select-String password"
+51 -21
View File
@@ -7,7 +7,8 @@
# What this does, in order:
# 1. Checks that docker (with the compose plugin), curl, and openssl exist.
# 2. Creates ./puter-selfhosted/ (override with PUTER_DIR=...).
# 3. Downloads docker-compose.yml from the OSS repo (raw.githubusercontent.com).
# 3. Downloads docker-compose.yml + caddy/Caddyfile from the OSS repo
# (raw.githubusercontent.com).
# 4. Generates fresh secrets and writes .env + puter/config/config.json.
# 5. Runs `docker compose up -d` and prints the first-boot admin password.
#
@@ -16,11 +17,22 @@
# the compose file + brings the stack up. Set PUTER_FORCE=1 to overwrite.
#
# Tunable env vars:
# PUTER_DIR install directory (default: ./puter-selfhosted)
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
# PUTER_PORT HTTP port for nginx (default: 80)
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
# PUTER_DIR install directory (default: ./puter-selfhosted)
# PUTER_URL base URL to fetch docker-compose.yml (default: GitHub raw, main branch)
# PUTER_DOMAIN domain Puter will serve on (default: puter.localhost)
# PUTER_PORT HTTP port for Caddy (default: 80)
# PUTER_PROTOCOL public scheme: http | https (default: http)
# Set https once TLS is terminating in front of Puter —
# either the bundled Caddy with your certs (see "Step 3 —
# TLS" in doc/self-hosting.md) or your own proxy (Traefik,
# a cloud LB). Puter then builds https:// origins, S3
# URLs, and redirects. Leave it http and the installer
# serves plain HTTP on PUTER_PORT.
# PUTER_TRUST_PROXY number of reverse-proxy hops in front (default: 1)
# 1 = the bundled Caddy (or a single external proxy);
# 2 = two hops (e.g. Cloudflare → Caddy → Puter).
# PUTER_ENV prod | dev (default: prod)
# PUTER_FORCE set to 1 to overwrite existing .env / config.json
set -eu
@@ -28,6 +40,9 @@ PUTER_DIR="${PUTER_DIR:-puter-selfhosted}"
PUTER_URL="${PUTER_URL:-https://raw.githubusercontent.com/HeyPuter/puter/main}"
PUTER_DOMAIN="${PUTER_DOMAIN:-puter.localhost}"
PUTER_PORT="${PUTER_PORT:-80}"
PUTER_PROTOCOL="${PUTER_PROTOCOL:-http}"
PUTER_TRUST_PROXY="${PUTER_TRUST_PROXY:-1}"
PUTER_ENV="${PUTER_ENV:-prod}"
PUTER_FORCE="${PUTER_FORCE:-0}"
log() { printf '\033[1;36m[puter-install]\033[0m %s\n' "$*"; }
@@ -60,25 +75,27 @@ mkdir -p puter/config puter/data puter/tls
# bind-mount roots sidesteps the mismatch without guessing each image's
# internal UID. (Docker Desktop on macOS/Windows papers over this with
# its VM layer; native Linux docker on Debian/Alpine doesn't.)
mkdir -p puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
chmod 0777 puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter
mkdir -p puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter puter/data/caddy
chmod 0777 puter/data/valkey puter/data/mariadb puter/data/dynamo puter/data/s3 puter/data/puter puter/data/caddy
log "install dir: $(pwd)"
# ── Step 3: docker-compose.yml + nginx config ──────────────────────
# ── Step 3: docker-compose.yml + Caddy config ──────────────────────
log "downloading docker-compose.yml from $PUTER_URL"
curl -fsSL "$PUTER_URL/docker-compose.yml" -o docker-compose.yml \
|| die "could not fetch $PUTER_URL/docker-compose.yml"
# nginx is mounted as `./nginx/nginx.conf:/etc/nginx/nginx.conf:ro` — if
# Caddy is mounted as `./caddy/Caddyfile:/etc/caddy/Caddyfile:ro,z` — if
# the host file is missing, docker silently creates a directory at that
# path and the mount fails with "not a directory" at container start.
log "downloading nginx/nginx.conf from $PUTER_URL"
mkdir -p nginx
# The Caddyfile is domain-agnostic — it answers on every Host and leaves
# the subdomain routing to Puter — so there's nothing to template in.
log "downloading caddy/Caddyfile from $PUTER_URL"
mkdir -p caddy
# If the path was previously auto-created as a dir by a failed `compose up`,
# remove it so curl can write the file.
[ -d nginx/nginx.conf ] && rmdir nginx/nginx.conf 2>/dev/null || true
curl -fsSL "$PUTER_URL/nginx/nginx.conf" -o nginx/nginx.conf \
|| die "could not fetch $PUTER_URL/nginx/nginx.conf"
[ -d caddy/Caddyfile ] && rmdir caddy/Caddyfile 2>/dev/null || true
curl -fsSL "$PUTER_URL/caddy/Caddyfile" -o caddy/Caddyfile \
|| die "could not fetch $PUTER_URL/caddy/Caddyfile"
# ── Step 4: secrets, .env, config.json ──────────────────────────────
write_config=1
@@ -101,7 +118,8 @@ if [ "$write_config" = "1" ]; then
cat > .env <<EOF
HTTP_PORT=$PUTER_PORT
# HTTPS_PORT=443 # uncomment after enabling TLS (see doc/selfhosting/full-stack.md)
# HTTPS_PORT=443 # uncomment after enabling TLS in caddy/Caddyfile
# # (see "Step 3 — TLS" in doc/self-hosting.md)
MARIADB_ROOT_PASSWORD=$MARIADB_ROOT_PASSWORD
MARIADB_DATABASE=puter
@@ -117,9 +135,9 @@ EOF
cat > puter/config/config.json <<EOF
{
"domain": "$PUTER_DOMAIN",
"protocol": "http",
"protocol": "$PUTER_PROTOCOL",
"pub_port": $PUTER_PORT,
"env": "prod",
"env": "$PUTER_ENV",
"static_hosting_domain": "site.$PUTER_DOMAIN",
"static_hosting_domain_alt": "host.$PUTER_DOMAIN",
@@ -159,7 +177,7 @@ EOF
"s3": {
"s3Config": {
"endpoint": "http://s3:9000",
"publicEndpoint": "http://s3.$PUTER_DOMAIN",
"publicEndpoint": "$PUTER_PROTOCOL://s3.$PUTER_DOMAIN",
"accessKeyId": "puter",
"secretAccessKey": "$S3_SECRET_KEY",
"region": "us-east-1",
@@ -173,11 +191,23 @@ EOF
"ollama": { "enabled": false }
},
"trust_proxy": 1
"trust_proxy": $PUTER_TRUST_PROXY
}
EOF
fi
# `https` only works if something in front is actually terminating it. The
# bundled Caddy does that from ./puter/tls once you enable its `:443` block
# (see "Step 3 — TLS" in doc/self-hosting.md); your own edge proxy is fine
# too. Neither in place means Puter hands out https:// URLs for an endpoint
# that only speaks HTTP — broken logins and mixed content.
if [ "$PUTER_PROTOCOL" = "https" ] && [ ! -f puter/tls/fullchain.pem ]; then
warn "PUTER_PROTOCOL=https but ./puter/tls/fullchain.pem is missing."
warn "The bundled Caddy is still serving plain HTTP on port $PUTER_PORT."
warn "Either drop a wildcard cert into ./puter/tls and uncomment the :443"
warn "block in caddy/Caddyfile, or terminate TLS in your own proxy."
fi
# ── Step 5: bring it up ─────────────────────────────────────────────
log "docker compose up -d"
docker compose up -d
@@ -187,6 +217,6 @@ log "stack starting. first boot takes ~30s while MariaDB initialises."
log "follow puter logs:"
log " cd $PUTER_DIR && docker compose logs -f puter"
log ""
log "open http://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
log "open $PUTER_PROTOCOL://$PUTER_DOMAIN:$PUTER_PORT once the puter container is healthy."
log "first-boot admin password is logged once — grab it with:"
log " cd $PUTER_DIR && docker compose logs puter | grep password"
-135
View File
@@ -1,135 +0,0 @@
# Reverse proxy in front of Puter — mirrors what the prod ALB does:
# accepts every Host header, forwards to the Puter container, and lets
# the Puter app handle subdomain-based routing internally (api.*,
# site.*, app.*, etc).
#
# To enable TLS:
# 1. Drop your fullchain.pem + privkey.pem into ./puter/tls/.
# 2. Uncomment the 443 server{} block below.
# 3. Update server_name to your domain (and wildcard subdomains).
worker_processes auto;
events {
worker_connections 4096;
}
http {
# Required for Puter's WebSocket / socket.io upgrades.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Rough size cap that mirrors prod ALB defaults; tune for your
# uploads. Puter chunks large uploads, so 1 GiB per request is plenty.
client_max_body_size 1024m;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_buffering off;
server_tokens off;
upstream puter_backend {
server puter:4100;
keepalive 32;
}
upstream s3_backend {
# RustFS — see `s3` service in docker-compose.full.yml. Browsers
# PUT/GET here for presigned-URL uploads / downloads. Routed via
# the `s3.<domain>` subdomain so signature verification works
# (Host header preserved end-to-end) and so HTTPS stays clean
# (no mixed-content from a port-9000 host publish).
server s3:9000;
keepalive 32;
}
# ── HTTP (port 80) ─────────────────────────────────────────────
server {
listen 80;
listen [::]:80;
server_name ~^s3\.;
location / {
proxy_pass http://s3_backend;
proxy_http_version 1.1;
# Critical: preserve the original Host so RustFS validates
# the request against the same host the URL was signed for.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# Note: when you enable TLS, replace this block with a redirect:
# return 301 https://$host$request_uri;
location / {
proxy_pass http://puter_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
# ── HTTPS (port 443) — uncomment after dropping certs in ./puter/tls/ ─
# server {
# listen 443 ssl;
# listen [::]:443 ssl;
# http2 on;
# server_name ~^s3\.;
#
# ssl_certificate /etc/nginx/tls/fullchain.pem;
# ssl_certificate_key /etc/nginx/tls/privkey.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_session_cache shared:SSL:10m;
# ssl_session_timeout 10m;
#
# location / {
# proxy_pass http://s3_backend;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }
#
# server {
# listen 443 ssl default_server;
# listen [::]:443 ssl default_server;
# http2 on;
# server_name _;
#
# ssl_certificate /etc/nginx/tls/fullchain.pem;
# ssl_certificate_key /etc/nginx/tls/privkey.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_session_cache shared:SSL:10m;
# ssl_session_timeout 10m;
#
# location / {
# proxy_pass http://puter_backend;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header X-Forwarded-Host $host;
# proxy_set_header X-Forwarded-Port $server_port;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection $connection_upgrade;
# }
# }
}
+5224 -4312
View File
File diff suppressed because it is too large. Load diff
+32 -7
View File
@@ -1,6 +1,6 @@
{
"name": "puter.com",
"version": "2.5.1",
"version": "26.07",
"author": "Puter Technologies Inc.",
"license": "AGPL-3.0-only",
"description": "Desktop environment in the browser!",
@@ -10,6 +10,7 @@
"lib": "lib"
},
"devDependencies": {
"@babel/core": "^7.29.7",
"@eslint/js": "^9.35.0",
"@playwright/test": "^1.56.1",
"@stylistic/eslint-plugin": "^5.3.1",
@@ -20,6 +21,8 @@
"@typescript-eslint/parser": "^8.46.1",
"@vitest/coverage-v8": "^4.0.14",
"@vitest/ui": "^4.0.14",
"babel-loader": "^10.1.1",
"babel-plugin-istanbul": "^8.0.0",
"chalk": "^4.1.0",
"clean-css": "^5.3.2",
"dotenv": "^16.4.5",
@@ -31,9 +34,13 @@
"globals": "^15.15.0",
"html-webpack-plugin": "^5.6.0",
"husky": "^9.1.7",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"license-check-and-add": "^4.0.5",
"nodemon": "^3.1.0",
"prettier": "^3.8.3",
"prettier-plugin-jsdoc": "^1.8.1",
"simple-git": "^3.32.3",
"typescript": "^5.4.5",
"uglify-js": "^3.17.4",
@@ -44,17 +51,23 @@
"yaml": "^2.8.1"
},
"scripts": {
"test:backend": "vitest run --config src/backend/vitest.config.ts ",
"start=gui": "nodemon --exec \"node dev-server.js\" ",
"start": "node --enable-source-maps -r ./dist/src/backend/telemetry.js ./dist/src/backend/index.js",
"prestart": "npm run build:ts",
"test:backend": "npm run setupExtensions && npm run build:workerLib && vitest run --config src/backend/vitest.config.ts ",
"test:backend:postgres": "npm run setupExtensions && npm run build:workerLib && PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
"test:puterjs": "npm run setupExtensions && vitest run --config src/puter-js/tests/api/vitest.config.ts",
"test:puterjs:node": "npm run test:puterjs -- src/puter-js/tests/api/runners/node.test.ts",
"test:puterjs:browser": "npm run test:puterjs -- src/puter-js/tests/api/runners/browser.test.ts",
"test:puterjs:workerd": "npm run test:puterjs -- src/puter-js/tests/api/runners/workerd.test.ts",
"test:puterjs:coverage": "npm run build:workerLib:coverage && npm run setupExtensions && rm -rf src/puter-js/coverage && PUTER_COVERAGE=1 vitest run --config src/puter-js/tests/api/vitest.config.ts && node ./tools/puterjsCoverageReport.mjs",
"build:workerLib:coverage": "cd src/puter-js && npm run build:coverage && cd ../worker && npm run build",
"start:gui": "nodemon --exec \"node dev-server.js\" ",
"start": "node ./tools/start.mjs",
"dev": "npm start",
"build": "npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
"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",
"postinstall": "node ./tools/extensionSetup.mjs"
"setupExtensions": "node ./tools/extensionSetup.mjs"
},
"workspaces": [
"src/*",
@@ -76,9 +89,21 @@
"ai": "^6.0.73",
"dedent": "^1.5.3",
"javascript-time-ago": "^2.5.11",
"libphonenumber-js": "1.13.6",
"miniflare": "^4.20260617.1",
"open": "^10.1.0"
},
"engines": {
"node": ">=24.0.0"
},
"allowScripts": {
"@google/genai": false,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true,
"[email protected]": true
}
}
@@ -0,0 +1,503 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AlarmClient } from './AlarmClient';
import type { IConfig, IPagerConfig } from '../../types';
import type { AlertPayload } from './types';
const pdEvent = vi.hoisted(() => vi.fn(async () => ({})));
vi.mock('@pagerduty/pdjs', () => ({ event: pdEvent }));
const makeClient = (pager: IPagerConfig = {}) =>
new AlarmClient({ serverId: 'test-node', pager } as unknown as IConfig);
/** Register a capturing handler in place of a real transport. */
const capture = (
client: AlarmClient,
minSeverity?: 'critical' | 'error' | 'warning' | 'info',
maxSeverity?: 'critical' | 'error' | 'warning' | 'info',
) => {
const seen: AlertPayload[] = [];
client.addAlertHandler(
async (alert) => {
seen.push(alert);
},
{ name: 'capture', minSeverity, maxSeverity },
);
return seen;
};
describe('AlarmClient severity routing', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
pdEvent.mockClear();
});
it('defaults to critical when the call site says nothing', () => {
const client = makeClient();
const seen = capture(client);
client.create('boom', 'everything is on fire');
expect(seen).toHaveLength(1);
expect(seen[0].severity).toBe('critical');
});
it('honours a configured default severity', () => {
const client = makeClient({ defaultSeverity: 'info' });
const seen = capture(client);
client.create('boom', 'everything is on fire');
expect(seen[0].severity).toBe('info');
});
it('skips handlers whose floor the alarm does not reach', () => {
const client = makeClient();
const paging = capture(client, 'warning');
const chat = capture(client, 'info');
client.create('rate-limit', 'a user hit a limit', {}, 'info');
expect(paging).toHaveLength(0);
expect(chat).toHaveLength(1);
});
it('skips handlers whose ceiling the alarm exceeds', () => {
const client = makeClient();
const chat = capture(client, 'info', 'info');
client.create('rate-limit', 'a user hit a limit', {}, 'info');
client.create('outage', 'everything is on fire', {}, 'critical');
expect(chat.map((alert) => alert.id)).toEqual(['rate-limit']);
});
it('retiers an alarm from config', () => {
const client = makeClient({
severityOverrides: { 'noisy:*': 'info' },
});
const paging = capture(client, 'warning');
const chat = capture(client, 'info');
client.create('noisy:thing', 'used to page', {}, 'critical');
expect(paging).toHaveLength(0);
expect(chat[0].severity).toBe('info');
});
it('mutes an alarm from config', () => {
const client = makeClient({
severityOverrides: { 'noisy:thing': 'mute' },
});
const chat = capture(client, 'info');
client.create('noisy:thing', 'not worth reporting');
client.create('noisy:thing', 'still not worth reporting');
expect(chat).toHaveLength(0);
});
it('lets config override a known-error rule', () => {
const client = makeClient({
severityOverrides: { 'known:thing': 'critical' },
});
client.setKnownErrors([
{
match: { id: 'known:thing' },
action: { type: 'severity', value: 'info' },
},
]);
const paging = capture(client, 'warning');
client.create('known:thing', 'known but escalated');
expect(paging[0].severity).toBe('critical');
});
it('still respects a no-alert known-error rule', () => {
const client = makeClient();
client.setKnownErrors([
{ match: { id: 'known:quiet' }, action: { type: 'no-alert' } },
]);
const chat = capture(client, 'info');
client.create('known:quiet', 'suppressed');
expect(chat).toHaveLength(0);
});
});
describe('AlarmClient alert payload', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
it('reports occurrence counts across repeats', () => {
const client = makeClient();
const seen = capture(client);
client.create('flap', 'first');
client.create('flap', 'second');
expect(seen[0]).toMatchObject({ repeatCount: 1, isRepeat: false });
expect(seen[1]).toMatchObject({ repeatCount: 2, isRepeat: true });
});
it('renders fields as strings and lifts the stack out of the error', () => {
const client = makeClient();
const seen = capture(client);
const error = new Error('kaboom');
client.create('boom', 'failed', { error, status: 500 }, 'critical');
expect(seen[0].fields.status).toBe('500');
expect(seen[0].trace).toBe(error.stack);
expect(seen[0].shortId).toMatch(/^[a-z]+-[a-z]+-[a-z]+$/);
});
it('keeps one failing handler from starving the others', async () => {
const client = makeClient();
client.addAlertHandler(
async () => {
throw new Error('transport down');
},
{ name: 'broken' },
);
const seen = capture(client);
client.create('boom', 'failed');
await Promise.resolve();
expect(seen).toHaveLength(1);
});
it('suppresses alarms once draining', () => {
const client = makeClient();
const seen = capture(client);
client.onServerPrepareShutdown();
client.create('boom', 'too late');
expect(seen).toHaveLength(0);
});
});
describe('AlarmClient transport registration', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
pdEvent.mockClear();
});
it('splits info to Slack and everything above it to PagerDuty', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const client = makeClient({
pagerduty: { enabled: true, routingKey: 'rk' },
slack: { enabled: true, webhookUrl: 'https://hooks.example/abc' },
});
await client.onServerStart();
client.create('quiet:thing', 'informational', {}, 'info');
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
expect(pdEvent).not.toHaveBeenCalled();
client.create('loud:thing', 'paging', {}, 'critical');
await vi.waitFor(() => expect(pdEvent).toHaveBeenCalledTimes(1));
// The pager has it; chat doesn't repeat it.
expect(fetchMock).toHaveBeenCalledTimes(1);
vi.unstubAllGlobals();
});
it('sends every severity to Slack when there is no pager', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const client = makeClient({
slack: { enabled: true, webhookUrl: 'https://hooks.example/abc' },
});
await client.onServerStart();
client.create(
'loud:thing',
'nowhere else to send this',
{},
'critical',
);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
vi.unstubAllGlobals();
});
it('lets config widen the Slack ceiling back out', async () => {
const fetchMock = vi.fn(async () => ({ ok: true, status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const client = makeClient({
pagerduty: { enabled: true, routingKey: 'rk' },
slack: {
enabled: true,
webhookUrl: 'https://hooks.example/abc',
maxSeverity: 'critical',
},
});
await client.onServerStart();
client.create('loud:thing', 'paging', {}, 'critical');
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
vi.unstubAllGlobals();
});
it('gives each occurrence its own PagerDuty incident by default', async () => {
const client = makeClient({
pagerduty: { enabled: true, routingKey: 'rk' },
});
await client.onServerStart();
client.create('scan:failed', 'first', {}, 'warning');
client.create('scan:failed', 'second', {}, 'warning');
await vi.waitFor(() => expect(pdEvent).toHaveBeenCalledTimes(2));
const keys = pdEvent.mock.calls.map(
([arg]: [{ data: { dedup_key: string } }]) => arg.data.dedup_key,
);
expect(keys[0]).not.toBe(keys[1]);
});
it('collapses repeats of a dedup alarm onto one incident', async () => {
const client = makeClient({
pagerduty: { enabled: true, routingKey: 'rk' },
});
await client.onServerStart();
const raise = () =>
client.create(
'http_500:POST:/notif/mark-ack:deadlock',
'HTTP 500 on POST /notif/mark-ack: deadlock',
{},
'critical',
{ dedup: true },
);
raise();
raise();
await vi.waitFor(() => expect(pdEvent).toHaveBeenCalledTimes(2));
const keys = pdEvent.mock.calls.map(
([arg]: [{ data: { dedup_key: string } }]) => arg.data.dedup_key,
);
expect(keys[0]).toBe('http_500:POST:/notif/mark-ack:deadlock');
expect(keys[1]).toBe(keys[0]);
});
it('skips transports that are enabled but not configured', async () => {
const client = makeClient({
pagerduty: { enabled: true },
slack: { enabled: true },
});
await client.onServerStart();
const seen = capture(client);
client.create('boom', 'nowhere to send this');
// Only the capturing handler is registered.
expect(seen).toHaveLength(1);
expect(pdEvent).not.toHaveBeenCalled();
});
});
describe('AlarmClient alarm registry', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
});
it('finds an alarm by its full id or its short id', () => {
const client = makeClient();
client.create('disk-pressure', 'nearly full');
const alarm = client.get('disk-pressure');
expect(alarm?.message).toBe('nearly full');
expect(client.get(alarm!.shortId)).toBe(alarm);
});
it('returns nothing for an id it has never seen', () => {
expect(makeClient().get('never-raised')).toBeUndefined();
});
it('forgets an alarm once cleared, under both ids', () => {
const client = makeClient();
client.create('disk-pressure', 'nearly full');
const shortId = client.get('disk-pressure')!.shortId;
client.clear('disk-pressure');
expect(client.get('disk-pressure')).toBeUndefined();
expect(client.get(shortId)).toBeUndefined();
});
it('ignores a clear for an alarm that is not active', () => {
const client = makeClient();
expect(() => client.clear('never-raised')).not.toThrow();
});
it('abbreviates long ids in the log line but keeps the short id usable', () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
const client = makeClient();
const longId = 'a-very-long-alarm-identifier-that-wraps';
client.create(longId, 'noisy');
const shortId = client.get(longId)!.shortId;
expect(log).toHaveBeenCalledWith(
`[alarm] ACTIVE ${shortId} (${longId.slice(0, 20)}...) :: noisy`,
);
});
it('accumulates fields across repeats of the same alarm', () => {
const client = makeClient();
const seen = capture(client);
client.create('flap', 'first', { a: 1 });
client.create('flap', 'second', { b: 2 });
expect(seen[1].fields).toEqual({ a: '1', b: '2' });
expect(client.get('flap')?.occurrences).toHaveLength(2);
});
it('names anonymous handlers by their registration order', () => {
const client = makeClient();
client.addAlertHandler(async () => {
throw new Error('down');
});
expect(() => client.create('boom', 'x')).not.toThrow();
});
it('only logs the drain notice once', () => {
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
log.mockClear();
const client = makeClient();
client.onServerPrepareShutdown();
// A second prepare must not restart the drain.
client.onServerPrepareShutdown();
client.create('a', 'x');
client.create('b', 'y');
const drainLogs = log.mock.calls.filter(
([message]) =>
message === '[alarm] suppressing alarm while draining',
);
expect(drainLogs).toHaveLength(1);
});
it('substitutes placeholders for an empty id and message', () => {
const client = makeClient();
const seen = capture(client);
client.create('', '');
expect(seen[0]).toMatchObject({
id: 'something-bad',
message: 'something bad happened',
});
});
});
describe('AlarmClient known-error rules', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
});
it('suppresses alerts for a matching rule', () => {
const client = makeClient();
const seen = capture(client);
client.setKnownErrors([
{ match: { id: 'noisy' }, action: { type: 'no-alert' } },
]);
client.create('noisy', 'ignore me');
client.create('noisy', 'ignore me again');
expect(seen).toHaveLength(0);
});
it('leaves alarms with a different id alone', () => {
const client = makeClient();
const seen = capture(client);
client.setKnownErrors([
{ match: { id: 'noisy' }, action: { type: 'no-alert' } },
]);
client.create('real', 'page me');
expect(seen).toHaveLength(1);
});
it('only matches when the message matches too', () => {
const rules = [
{
match: { id: 'timeout', message: 'upstream timed out' },
action: { type: 'no-alert' as const },
},
];
// Separate clients: suppression is recorded on the alarm, so a
// second occurrence of the same id would inherit it.
const matching = makeClient();
const matchingSeen = capture(matching);
matching.setKnownErrors(rules);
matching.create('timeout', 'upstream timed out');
expect(matchingSeen).toHaveLength(0);
const differing = makeClient();
const differingSeen = capture(differing);
differing.setKnownErrors(rules);
differing.create('timeout', 'something else entirely');
expect(differingSeen.map((alert) => alert.message)).toEqual([
'something else entirely',
]);
});
it('only matches when every named field matches', () => {
const client = makeClient();
const seen = capture(client);
client.setKnownErrors([
{
match: { id: 'http', fields: { status: 404 } },
action: { type: 'no-alert' },
},
]);
client.create('http', 'not found', { status: 404 });
client.create('http-2', 'server error', { status: 500 });
expect(seen.map((alert) => alert.id)).toEqual(['http-2']);
});
it('retiers a matching alarm to a lower severity', () => {
const client = makeClient();
const paging = capture(client, 'error');
const chat = capture(client, 'info');
client.setKnownErrors([
{
match: { id: 'flaky' },
action: { type: 'severity', value: 'info' },
},
]);
client.create('flaky', 'transient', {}, 'critical');
expect(paging).toHaveLength(0);
expect(chat).toHaveLength(1);
expect(chat[0].severity).toBe('info');
});
});
+223 -111
View File
@@ -1,4 +1,4 @@
/**
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
@@ -20,64 +20,56 @@
import { event as pdEvent } from '@pagerduty/pdjs';
import { inspect } from 'node:util';
import { createHash } from 'node:crypto';
import type { IConfig } from '../../types';
import type { IConfig, PagerSeverity, SeverityRule } from '../../types';
import { PuterClient } from '../types';
import {
meetsMinSeverity,
resolveSeverityOverride,
withinMaxSeverity,
} from './severity';
import { createSlackAlertHandler } from './slack';
import type {
Alarm,
AlarmFields,
AlarmOptions,
AlertHandler,
AlertPayload,
KnownErrorRule,
} from './types';
// ── Types ────────────────────────────────────────────────────────────
export type {
Alarm,
AlarmFields,
AlarmOptions,
AlertHandler,
AlertPayload,
KnownErrorRule,
} from './types';
export type { PagerSeverity } from '../../types';
export interface AlarmFields {
error?: Error;
[key: string]: unknown;
// -- Types ------------------------------------------------------------
interface RegisteredHandler {
name: string;
/** Lowest severity this transport accepts. */
minSeverity: PagerSeverity;
/** Highest severity this transport accepts. */
maxSeverity: PagerSeverity;
handler: AlertHandler;
}
interface AlarmOccurrence {
message: string;
fields: AlarmFields;
timestamp: number;
}
/** Severity used when neither the call site nor config picks one. */
const FALLBACK_SEVERITY: PagerSeverity = 'critical';
/** Keeps `info` alarms out of the paging system unless config says otherwise. */
const DEFAULT_PAGERDUTY_MIN_SEVERITY: PagerSeverity = 'warning';
/** Slack's ceiling once a pager exists: chat gets what doesn't page. */
const DEFAULT_SLACK_MAX_SEVERITY_WITH_PAGER: PagerSeverity = 'info';
interface Alarm {
id: string;
shortId: string;
message: string;
fields: AlarmFields;
error?: Error;
started: number;
timestamps: number[];
occurrences: AlarmOccurrence[];
severity?: PagerSeverity;
noAlert?: boolean;
}
export type PagerSeverity = 'critical' | 'error' | 'warning' | 'info';
export interface AlertPayload {
id: string;
message: string;
source: string;
severity: PagerSeverity;
custom?: Record<string, unknown>;
}
type AlertHandler = (alert: AlertPayload) => Promise<void>;
interface KnownErrorRule {
match: {
id: string;
message?: string;
fields?: Record<string, unknown>;
};
action: {
type: 'no-alert' | 'severity';
value?: PagerSeverity;
};
}
// ── Helpers ──────────────────────────────────────────────────────────
// -- Helpers ----------------------------------------------------------
/**
* Deterministic short identifier derived from an alarm ID.
* Produces a readable 3-word slug like "amber-delta-fox".
* Deterministic short identifier derived from an alarm ID. Produces a readable
* 3-word slug like "amber-delta-fox".
*/
const WORD_POOL = [
'alpha',
@@ -166,16 +158,20 @@ function cleanFields(fields: AlarmFields): Record<string, string> {
return out;
}
// ── AlarmClient ──────────────────────────────────────────────────────
// -- AlarmClient ------------------------------------------------------
/**
* Manages system alarms and dispatches alerts to external paging
* services (PagerDuty, or any registered handler).
* Manages system alarms and routes them to alert transports by severity.
*
* Severity is the routing decision: each transport declares the severity window
* it accepts, so `critical` pages on-call while `info` only reaches the chat
* channel. Config can retier or mute any alarm id after the fact — see
* `pager.severityOverrides` in {@link IConfig}.
*/
export class AlarmClient extends PuterClient {
private alarms = new Map<string, Alarm>();
private aliases = new Map<string, Alarm>();
private alertHandlers: AlertHandler[] = [];
private alertHandlers: RegisteredHandler[] = [];
private knownErrors: KnownErrorRule[] = [];
private draining = false;
private drainLogged = false;
@@ -184,42 +180,11 @@ export class AlarmClient extends PuterClient {
super(config);
}
// ── Lifecycle ────────────────────────────────────────────────────
// -- Lifecycle ----------------------------------------------------
override async onServerStart(): Promise<void> {
const pagerConf = this.config.pager;
if (!pagerConf?.pagerduty?.enabled) return;
const routingKey = pagerConf.pagerduty.routingKey;
if (!routingKey) {
console.warn(
'[alarm] PagerDuty enabled but no routingKey configured',
);
return;
}
const serverId = this.config.serverId;
this.alertHandlers.push(async (alert) => {
await pdEvent({
data: {
routing_key: routingKey,
event_action: 'trigger',
dedup_key: alert.id,
payload: {
summary: alert.message,
source: alert.source,
severity: alert.severity,
custom_details: {
...alert.custom,
server_id: serverId,
},
},
},
});
});
console.log('[alarm] PagerDuty handler registered');
const paging = this.registerPagerDuty();
this.registerSlack({ paging });
}
override onServerPrepareShutdown(): void {
@@ -228,22 +193,107 @@ export class AlarmClient extends PuterClient {
console.log('[alarm] entering drain mode — suppressing new alarms');
}
// ── Public API ───────────────────────────────────────────────────
/** @returns Whether a working PagerDuty transport was registered. */
private registerPagerDuty(): boolean {
const pagerDutyConf = this.config.pager?.pagerduty;
if (!pagerDutyConf?.enabled) return false;
const routingKey = pagerDutyConf.routingKey;
if (!routingKey) {
console.warn(
'[alarm] PagerDuty enabled but no routingKey configured',
);
return false;
}
const serverId = this.config.serverId;
const minSeverity =
pagerDutyConf.minSeverity ?? DEFAULT_PAGERDUTY_MIN_SEVERITY;
this.addAlertHandler(
async (alert) => {
await pdEvent({
data: {
routing_key: routingKey,
event_action: 'trigger',
dedup_key: alert.dedupKey,
payload: {
summary: alert.message,
source: alert.source,
severity: alert.severity,
custom_details: {
...alert.custom,
server_id: serverId,
},
},
},
});
},
{ name: 'pagerduty', minSeverity },
);
console.log(
`[alarm] PagerDuty handler registered (min severity: ${minSeverity})`,
);
return true;
}
private registerSlack({ paging }: { paging: boolean }): void {
const slackConf = this.config.pager?.slack;
if (!slackConf?.enabled) return;
if (!slackConf.webhookUrl) {
console.warn('[alarm] Slack enabled but no webhookUrl configured');
return;
}
const minSeverity = slackConf.minSeverity ?? 'info';
// With a pager taking everything from `warning` up, chat is where the
// rest is recorded — reposting the paging tiers there only trains
// people to skim the channel. Without one, Slack is the only place an
// alarm can land, so it takes all of them.
const maxSeverity =
slackConf.maxSeverity ??
(paging ? DEFAULT_SLACK_MAX_SEVERITY_WITH_PAGER : 'critical');
this.addAlertHandler(
createSlackAlertHandler(slackConf, {
serverId: this.config.serverId,
}),
{ name: 'slack', minSeverity, maxSeverity },
);
console.log(
`[alarm] Slack handler registered (severity ${minSeverity}..${maxSeverity})`,
);
}
// -- Public API ---------------------------------------------------
/**
* Create or update an alarm. If the alarm ID already exists, the
* occurrence count is incremented and a repeat alert is dispatched.
* Create or update an alarm. If the alarm ID already exists, the occurrence
* count is incremented and a repeat alert is dispatched.
*
* `severity` is the PagerDuty severity for this alarm; omit for the
* default 'critical'. Use 'info' / 'warning' for expected-but-worth-
* tracking signals (e.g. a user hitting a rate limit) so they record
* and de-dupe like any other alarm but don't page on-call.
* `severity` decides where the alarm lands:
*
* Critical — a real outage; pages on-call. Reserve it for unhandled server
* errors. error — pages on-call as well; prefer `critical` or `warning`.
* warning — worth a look soon, but nobody gets woken up. info — a record in
* the chat channel; never pages.
*
* Omit it to take `pager.defaultSeverity` (itself defaulting to
* 'critical'). Operators can retier or mute any alarm id from config
* afterwards, so the value here is the starting point, not the last word.
*
* Pass `{ dedup: true }` when repeats of this id are one recurring fault
* that should collapse into a single incident — see {@link AlarmOptions}.
*/
create(
id: string,
message: string,
fields: AlarmFields = {},
severity?: PagerSeverity,
opts: AlarmOptions = {},
): void {
if (this.draining) {
if (!this.drainLogged) {
@@ -267,8 +317,11 @@ export class AlarmClient extends PuterClient {
message,
fields,
severity,
dedup: opts.dedup,
started: Date.now(),
timestamps: [Date.now()],
// `recordOccurrence` below stamps the first occurrence; seeding one
// here too would report every alarm as one occurrence ahead.
timestamps: [],
occurrences: [],
};
if (fields.error) alarm.error = fields.error;
@@ -295,21 +348,33 @@ export class AlarmClient extends PuterClient {
}
/**
* Register an additional alert handler. Handlers are called for
* every alarm that isn't suppressed by a known-error rule.
* Register an additional alert handler. Handlers are called for every alarm
* that isn't suppressed by a known-error rule or muted by config, and whose
* severity falls inside the handler's own `minSeverity`..`maxSeverity`
* window (default: everything).
*/
addAlertHandler(handler: AlertHandler): void {
this.alertHandlers.push(handler);
addAlertHandler(
handler: AlertHandler,
opts: {
name?: string;
minSeverity?: PagerSeverity;
maxSeverity?: PagerSeverity;
} = {},
): void {
this.alertHandlers.push({
name: opts.name ?? `handler-${this.alertHandlers.length}`,
minSeverity: opts.minSeverity ?? 'info',
maxSeverity: opts.maxSeverity ?? 'critical',
handler,
});
}
/**
* Add rules that can suppress or adjust severity of known errors.
*/
/** Add rules that can suppress or adjust severity of known errors. */
setKnownErrors(rules: KnownErrorRule[]): void {
this.knownErrors = rules;
}
// ── Internals ────────────────────────────────────────────────────
// -- Internals ----------------------------------------------------
private recordOccurrence(
alarm: Alarm,
@@ -381,25 +446,72 @@ export class AlarmClient extends PuterClient {
this.dispatchAlert(alarm);
}
/**
* Call-site severity, then any known-error rule (both already on the
* alarm), then the config override — so an operator always has the last
* word over what the code asked for.
*/
private resolveSeverity(alarm: Alarm): SeverityRule {
const base =
alarm.severity ??
this.config.pager?.defaultSeverity ??
FALLBACK_SEVERITY;
return (
resolveSeverityOverride(
alarm.id,
this.config.pager?.severityOverrides,
) ?? base
);
}
private dispatchAlert(alarm: Alarm): void {
const severity = alarm.severity ?? 'critical';
const resolved = this.resolveSeverity(alarm);
if (resolved === 'mute') {
if (!alarm.muteLogged) {
alarm.muteLogged = true;
console.log(`[alarm] MUTED by config ${displayId(alarm)}`);
}
return;
}
alarm.severity = resolved;
const fieldsClean = cleanFields(alarm.fields);
const repeatCount = alarm.timestamps.length;
const id = alarm.id || 'something-bad';
const payload: AlertPayload = {
id: alarm.id || 'something-bad',
id,
// A de-duplicating transport should fold repeats of a recurring
// fault into one incident, but everything else is a fresh event
// each time it fires — the start time keeps occurrence numbering
// from colliding with an incident left open by an earlier boot.
dedupKey: alarm.dedup
? id
: `${id}#${alarm.started}.${repeatCount}`,
shortId: alarm.shortId,
message: alarm.message || alarm.id || 'something bad happened',
source: 'alarm',
severity,
severity: resolved,
fields: fieldsClean,
trace: alarm.error?.stack,
repeatCount,
isRepeat: repeatCount > 1,
custom: {
fields: fieldsClean,
trace: alarm.error?.stack,
repeat_count: alarm.timestamps.length,
repeat_count: repeatCount,
},
};
for (const handler of this.alertHandlers) {
for (const { name, minSeverity, maxSeverity, handler } of this
.alertHandlers) {
if (!meetsMinSeverity(resolved, minSeverity)) continue;
if (!withinMaxSeverity(resolved, maxSeverity)) continue;
handler(payload).catch((err) => {
console.error(`[alarm] alert handler failed: ${err?.message}`);
console.error(
`[alarm] ${name} alert handler failed: ${err?.message}`,
);
});
}
}
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest';
import { meetsMinSeverity, resolveSeverityOverride } from './severity';
describe('meetsMinSeverity', () => {
it('accepts everything at or above the floor', () => {
expect(meetsMinSeverity('critical', 'warning')).toBe(true);
expect(meetsMinSeverity('error', 'warning')).toBe(true);
expect(meetsMinSeverity('warning', 'warning')).toBe(true);
expect(meetsMinSeverity('info', 'warning')).toBe(false);
});
it('lets an info floor take every severity', () => {
for (const severity of [
'critical',
'error',
'warning',
'info',
] as const) {
expect(meetsMinSeverity(severity, 'info')).toBe(true);
}
});
});
describe('resolveSeverityOverride', () => {
it('returns undefined without overrides or on no match', () => {
expect(resolveSeverityOverride('a:b', undefined)).toBeUndefined();
expect(
resolveSeverityOverride('a:b', { 'c:*': 'info' }),
).toBeUndefined();
});
it('matches an exact id', () => {
expect(
resolveSeverityOverride('cronMonitor:lowSignupRate', {
'cronMonitor:lowSignupRate': 'info',
}),
).toBe('info');
});
it('matches a prefix pattern', () => {
expect(
resolveSeverityOverride('cronMonitor:high_aiLogEntries', {
'cronMonitor:*': 'warning',
}),
).toBe('warning');
});
it('prefers the exact id over a prefix pattern', () => {
expect(
resolveSeverityOverride('cronMonitor:lowSignupRate', {
'cronMonitor:*': 'warning',
'cronMonitor:lowSignupRate': 'mute',
}),
).toBe('mute');
});
it('prefers the longest matching prefix', () => {
expect(
resolveSeverityOverride('abuse:card-verification:setup-failed', {
'abuse:*': 'info',
'abuse:card-verification:*': 'mute',
}),
).toBe('mute');
});
it('drops an unrecognized rule rather than guessing', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(
resolveSeverityOverride('a:b', {
'a:b': 'silent' as unknown as 'mute',
}),
).toBeUndefined();
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});
+94
View File
@@ -0,0 +1,94 @@
/*
* 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 { PagerSeverity, SeverityRule } from '../../types';
/** Ascending urgency. A transport takes everything at or above its floor. */
const RANK: Record<PagerSeverity, number> = {
info: 0,
warning: 1,
error: 2,
critical: 3,
};
const SEVERITIES = Object.keys(RANK) as PagerSeverity[];
export function isPagerSeverity(value: unknown): value is PagerSeverity {
return typeof value === 'string' && value in RANK;
}
/** True when `severity` is urgent enough for a transport whose floor is `min`. */
export function meetsMinSeverity(
severity: PagerSeverity,
min: PagerSeverity,
): boolean {
return RANK[severity] >= RANK[min];
}
/**
* True when `severity` is quiet enough for a transport whose ceiling is `max`.
* A ceiling is what keeps a chat transport from repeating everything the pager
* already delivered.
*/
export function withinMaxSeverity(
severity: PagerSeverity,
max: PagerSeverity,
): boolean {
return RANK[severity] <= RANK[max];
}
/**
* Look up the operator override for an alarm id. Exact ids win over prefix
* patterns (`cronMonitor:*`); among patterns the longest prefix wins, so a
* specific rule can carve an exception out of a broad one.
*/
export function resolveSeverityOverride(
id: string,
overrides: Record<string, SeverityRule> | undefined,
): SeverityRule | undefined {
if (!overrides) return undefined;
const exact = overrides[id];
if (exact !== undefined) return validRule(id, exact);
let bestLength = -1;
let best: SeverityRule | undefined;
for (const [pattern, rule] of Object.entries(overrides)) {
if (!pattern.endsWith('*')) continue;
const prefix = pattern.slice(0, -1);
if (!id.startsWith(prefix)) continue;
if (prefix.length <= bestLength) continue;
bestLength = prefix.length;
best = rule;
}
return best === undefined ? undefined : validRule(id, best);
}
/**
* A typo in the override map would otherwise silently mute or escalate an
* alarm, so an unrecognized value is dropped with a warning instead.
*/
function validRule(id: string, rule: SeverityRule): SeverityRule | undefined {
if (rule === 'mute' || isPagerSeverity(rule)) return rule;
console.warn(
`[alarm] ignoring invalid severity override "${rule}" for ${id} ` +
`(expected mute or one of ${SEVERITIES.join(', ')})`,
);
return undefined;
}
+206
View File
@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { buildSlackMessage, createSlackAlertHandler } from './slack';
import type { AlertPayload } from './types';
const alert = (over: Partial<AlertPayload> = {}): AlertPayload => ({
id: 'cronMonitor:high_aiLogEntries',
dedupKey: 'cronMonitor:high_aiLogEntries#1.1',
shortId: 'amber-delta-fox',
message: 'High AI log entries: 1200 in the last 10 minutes',
source: 'alarm',
severity: 'warning',
fields: { count: '1200', threshold: '1000' },
repeatCount: 1,
isRepeat: false,
...over,
});
describe('buildSlackMessage', () => {
it('renders severity, message and fields', () => {
const msg = buildSlackMessage(alert(), {
channel: '#alerts',
username: 'puter-alarms',
serverId: 'oregon',
});
expect(msg.text).toContain('[WARNING]');
expect(msg.text).toContain('High AI log entries');
expect(msg.channel).toBe('#alerts');
expect(msg.username).toBe('puter-alarms');
expect(msg.attachments[0].fields).toEqual([
{ title: 'count', value: '1200', short: true },
{ title: 'threshold', value: '1000', short: true },
]);
expect(msg.attachments[0].footer).toBe(
'amber-delta-fox • cronMonitor:high_aiLogEntries • oregon',
);
});
it('marks repeats with an occurrence count', () => {
const msg = buildSlackMessage(
alert({ repeatCount: 7, isRepeat: true }),
);
expect(msg.text).toContain('(x7)');
});
it('colours each severity differently', () => {
const colors = (['critical', 'error', 'warning', 'info'] as const).map(
(severity) =>
buildSlackMessage(alert({ severity })).attachments[0].color,
);
expect(new Set(colors).size).toBe(4);
});
it('puts the stack in a code block and keeps it out of the fields', () => {
const msg = buildSlackMessage(
alert({
fields: { error: 'Error: boom', path: '/api/x' },
trace: 'Error: boom\n at handler',
}),
);
expect(msg.attachments[0].text).toBe(
'```Error: boom\n at handler```',
);
expect(msg.attachments[0].fields.map((f) => f.title)).toEqual(['path']);
});
it('truncates long values and caps the field count', () => {
const fields: Record<string, string> = { long: 'x'.repeat(1000) };
for (let i = 0; i < 30; i++) fields[`f${i}`] = String(i);
const msg = buildSlackMessage(alert({ fields }));
expect(msg.attachments[0].fields.length).toBe(12);
expect(msg.attachments[0].fields[0].value).toHaveLength(400);
expect(msg.attachments[0].fields[0].value.endsWith('…')).toBe(true);
});
it('omits channel and username when unset', () => {
const msg = buildSlackMessage(alert());
expect(msg.channel).toBeUndefined();
expect(msg.username).toBeUndefined();
});
});
describe('createSlackAlertHandler', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn(async () => ({ ok: true, status: 200 }));
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('posts the payload to the webhook', async () => {
const handler = createSlackAlertHandler({
webhookUrl: 'https://hooks.example/abc',
channel: '#alerts',
});
await handler(alert());
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://hooks.example/abc');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body).channel).toBe('#alerts');
});
it('throttles repeats of the same alarm and lets other ids through', async () => {
const handler = createSlackAlertHandler({
webhookUrl: 'https://hooks.example/abc',
repeatThrottleMs: 60_000,
});
await handler(alert());
await handler(alert({ repeatCount: 2, isRepeat: true }));
expect(fetchMock).toHaveBeenCalledTimes(1);
await handler(alert({ id: 'other:alarm' }));
expect(fetchMock).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(60_000);
await handler(alert({ repeatCount: 3, isRepeat: true }));
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('posts every occurrence when throttling is disabled', async () => {
const handler = createSlackAlertHandler({
webhookUrl: 'https://hooks.example/abc',
repeatThrottleMs: 0,
});
await handler(alert());
await handler(alert({ repeatCount: 2, isRepeat: true }));
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('does not let a failed post consume the throttle slot', async () => {
fetchMock.mockResolvedValueOnce({ ok: false, status: 500 });
const handler = createSlackAlertHandler({
webhookUrl: 'https://hooks.example/abc',
repeatThrottleMs: 60_000,
});
await expect(handler(alert())).rejects.toThrow('500');
await handler(alert());
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('releases the throttle slot when the post itself errors', async () => {
fetchMock.mockRejectedValueOnce(new Error('socket hang up'));
const handler = createSlackAlertHandler({
webhookUrl: 'https://hooks.example/abc',
repeatThrottleMs: 60_000,
});
await expect(handler(alert())).rejects.toThrow('socket hang up');
await handler(alert());
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('evicts throttle entries that have aged out once the map fills', async () => {
const throttleMs = 60_000;
const handler = createSlackAlertHandler({
webhookUrl: 'https://hooks.example/abc',
repeatThrottleMs: throttleMs,
});
// Fill to the high-water mark, then age every entry past the window.
for (let i = 0; i < 5000; i++) {
await handler(alert({ id: `aged:${i}` }));
}
vi.advanceTimersByTime(throttleMs);
await handler(alert({ id: 'fresh' }));
expect(fetchMock).toHaveBeenCalledTimes(5001);
// The aged ids were pruned, so they post again immediately.
await handler(alert({ id: 'aged:0' }));
expect(fetchMock).toHaveBeenCalledTimes(5002);
});
it('drops the oldest entry when the map is full of live ones', async () => {
const handler = createSlackAlertHandler({
webhookUrl: 'https://hooks.example/abc',
repeatThrottleMs: 60_000,
});
for (let i = 0; i < 5000; i++) {
await handler(alert({ id: `live:${i}` }));
}
// Nothing has aged out, so the oldest id makes room for the new one.
await handler(alert({ id: 'newcomer' }));
expect(fetchMock).toHaveBeenCalledTimes(5001);
await handler(alert({ id: 'live:0' }));
expect(fetchMock).toHaveBeenCalledTimes(5002);
// The newcomer is still throttled.
await handler(alert({ id: 'newcomer' }));
expect(fetchMock).toHaveBeenCalledTimes(5002);
});
});
+178
View File
@@ -0,0 +1,178 @@
/*
* 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 { ISlackAlertConfig, PagerSeverity } from '../../types';
import type { AlertHandler, AlertPayload } from './types';
const REQUEST_TIMEOUT_MS = 5000;
export const DEFAULT_REPEAT_THROTTLE_MS = 15 * 60 * 1000;
/** Beyond this many tracked alarm ids, throttle state is pruned. */
const MAX_THROTTLE_ENTRIES = 5000;
const MAX_FIELDS = 12;
const MAX_FIELD_VALUE = 400;
const MAX_TRACE = 1500;
/** Values under this length sit two-per-row in the Slack attachment. */
const SHORT_FIELD_LENGTH = 40;
const STYLE: Record<PagerSeverity, { emoji: string; color: string }> = {
critical: { emoji: ':rotating_light:', color: '#d64545' },
error: { emoji: ':red_circle:', color: '#e08d4c' },
warning: { emoji: ':warning:', color: '#e0b84c' },
info: { emoji: ':information_source:', color: '#4c8de0' },
};
interface SlackField {
title: string;
value: string;
short: boolean;
}
export interface SlackMessage {
text: string;
channel?: string;
username?: string;
icon_emoji?: string;
attachments: Array<{
color: string;
fallback: string;
fields: SlackField[];
footer: string;
mrkdwn_in: string[];
text?: string;
}>;
}
function truncate(value: string, max: number): string {
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
}
/**
* Render an alert as an incoming-webhook payload: severity-coloured attachment,
* the alarm's fields as a table, and the stack (when there is one) as a code
* block. Split out from the handler so the formatting is testable on its own.
*/
export function buildSlackMessage(
alert: AlertPayload,
opts: { channel?: string; username?: string; serverId?: string } = {},
): SlackMessage {
const style = STYLE[alert.severity] ?? STYLE.info;
const repeat = alert.isRepeat ? ` (x${alert.repeatCount})` : '';
const headline = `${style.emoji} *[${alert.severity.toUpperCase()}]* ${alert.message}${repeat}`;
const fields: SlackField[] = [];
for (const [key, value] of Object.entries(alert.fields)) {
if (fields.length >= MAX_FIELDS) break;
// `error` is already rendered as the trace block below.
if (key === 'error') continue;
const rendered = truncate(value, MAX_FIELD_VALUE);
fields.push({
title: key,
value: rendered,
short: rendered.length <= SHORT_FIELD_LENGTH,
});
}
const footerParts = [alert.shortId, alert.id];
if (opts.serverId) footerParts.push(opts.serverId);
return {
text: headline,
...(opts.channel ? { channel: opts.channel } : {}),
...(opts.username ? { username: opts.username } : {}),
attachments: [
{
color: style.color,
fallback: `[${alert.severity}] ${alert.message}`,
fields,
footer: footerParts.join(' • '),
mrkdwn_in: ['text'],
...(alert.trace
? { text: '```' + truncate(alert.trace, MAX_TRACE) + '```' }
: {}),
},
],
};
}
/**
* Post alerts to a Slack incoming webhook. Repeats of the same alarm id are
* throttled so a hot loop doesn't flood the channel — the occurrence count on
* the next post that gets through tells the reader what they missed.
*/
export function createSlackAlertHandler(
conf: ISlackAlertConfig,
opts: { serverId?: string } = {},
): AlertHandler {
const webhookUrl = conf.webhookUrl as string;
const throttleMs = conf.repeatThrottleMs ?? DEFAULT_REPEAT_THROTTLE_MS;
const lastPosted = new Map<string, number>();
const shouldPost = (alert: AlertPayload): boolean => {
if (throttleMs <= 0) return true;
const now = Date.now();
const previous = lastPosted.get(alert.id);
if (previous !== undefined && now - previous < throttleMs) return false;
if (lastPosted.size >= MAX_THROTTLE_ENTRIES) {
for (const [id, at] of lastPosted) {
if (now - at >= throttleMs) lastPosted.delete(id);
}
// Still full of live entries — drop the oldest to stay bounded.
if (lastPosted.size >= MAX_THROTTLE_ENTRIES) {
const oldest = lastPosted.keys().next().value;
if (oldest !== undefined) lastPosted.delete(oldest);
}
}
lastPosted.set(alert.id, now);
return true;
};
return async (alert) => {
if (!shouldPost(alert)) return;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
buildSlackMessage(alert, {
channel: conf.channel,
username: conf.username,
serverId: opts.serverId,
}),
),
signal: controller.signal,
});
if (!res.ok) {
// A rejected post shouldn't leave the id marked as delivered.
lastPosted.delete(alert.id);
throw new Error(`Slack webhook returned ${res.status}`);
}
} catch (err) {
lastPosted.delete(alert.id);
throw err;
} finally {
clearTimeout(timer);
}
};
}
+95
View File
@@ -0,0 +1,95 @@
/*
* 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 { PagerSeverity } from '../../types';
export interface AlarmFields {
error?: Error;
[key: string]: unknown;
}
export interface AlarmOccurrence {
message: string;
fields: AlarmFields;
timestamp: number;
}
export interface AlarmOptions {
/**
* Collapse repeats of this alarm id into a single incident on transports
* that de-duplicate (PagerDuty). Only right when the id already pins down
* one recurring fault — an uncaught error on a route, say — so that N
* occurrences really are one thing to fix. Off by default: every other
* alarm raises its own incident per occurrence.
*/
dedup?: boolean;
}
export interface Alarm extends AlarmOptions {
id: string;
shortId: string;
message: string;
fields: AlarmFields;
error?: Error;
started: number;
timestamps: number[];
occurrences: AlarmOccurrence[];
severity?: PagerSeverity;
noAlert?: boolean;
/** Set once a config mute has been logged, so it's reported only once. */
muteLogged?: boolean;
}
export interface AlertPayload {
id: string;
/**
* Key a de-duplicating transport groups by. Equal to `id` for alarms raised
* with `dedup`, and unique per occurrence for everything else.
*/
dedupKey: string;
/** Readable slug for the same alarm, for humans quoting it back. */
shortId: string;
message: string;
source: string;
severity: PagerSeverity;
/** Field values rendered as strings, ready to display. */
fields: Record<string, string>;
/** Stack of the attached error, when the alarm carried one. */
trace?: string;
/** Total occurrences of this alarm so far, including this one. */
repeatCount: number;
/** False for the first occurrence of an alarm id. */
isRepeat: boolean;
/** PagerDuty `custom_details` payload. */
custom?: Record<string, unknown>;
}
export type AlertHandler = (alert: AlertPayload) => Promise<void>;
export interface KnownErrorRule {
match: {
id: string;
message?: string;
fields?: Record<string, unknown>;
};
action: {
type: 'no-alert' | 'severity';
value?: PagerSeverity;
};
}
@@ -0,0 +1,50 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Optional ClickHouse client.
*
* ClickHouse is NOT a hard dependency. Core Puter runs without it — the app
* stats read path (open / unique-user counts) falls back to the primary SQL
* database. `this.clients.clickhouse` is therefore typed as optional and is
* `undefined` in default and self-hosted setups; callers MUST branch on its
* presence and fall back to SQL.
*
* A production deployment can register a real ClickHouse client via an
* extension (`extension.registerClient('clickhouse', client)`) to offload the
* analytics queries off the primary database and keep the stats path fast at
* scale. When registered, the instance flows into `this.clients.clickhouse`
* everywhere it's typed.
*
* Only the surface the stats path actually consumes is declared here. Extend
* this interface (don't widen to `any`) when a new query shape is needed.
*/
export interface ClickhouseQueryResult {
json<T = Record<string, unknown>>(): Promise<T[]>;
}
export interface ClickhouseQueryParams {
query: string;
query_params?: Record<string, unknown>;
format?: string;
}
export interface ClickhouseClient {
query(params: ClickhouseQueryParams): Promise<ClickhouseQueryResult>;
}
@@ -0,0 +1,247 @@
/**
* 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 { describe, expect, it } from 'vitest';
import type { IConfig } from '../../types';
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
import { DatabaseClientFactory } from './index.js';
import { MySQLDatabaseClient } from './MySQLDatabaseClient.js';
import { PostgresDatabaseClient } from './PostgresDatabaseClient.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
const config = (engine: string): IConfig =>
({ port: 0, extensions: [], database: { engine } }) as IConfig;
const sqlite = () => new SqliteDatabaseClient(config('sqlite'));
const mysql = () => new MySQLDatabaseClient(config('mysql'));
const postgres = () => new PostgresDatabaseClient(config('postgres'));
/**
* Minimal concrete client backed by fixture rows — exercises the shared helpers
* on `AbstractDatabaseClient` that the real engines inherit.
*/
class FixtureDatabaseClient extends AbstractDatabaseClient {
override readonly engineName = 'fixture';
readonly reads: string[] = [];
readonly preads: string[] = [];
readonly writes: { query: string; params: unknown[] }[] = [];
constructor(
private readonly replicaRows: Record<string, unknown>[] | Error,
private readonly primaryRows: Record<string, unknown>[] = [],
) {
super(config('fixture'));
}
override async read(query: string): Promise<Record<string, unknown>[]> {
this.reads.push(query);
if (this.replicaRows instanceof Error) throw this.replicaRows;
return this.replicaRows;
}
override async pread(query: string): Promise<Record<string, unknown>[]> {
this.preads.push(query);
return this.primaryRows;
}
override async write(
query: string,
params: unknown[] = [],
): Promise<WriteResult> {
this.writes.push({ query, params });
return { insertId: 1, affectedRows: 1, anyRowsAffected: true };
}
}
describe('DatabaseClientFactory', () => {
it('picks sqlite when no engine is configured', () => {
expect(
new DatabaseClientFactory({ port: 0, extensions: [] } as IConfig),
).toBeInstanceOf(SqliteDatabaseClient);
});
it('refuses an unknown engine name', () => {
expect(() => new DatabaseClientFactory(config('cassandra'))).toThrow(
'Unknown database engine: cassandra',
);
});
});
describe('AbstractDatabaseClient — unimplemented surface', () => {
const bare = () => new AbstractDatabaseClient(config('none'));
it('refuses to run queries until a subclass implements them', async () => {
await expect(bare().read('SELECT 1')).rejects.toThrow(
'DatabaseClient.read() not implemented',
);
await expect(bare().pread('SELECT 1')).rejects.toThrow(
'DatabaseClient.pread() not implemented',
);
await expect(bare().write('DELETE FROM x')).rejects.toThrow(
'DatabaseClient.write() not implemented',
);
await expect(bare().batchWrite([])).rejects.toThrow(
'DatabaseClient.batchWrite() not implemented',
);
});
});
describe('AbstractDatabaseClient — replica-aware reads', () => {
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([
{ id: 1 },
]);
});
it('falls back to the primary when the replica has not caught up', async () => {
const client = new FixtureDatabaseClient([], [{ id: 2 }]);
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
{ id: 2 },
]);
});
it('falls back to the primary when the replica read throws', async () => {
const client = new FixtureDatabaseClient(
new Error('replica unavailable'),
[{ id: 3 }],
);
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
{ id: 3 },
]);
});
it('names the failing query when a required read finds nothing', async () => {
const client = new FixtureDatabaseClient([], []);
await expect(
client.requireRead('SELECT * FROM `user` WHERE `id` = ?'),
).rejects.toThrow(
'required read returned no rows: SELECT * FROM `user` WHERE `id` = ?',
);
});
});
describe('AbstractDatabaseClient — SQL generation', () => {
it('generates an INSERT with quoted columns and placeholders', async () => {
const client = new FixtureDatabaseClient([]);
await client.insert('user', { username: 'ada', email: null });
expect(client.writes).toEqual([
{
query: 'INSERT INTO `user` (`username`, `email`) VALUES (?, ?)',
params: ['ada', null],
},
]);
});
it('quotes each dotted segment and escapes embedded backticks', () => {
const client = sqlite();
expect(client.quoteIdentifier('user')).toBe('`user`');
expect(client.quoteIdentifier('db.user.id')).toBe('`db`.`user`.`id`');
expect(client.quoteIdentifier('user.*')).toBe('`user`.*');
expect(client.quoteIdentifier('we`ird')).toBe('`we``ird`');
});
it('quotes postgres identifiers with double quotes', () => {
const client = postgres();
expect(client.quoteIdentifier('db.user')).toBe('"db"."user"');
expect(client.quoteIdentifier('we"ird')).toBe('"we""ird"');
expect(client.quoteIdentifier('user.*')).toBe('"user".*');
});
it('renders booleans the way each engine expects', () => {
expect(sqlite().booleanLiteral(true)).toBe('1');
expect(sqlite().booleanValue(false)).toBe(0);
expect(postgres().booleanLiteral(true)).toBe('TRUE');
expect(postgres().booleanLiteral(false)).toBe('FALSE');
expect(postgres().booleanValue(true)).toBe(true);
});
it('picks the engine-specific ignore-conflict syntax', () => {
expect(sqlite().insertIgnoreInto('kv')).toBe(
'INSERT OR IGNORE INTO `kv`',
);
expect(sqlite().insertIgnoreSuffix()).toBe('');
expect(postgres().insertIgnoreInto('kv')).toBe('INSERT INTO "kv"');
expect(postgres().insertIgnoreSuffix()).toBe(' ON CONFLICT DO NOTHING');
expect(mysql().insertIgnoreInto('kv')).toBe('INSERT IGNORE INTO `kv`');
expect(mysql().insertIgnoreSuffix()).toBe('');
});
it('builds an upsert clause per engine', () => {
expect(mysql().upsertClause(['user_id'], ['value', 'dt'])).toBe(
'ON DUPLICATE KEY UPDATE `value` = ?, `dt` = ?',
);
expect(sqlite().upsertClause(['user_id', 'app'], ['value'])).toBe(
'ON CONFLICT(`user_id`, `app`) DO UPDATE SET `value` = ?',
);
expect(postgres().upsertClause(['user_id'], ['value'])).toBe(
'ON CONFLICT("user_id") DO UPDATE SET "value" = ?',
);
});
it('rejects an upsert with nothing to update', () => {
expect(() => sqlite().upsertClause(['user_id'], [])).toThrow(
'upsertClause requires at least one update column',
);
});
it('extracts JSON text using each engine dialect', () => {
expect(sqlite().jsonTextExtract('`metadata`', ['a', 'b'])).toBe(
"json_extract(`metadata`, '$.a.b')",
);
expect(mysql().jsonTextExtract('`metadata`', ['a'])).toBe(
"JSON_UNQUOTE(JSON_EXTRACT(`metadata`, '$.a'))",
);
expect(postgres().jsonTextExtract('"metadata"', ['a', 'b'])).toBe(
`"metadata" #>> ARRAY['a', 'b']`,
);
});
it('escapes quotes inside JSON path segments', () => {
expect(sqlite().jsonTextExtract('`m`', ["it's"])).toBe(
"json_extract(`m`, '$.it''s')",
);
});
it('coalesces expressions and rejects an empty list', () => {
expect(sqlite().nullCoalesce('`a`', '`b`', '0')).toBe(
'COALESCE(`a`, `b`, 0)',
);
expect(() => sqlite().nullCoalesce()).toThrow(
'nullCoalesce requires at least one expression',
);
});
it('only postgres needs a RETURNING clause to learn the insert id', () => {
expect(postgres().returningIdClause()).toBe(' RETURNING id');
expect(sqlite().returningIdClause()).toBe('');
expect(mysql().returningIdClause()).toBe('');
});
it('falls back to `otherwise` for an engine with no explicit choice', () => {
const client = new FixtureDatabaseClient([]);
expect(
client.case({ sqlite: 'a', mysql: 'b', otherwise: 'fallback' }),
).toBe('fallback');
expect(client.case({ fixture: 'exact', otherwise: 'fallback' })).toBe(
'exact',
);
});
});
+109 -27
View File
@@ -1,4 +1,4 @@
/**
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
@@ -31,12 +31,14 @@ export interface BatchEntry {
values: unknown[];
}
type SqlJsonPath = readonly [string, ...string[]];
/**
* Base database client. Subclasses must override every method that throws here.
*
* Do not instantiate directly — use the factory exported from
* `clients/database/index.ts` which picks the right implementation
* based on `config.database.engine`.
* `clients/database/index.ts` which picks the right implementation based on
* `config.database.engine`.
*/
export class AbstractDatabaseClient extends PuterClient {
/** Short name used by `case()` to pick engine-specific values. */
@@ -50,9 +52,7 @@ export class AbstractDatabaseClient extends PuterClient {
// Abstract interface — subclasses MUST override
// ------------------------------------------------------------------
/**
* Execute a read query. Returns an array of row objects.
*/
/** Execute a read query. Returns an array of row objects. */
async read(
_query: string,
_params: unknown[] = [],
@@ -61,9 +61,9 @@ export class AbstractDatabaseClient extends PuterClient {
}
/**
* Read that prefers the primary database (useful when read-replicas
* may have replication lag). In single-node setups this is identical
* to `read()`.
* Read that prefers the primary database (useful when read-replicas may
* have replication lag). In single-node setups this is identical to
* `read()`.
*/
async pread(
_query: string,
@@ -72,16 +72,12 @@ export class AbstractDatabaseClient extends PuterClient {
throw new Error('DatabaseClient.pread() not implemented');
}
/**
* Execute a write query (INSERT / UPDATE / DELETE).
*/
/** Execute a write query (INSERT / UPDATE / DELETE). */
async write(_query: string, _params: unknown[] = []): Promise<WriteResult> {
throw new Error('DatabaseClient.write() not implemented');
}
/**
* Execute multiple write statements in a single transaction.
*/
/** Execute multiple write statements in a single transaction. */
async batchWrite(_entries: BatchEntry[]): Promise<void> {
throw new Error('DatabaseClient.batchWrite() not implemented');
}
@@ -101,16 +97,17 @@ export class AbstractDatabaseClient extends PuterClient {
const cols = Object.keys(data);
const values = Object.values(data);
const sql =
`INSERT INTO \`${tableName}\` ` +
`(${cols.map((c) => `\`${c}\``).join(', ')}) ` +
`VALUES (${cols.map(() => '?').join(', ')})`;
`INSERT INTO ${this.quoteIdentifier(tableName)} ` +
`(${cols.map((c) => this.quoteIdentifier(c)).join(', ')}) ` +
`VALUES (${cols.map(() => '?').join(', ')})` +
this.returningIdClause();
return this.write(sql, values);
}
/**
* Like `read()` but falls back to the primary when read-replicas are
* in use. Subclasses may override with replica-aware logic; the
* default delegates to `pread()`.
* Like `read()` but falls back to the primary when read-replicas are in
* use. Subclasses may override with replica-aware logic; the default
* delegates to `pread()`.
*/
async tryHardRead(
query: string,
@@ -130,9 +127,7 @@ export class AbstractDatabaseClient extends PuterClient {
return primary;
}
/**
* Like `tryHardRead()` but throws when the result set is empty.
*/
/** Like `tryHardRead()` but throws when the result set is empty. */
async requireRead(
query: string,
params: unknown[] = [],
@@ -148,9 +143,12 @@ export class AbstractDatabaseClient extends PuterClient {
* Return the value from `choices` that matches the current engine.
*
* Usage:
* ```
* db.case({ sqlite: "datetime('now')", mysql: 'NOW()', otherwise: 'NOW()' })
* ```
*
* db.case({
* sqlite: "datetime('now')",
* mysql: 'NOW()',
* otherwise: 'NOW()',
* });
*
* If the engine name isn't present in `choices`, falls back to
* `choices.otherwise`.
@@ -161,4 +159,88 @@ export class AbstractDatabaseClient extends PuterClient {
}
return choices.otherwise as T;
}
quoteIdentifier(identifier: string): string {
return identifier
.split('.')
.map((part) => {
if (part === '*') return part;
return `\`${part.replaceAll('`', '``')}\``;
})
.join('.');
}
booleanLiteral(value: boolean): string {
return value ? '1' : '0';
}
booleanValue(value: boolean): boolean | 0 | 1 {
return value ? 1 : 0;
}
insertIgnoreInto(tableName: string): string {
const table = this.quoteIdentifier(tableName);
return this.case({
sqlite: `INSERT OR IGNORE INTO ${table}`,
postgres: `INSERT INTO ${table}`,
otherwise: `INSERT IGNORE INTO ${table}`,
});
}
insertIgnoreSuffix(): string {
return this.case({
postgres: ' ON CONFLICT DO NOTHING',
otherwise: '',
});
}
upsertClause(
conflictColumns: readonly string[],
updateColumns: readonly string[],
): string {
if (updateColumns.length === 0) {
throw new Error('upsertClause requires at least one update column');
}
const updateList = updateColumns
.map((column) => `${this.quoteIdentifier(column)} = ?`)
.join(', ');
return this.case({
mysql: `ON DUPLICATE KEY UPDATE ${updateList}`,
otherwise: `ON CONFLICT(${conflictColumns
.map((column) => this.quoteIdentifier(column))
.join(', ')}) DO UPDATE SET ${updateList}`,
});
}
jsonTextExtract(jsonExpression: string, path: SqlJsonPath): string {
const sqlitePath = `$${path.map((part) => `.${part}`).join('')}`;
return this.case({
sqlite: `json_extract(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)})`,
mysql: `JSON_UNQUOTE(JSON_EXTRACT(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)}))`,
postgres: `${jsonExpression} #>> ARRAY[${path
.map((part) => this.sqlStringLiteral(part))
.join(', ')}]`,
otherwise: `JSON_UNQUOTE(JSON_EXTRACT(${jsonExpression}, ${this.sqlStringLiteral(sqlitePath)}))`,
});
}
nullCoalesce(...expressions: readonly string[]): string {
if (expressions.length === 0) {
throw new Error('nullCoalesce requires at least one expression');
}
return `COALESCE(${expressions.join(', ')})`;
}
returningIdClause(): string {
return this.case({
postgres: ' RETURNING id',
otherwise: '',
});
}
protected sqlStringLiteral(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
}
@@ -0,0 +1,847 @@
/*
* 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 { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { IConfig } from '../../types';
import {
MySQLDatabaseClient,
compareMigrationFilenames,
} from './MySQLDatabaseClient.js';
interface FakeConnection {
query: ReturnType<typeof vi.fn>;
execute: ReturnType<typeof vi.fn>;
beginTransaction: ReturnType<typeof vi.fn>;
commit: ReturnType<typeof vi.fn>;
rollback: ReturnType<typeof vi.fn>;
release: ReturnType<typeof vi.fn>;
}
interface FakePool {
poolConfig: Record<string, unknown>;
connectionHandlers: ((conn: unknown) => void)[];
connections: FakeConnection[];
calls: { sql: string; values?: unknown[] }[];
ended: boolean;
endError: unknown;
endThrows: unknown;
respond: (sql: string, values?: unknown[]) => unknown;
on: (event: string, handler: (conn: unknown) => void) => FakePool;
promise: () => { getConnection: () => Promise<FakeConnection> };
end: (cb: (err?: unknown) => void) => void;
}
// mysql2 is the wire boundary: stub the driver's pool so we can assert on
// the exact SQL, parameters and connection choreography the adapter emits.
const { createPoolMock, createdPools } = vi.hoisted(() => {
const createdPools: FakePool[] = [];
const createPoolMock = vi.fn((poolConfig: Record<string, unknown>) => {
const pool: FakePool = {
poolConfig,
connectionHandlers: [],
connections: [],
calls: [],
ended: false,
endError: null,
endThrows: null,
respond: () => [[], undefined],
on(event, handler) {
if (event === 'connection') {
pool.connectionHandlers.push(handler);
}
return pool;
},
promise() {
return {
getConnection: async () => {
const run = async (sql: string, values?: unknown[]) => {
pool.calls.push({ sql, values });
return pool.respond(sql, values);
};
const conn: FakeConnection = {
query: vi.fn(run),
execute: vi.fn(run),
beginTransaction: vi.fn(async () => {}),
commit: vi.fn(async () => {}),
rollback: vi.fn(async () => {}),
release: vi.fn(),
};
pool.connections.push(conn);
return conn;
},
};
},
end(cb) {
if (pool.endThrows) throw pool.endThrows;
pool.ended = true;
cb(pool.endError ?? undefined);
},
};
createdPools.push(pool);
return pool;
});
return { createPoolMock, createdPools };
});
vi.mock('mysql2', () => ({ createPool: createPoolMock }));
describe('compareMigrationFilenames', () => {
it('orders numbered migrations numerically, not lexically', () => {
const input = [
'mysql_mig_10.sql',
'mysql_mig_2.sql',
'mysql_mig_1.sql',
'mysql_mig_9.sql',
'mysql_mig_3.sql',
];
const sorted = [...input].sort(compareMigrationFilenames);
expect(sorted).toEqual([
'mysql_mig_1.sql',
'mysql_mig_2.sql',
'mysql_mig_3.sql',
'mysql_mig_9.sql',
'mysql_mig_10.sql',
]);
});
it('keeps mig_10 after mig_9 when the real prod set is shuffled', () => {
// Reflects the current migrations/mysql/ listing — guards against
// a future "let's just rename to padded" suggestion accidentally
// re-introducing the lex bug if the rename is incomplete.
const real = [
'mysql_mig_9.sql',
'mysql_mig_3.sql',
'mysql_mig_10.sql',
'mysql_mig_1.sql',
'mysql_mig_7.sql',
'mysql_mig_5.sql',
'mysql_mig_2.sql',
'mysql_mig_4.sql',
'mysql_mig_8.sql',
'mysql_mig_6.sql',
];
const sorted = [...real].sort(compareMigrationFilenames);
for (let i = 1; i <= sorted.length; i += 1) {
expect(sorted[i - 1]).toBe(`mysql_mig_${i}.sql`);
}
});
it('sorts non-numeric filenames after numbered ones, lexically among themselves', () => {
// Numbered files always run first (they're the canonical history);
// unmatched names follow in localeCompare order. Mixing the two
// sets prevents a vendor dump from accidentally wedging itself
// between mig_4 and mig_5 if it happened to lex-sort there.
const mixed = [
'mysql_mig_2.sql',
'mysql_vendor_dump.sql',
'mysql_mig_10.sql',
'mysql_bootstrap.sql',
'mysql_mig_1.sql',
];
const sorted = [...mixed].sort(compareMigrationFilenames);
expect(sorted).toEqual([
'mysql_mig_1.sql',
'mysql_mig_2.sql',
'mysql_mig_10.sql',
'mysql_bootstrap.sql',
'mysql_vendor_dump.sql',
]);
});
it('is stable for already-sorted input', () => {
const sorted = [
'mysql_mig_1.sql',
'mysql_mig_2.sql',
'mysql_mig_10.sql',
'mysql_mig_11.sql',
];
expect([...sorted].sort(compareMigrationFilenames)).toEqual(sorted);
});
});
// ── Replica read failover ───────────────────────────────────────────
//
// `read()` normally goes to the replica batcher; when the replica side is
// degraded (batcher load-shed or a transient connection error) and a real
// replica is configured, the read retries once on the primary batcher.
type Batcher = { execute: ReturnType<typeof vi.fn> };
const makeClient = (opts: {
replica: Batcher;
primary: Batcher;
multiNode?: boolean;
}) => {
const client = new MySQLDatabaseClient({
database: { engine: 'mysql' },
} as IConfig);
// Bypass onServerStart (which would connect to a real database) and
// inject the batchers directly. Configuration enum: SINGLE=0, REPLICA=1.
Object.assign(client as unknown as Record<string, unknown>, {
dbReplica: opts.replica,
db: opts.primary,
configuration: opts.multiNode === false ? 0 : 1,
});
return client;
};
const codedError = (code: string) => {
const err = new Error(code) as Error & { code: string };
err.code = code;
return err;
};
describe('MySQLDatabaseClient.read — replica failover', () => {
it('fails over to the primary on batcher load-shed errors', async () => {
const replica = {
execute: vi.fn().mockRejectedValue(codedError('dbBatchFailed')),
};
const primary = { execute: vi.fn().mockResolvedValue([[{ ok: 1 }]]) };
const client = makeClient({ replica, primary });
await expect(client.read('SELECT 1')).resolves.toEqual([{ ok: 1 }]);
expect(primary.execute).toHaveBeenCalledTimes(1);
});
it('fails over on transient connection errors', async () => {
const replica = {
execute: vi.fn().mockRejectedValue(codedError('ECONNRESET')),
};
const primary = { execute: vi.fn().mockResolvedValue([[{ ok: 1 }]]) };
const client = makeClient({ replica, primary });
await expect(client.read('SELECT 1')).resolves.toEqual([{ ok: 1 }]);
});
it('rethrows deterministic SQL errors without touching the primary', async () => {
const replica = {
execute: vi.fn().mockRejectedValue(codedError('ER_PARSE_ERROR')),
};
const primary = { execute: vi.fn() };
const client = makeClient({ replica, primary });
await expect(client.read('SELEC oops')).rejects.toMatchObject({
code: 'ER_PARSE_ERROR',
});
expect(primary.execute).not.toHaveBeenCalled();
});
it('does not fail over in single-node configuration', async () => {
const replica = {
execute: vi.fn().mockRejectedValue(codedError('dbBatchFailed')),
};
const primary = { execute: vi.fn() };
const client = makeClient({ replica, primary, multiNode: false });
await expect(client.read('SELECT 1')).rejects.toMatchObject({
code: 'dbBatchFailed',
});
expect(primary.execute).not.toHaveBeenCalled();
});
});
// -- Driver-level behaviour (stubbed mysql2 pool) ---------------------
const mysqlConfig = (
database: Partial<NonNullable<IConfig['database']>> = {},
): IConfig =>
({
port: 0,
extensions: [],
database: { engine: 'mysql', ...database },
}) as IConfig;
const startClient = async (
database: Partial<NonNullable<IConfig['database']>> = {},
): Promise<MySQLDatabaseClient> => {
const client = new MySQLDatabaseClient(mysqlConfig(database));
await client.onServerStart();
return client;
};
// The batcher coalesces a batch into `stmt1;stmt2; SELECT 1`, so the driver
// must answer with one row-set per statement plus one for the SELECT 1.
const rowSets = (...sets: unknown[][]) => [[...sets, [{ 1: 1 }]], undefined];
beforeEach(() => {
createdPools.length = 0;
createPoolMock.mockClear();
});
describe('MySQLDatabaseClient — pool construction', () => {
it('builds the primary pool from config with multi-statement support', async () => {
await startClient({
host: 'db.internal',
port: 3307,
user: 'puter',
password: 'hunter2',
database: 'puterdb',
});
expect(createPoolMock).toHaveBeenCalledTimes(1);
expect(createdPools[0].poolConfig).toEqual({
maxPreparedStatements: 900,
connectionLimit: 30,
enableKeepAlive: true,
host: 'db.internal',
port: 3307,
user: 'puter',
password: 'hunter2',
database: 'puterdb',
multipleStatements: true,
});
});
it('falls back to loopback defaults when the endpoint is unspecified', async () => {
await startClient();
expect(createdPools[0].poolConfig).toMatchObject({
host: '127.0.0.1',
port: 3306,
user: 'root',
password: '',
database: 'puter',
});
});
it('arms a server-side statement timeout on every new connection', async () => {
await startClient({ selectTimeoutMs: 12_000 });
const pool = createdPools[0];
expect(pool.connectionHandlers).toHaveLength(1);
const conn = { query: vi.fn() };
pool.connectionHandlers[0](conn);
expect(conn.query).toHaveBeenCalledWith(
'SET SESSION max_execution_time = 12000',
);
});
it('omits the statement timeout when configured to 0', async () => {
await startClient({ selectTimeoutMs: 0 });
expect(createdPools[0].connectionHandlers).toHaveLength(0);
});
it('shares one pool between reads and writes without a replica', async () => {
await startClient();
expect(createPoolMock).toHaveBeenCalledTimes(1);
});
it('creates a second pool when a read-replica is configured', async () => {
await startClient({
replica: { host: 'replica.internal', port: 3306 },
});
expect(createPoolMock).toHaveBeenCalledTimes(2);
expect(createdPools[1].poolConfig).toMatchObject({
host: 'replica.internal',
multipleStatements: true,
});
});
});
describe('MySQLDatabaseClient — query interface', () => {
it('sends the query and its parameters through to the driver', async () => {
const client = await startClient();
const pool = createdPools[0];
pool.respond = () => rowSets([{ id: 1, username: 'ada' }]);
await expect(
client.read('SELECT * FROM `user` WHERE `id` = ?', [1]),
).resolves.toEqual([{ id: 1, username: 'ada' }]);
expect(pool.calls).toEqual([
{
sql: 'SELECT * FROM `user` WHERE `id` = ?; SELECT 1',
values: [1],
},
]);
});
it('returns an empty array when the driver yields no row-set', async () => {
const client = await startClient();
createdPools[0].respond = () => [[], undefined];
await expect(client.read('SELECT 1')).resolves.toEqual([]);
await expect(client.pread('SELECT 1')).resolves.toEqual([]);
});
it('reads from the replica pool and primary-reads from the primary', async () => {
const client = await startClient({ replica: { host: 'replica' } });
const [primary, replica] = createdPools;
primary.respond = () => rowSets([{ from: 'primary' }]);
replica.respond = () => rowSets([{ from: 'replica' }]);
await expect(client.read('SELECT 1')).resolves.toEqual([
{ from: 'replica' },
]);
await expect(client.pread('SELECT 1')).resolves.toEqual([
{ from: 'primary' },
]);
});
it('maps the mysql result header onto the shared write result', async () => {
const client = await startClient();
createdPools[0].respond = () =>
rowSets({ insertId: 42, affectedRows: 2 } as unknown as unknown[]);
await expect(
client.write('UPDATE `user` SET `username` = ? WHERE `id` = ?', [
'ada',
1,
]),
).resolves.toEqual({
insertId: 42,
affectedRows: 2,
anyRowsAffected: true,
});
});
it('reports no rows affected when the header omits the counters', async () => {
const client = await startClient();
createdPools[0].respond = () => rowSets({} as unknown as unknown[]);
await expect(client.write('DELETE FROM `user`')).resolves.toEqual({
insertId: 0,
affectedRows: 0,
anyRowsAffected: false,
});
});
it('builds an INSERT with quoted identifiers and positional params', async () => {
const client = await startClient();
const pool = createdPools[0];
pool.respond = () =>
rowSets({ insertId: 7, affectedRows: 1 } as unknown as unknown[]);
await expect(
client.insert('user', { username: 'ada', email: null }),
).resolves.toMatchObject({ insertId: 7 });
expect(pool.calls[0]).toEqual({
sql:
'INSERT INTO `user` (`username`, `email`) VALUES (?, ?); ' +
'SELECT 1',
values: ['ada', null],
});
});
});
describe('MySQLDatabaseClient — batchWrite transactions', () => {
it('runs every statement on one connection inside a transaction', async () => {
const client = await startClient();
const pool = createdPools[0];
await client.batchWrite([
{ statement: 'UPDATE `user` SET `x` = ?', values: [1] },
{
statement: 'DELETE FROM `sessions` WHERE `uuid` = ?',
values: ['s'],
},
]);
expect(pool.connections).toHaveLength(1);
const conn = pool.connections[0];
expect(conn.beginTransaction).toHaveBeenCalledTimes(1);
expect(conn.execute.mock.calls).toEqual([
['UPDATE `user` SET `x` = ?', [1]],
['DELETE FROM `sessions` WHERE `uuid` = ?', ['s']],
]);
expect(conn.commit).toHaveBeenCalledTimes(1);
expect(conn.rollback).not.toHaveBeenCalled();
expect(conn.release).toHaveBeenCalledTimes(1);
});
it('rolls back and releases the connection when a statement fails', async () => {
const client = await startClient();
const pool = createdPools[0];
pool.respond = (sql) => {
if (sql.startsWith('DELETE')) throw new Error('constraint blew up');
return [[], undefined];
};
await expect(
client.batchWrite([
{ statement: 'UPDATE `user` SET `x` = ?', values: [1] },
{ statement: 'DELETE FROM `user`', values: [] },
]),
).rejects.toThrow('constraint blew up');
const conn = pool.connections[0];
expect(conn.commit).not.toHaveBeenCalled();
expect(conn.rollback).toHaveBeenCalledTimes(1);
expect(conn.release).toHaveBeenCalledTimes(1);
});
it('still rethrows the original failure when the rollback also fails', async () => {
const client = await startClient();
const pool = createdPools[0];
pool.respond = () => {
throw new Error('statement blew up');
};
const original = client.batchWrite([
{ statement: 'UPDATE `user` SET `x` = ?', values: [1] },
]);
// The rollback is attached after the connection is handed out, so
// patch it once the adapter has acquired one.
await Promise.resolve();
pool.connections[0]?.rollback.mockRejectedValue(
new Error('rollback blew up'),
);
await expect(original).rejects.toThrow('statement blew up');
expect(pool.connections[0].release).toHaveBeenCalledTimes(1);
});
it('never acquires a connection for an empty batch', async () => {
const client = await startClient();
await client.batchWrite([]);
expect(createdPools[0].connections).toHaveLength(0);
});
});
describe('MySQLDatabaseClient — tryHardRead', () => {
it('issues exactly one query in single-node configuration', async () => {
const client = await startClient();
const pool = createdPools[0];
pool.respond = () => rowSets([{ id: 1 }]);
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
{ id: 1 },
]);
expect(pool.calls).toHaveLength(1);
});
it('prefers replica rows when the replica has them', async () => {
const client = await startClient({ replica: { host: 'replica' } });
const [primary, replica] = createdPools;
primary.respond = () => rowSets([{ from: 'primary' }]);
replica.respond = () => rowSets([{ from: 'replica' }]);
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
{ from: 'replica' },
]);
});
it('falls back to the primary when the replica is behind', async () => {
const client = await startClient({ replica: { host: 'replica' } });
const [primary, replica] = createdPools;
primary.respond = () => rowSets([{ from: 'primary' }]);
replica.respond = () => rowSets([]);
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
{ from: 'primary' },
]);
});
it('falls back to the primary when the replica query throws', async () => {
const client = await startClient({ replica: { host: 'replica' } });
const [primary, replica] = createdPools;
primary.respond = () => rowSets([{ from: 'primary' }]);
replica.respond = () => {
throw new Error('replica down');
};
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
{ from: 'primary' },
]);
});
});
describe('MySQLDatabaseClient — migrations', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'puter-mysql-mig-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('applies mysql migrations in numeric order, one statement at a time', async () => {
writeFileSync(
join(dir, 'mysql_mig_2.sql'),
'CREATE TABLE b (id INT);\nCREATE TABLE c (id INT);\n',
);
writeFileSync(
join(dir, 'mysql_mig_10.sql'),
'CREATE TABLE d (id INT);',
);
// Other engines' files and non-SQL noise must be ignored.
writeFileSync(join(dir, 'postgres_mig_1.sql'), 'CREATE TABLE nope ();');
writeFileSync(join(dir, 'mysql_notes.txt'), 'not sql');
await startClient({ migrationPaths: [dir] });
const pool = createdPools[0];
expect(pool.calls.map((c) => c.sql)).toEqual([
'CREATE TABLE b (id INT)',
'CREATE TABLE c (id INT)',
'CREATE TABLE d (id INT)',
]);
expect(pool.connections[0].release).toHaveBeenCalledTimes(1);
});
it('reports which file and statement index failed', async () => {
writeFileSync(
join(dir, 'mysql_mig_1.sql'),
'CREATE TABLE ok (id INT);\nCREATE TABLE bad (id INT);',
);
const client = new MySQLDatabaseClient(
mysqlConfig({ migrationPaths: [dir] }),
);
const cause = new Error('ER_PARSE_ERROR');
// The pool only exists once onServerStart runs, so arm the failure
// through the factory.
createPoolMock.mockImplementationOnce((poolConfig) => {
const pool = createPoolMock.getMockImplementation()!(poolConfig);
pool.respond = (sql: string) => {
if (sql.includes('bad')) throw cause;
return [[], undefined];
};
return pool;
});
await expect(client.onServerStart()).rejects.toThrow(
'[mysql] failed to apply mysql_mig_1.sql at statement 1',
);
});
it('fails loudly when a configured migration path is unreadable', async () => {
await expect(
startClient({ migrationPaths: [join(dir, 'does-not-exist')] }),
).rejects.toThrow('[mysql] migration path is unreadable');
});
it('skips a configured directory that holds no mysql migrations', async () => {
writeFileSync(join(dir, 'readme.md'), '# nothing here');
await startClient({ migrationPaths: [dir] });
expect(createdPools[0].calls).toEqual([]);
});
});
describe('MySQLDatabaseClient — pool lifecycle', () => {
it('replaces and closes the previous primary pool on reinit', async () => {
const client = await startClient();
const original = createdPools[0];
client.reinitPrimary();
expect(createdPools).toHaveLength(2);
expect(original.ended).toBe(true);
// Single-node: the replica batcher must follow the new primary.
createdPools[1].respond = () => rowSets([{ from: 'new-primary' }]);
await expect(client.read('SELECT 1')).resolves.toEqual([
{ from: 'new-primary' },
]);
});
it('replaces only the replica pool on replica reinit', async () => {
const client = await startClient({ replica: { host: 'replica' } });
const [primary, originalReplica] = createdPools;
client.reinitReplica();
expect(createdPools).toHaveLength(3);
expect(originalReplica.ended).toBe(true);
expect(primary.ended).toBe(false);
});
it('ignores a replica reinit when no replica is configured', async () => {
const client = await startClient();
client.reinitReplica();
expect(createdPools).toHaveLength(1);
});
it('ignores reinit once shutdown has started', async () => {
const client = await startClient();
await client.onServerPrepareShutdown();
client.reinitPrimary();
expect(createdPools).toHaveLength(1);
await client.onServerShutdown();
});
it('closes both pools on shutdown when a replica is configured', async () => {
const client = await startClient({ replica: { host: 'replica' } });
await client.onServerShutdown();
expect(createdPools[0].ended).toBe(true);
expect(createdPools[1].ended).toBe(true);
});
it('closes the pools once the drain window elapses', async () => {
vi.useFakeTimers();
try {
const client = await startClient();
await client.onServerPrepareShutdown();
expect(createdPools[0].ended).toBe(false);
await vi.advanceTimersByTimeAsync(60_000);
expect(createdPools[0].ended).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('cancels the drain timer when shutdown arrives first', async () => {
vi.useFakeTimers();
try {
const client = await startClient();
await client.onServerPrepareShutdown();
// A second prepare is a no-op — only one drain timer may exist.
await client.onServerPrepareShutdown();
expect(vi.getTimerCount()).toBe(1);
await client.onServerShutdown();
expect(vi.getTimerCount()).toBe(0);
expect(createdPools[0].ended).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('surfaces a driver error raised while closing the pool', async () => {
const client = await startClient();
createdPools[0].endError = new Error('pool refused to close');
await expect(client.onServerShutdown()).rejects.toThrow(
'pool refused to close',
);
});
it('surfaces a driver error thrown synchronously by end()', async () => {
const client = await startClient();
createdPools[0].endThrows = new Error('end exploded');
await expect(client.onServerShutdown()).rejects.toThrow('end exploded');
});
});
describe('MySQLDatabaseClient.readWithRetry', () => {
const codedFailure = (code: string) => {
const err = new Error(code) as Error & { code: string };
err.code = code;
return err;
};
it('classifies transient connection failures as retriable', () => {
expect(
MySQLDatabaseClient.isRetriableError(codedFailure('ECONNRESET')),
).toBe(true);
expect(
MySQLDatabaseClient.isRetriableError(codedFailure('ER_DUP_ENTRY')),
).toBe(false);
expect(
MySQLDatabaseClient.isRetriableError(new Error('Connection lost')),
).toBe(true);
});
it('retries a transient failure and returns the eventual result', async () => {
const client = await startClient();
let attempts = 0;
const operation = vi.fn(async () => {
attempts += 1;
if (attempts < 3) throw codedFailure('PROTOCOL_CONNECTION_LOST');
return [{ ok: 1 }];
});
await expect(
client.readWithRetry('health', operation, { baseBackoffMs: 0 }),
).resolves.toEqual([{ ok: 1 }]);
expect(operation).toHaveBeenCalledTimes(3);
});
it('gives up after the attempt budget is spent', async () => {
const client = await startClient();
const operation = vi.fn(async () => {
throw codedFailure('ETIMEDOUT');
});
await expect(
client.readWithRetry('health', operation, {
maxAttempts: 2,
baseBackoffMs: 0,
}),
).rejects.toMatchObject({ code: 'ETIMEDOUT' });
expect(operation).toHaveBeenCalledTimes(2);
});
it('never retries a deterministic SQL error', async () => {
const client = await startClient();
const operation = vi.fn(async () => {
throw codedFailure('ER_PARSE_ERROR');
});
await expect(
client.readWithRetry('health', operation, { baseBackoffMs: 0 }),
).rejects.toMatchObject({ code: 'ER_PARSE_ERROR' });
expect(operation).toHaveBeenCalledTimes(1);
});
it('stops retrying once shutdown has started', async () => {
const client = await startClient();
await client.onServerPrepareShutdown();
const operation = vi.fn(async () => {
throw codedFailure('ECONNRESET');
});
await expect(
client.readWithRetry('health', operation, { baseBackoffMs: 0 }),
).rejects.toMatchObject({ code: 'ECONNRESET' });
expect(operation).toHaveBeenCalledTimes(1);
await client.onServerShutdown();
});
it('applies jittered exponential backoff capped at maxBackoffMs', async () => {
const client = await startClient();
const delays: number[] = [];
const realSetTimeout = globalThis.setTimeout;
const timeoutSpy = vi
.spyOn(globalThis, 'setTimeout')
.mockImplementation(((fn: () => void, ms?: number) => {
delays.push(ms ?? 0);
return realSetTimeout(fn, 0);
}) as typeof setTimeout);
try {
const operation = vi.fn(async () => {
throw codedFailure('ECONNRESET');
});
await expect(
client.readWithRetry('health', operation, {
maxAttempts: 4,
baseBackoffMs: 100,
maxBackoffMs: 150,
jitterRatio: 0,
}),
).rejects.toMatchObject({ code: 'ECONNRESET' });
expect(delays).toEqual([100, 150, 150]);
} finally {
timeoutSpy.mockRestore();
}
});
});
@@ -1,4 +1,4 @@
/**
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
@@ -19,30 +19,26 @@
import { readdirSync, readFileSync } from 'fs';
import { isAbsolute, resolve as resolvePath } from 'path';
import { metrics } from '@opentelemetry/api';
import { createPool, type Pool } from 'mysql2';
import { Span } from '../../util/span.js';
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
import { SQLBatcher } from './SQLBatcher.js';
import { isRetriableError } from './retriableErrors.js';
import { splitMysqlStatements } from './splitMysqlStatements.js';
import { compareMigrationFilenames } from './migrationFilenames.js';
import type { IConfig } from '../../types';
const RETRIABLE_ERROR_CODES = new Set([
'PROTOCOL_CONNECTION_LOST',
'PROTOCOL_SEQUENCE_TIMEOUT',
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
'ECONNRESET',
'ETIMEDOUT',
'EPIPE',
'ECONNREFUSED',
'EHOSTUNREACH',
'ENETUNREACH',
'EAI_AGAIN',
]);
const DEFAULT_SELECT_TIMEOUT_MS = 30_000;
const RETRIABLE_ERROR_MESSAGES = [
'Connection lost',
'read ECONNRESET',
'ETIMEDOUT',
];
const replicaFailoverCounter = metrics
.getMeter('puter-backend')
.createCounter('db.read.replica_failover', {
description:
'Reads that failed on the replica batcher and were retried on the primary',
});
export { compareMigrationFilenames };
type PoolConfig = Parameters<typeof createPool>[0];
@@ -82,7 +78,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
});
console.log('[mysql] connected to primary');
this.db = new SQLBatcher(this.primaryPool, 30, 5);
this.db = this.createPrimaryBatcher(this.primaryPool);
if (dbConf.replica) {
this.replicaPool = this.createPool(dbConf.replica);
@@ -93,7 +89,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
this.configuration = Configuration.SINGLE;
}
this.dbReplica = new SQLBatcher(this.replicaPool, 10, 5);
this.dbReplica = this.createReplicaBatcher(this.replicaPool);
await this.runMigrations();
}
@@ -132,15 +128,36 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
// Query interface
// ------------------------------------------------------------------
// The db.* spans measure the logical query, including time queued in
// the SQLBatcher — the mysql2 auto-instrumentation only sees the
// coalesced multi-statement flush, so per-query latency lives here.
@Span('db.read', (query: string) => ({ 'db.statement': query }))
override async read(
query: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
const result = await this.dbReplica.execute(query, params);
let result;
try {
result = await this.dbReplica.execute(query, params);
} catch (error) {
// Replica-side degradation (batcher load-shed or a transient
// connection failure) shouldn't fail reads while the primary is
// healthy. Deterministic errors (bad SQL) are rethrown — they
// would fail identically on the primary.
if (
this.configuration !== Configuration.REPLICA ||
!MySQLDatabaseClient.isFailoverWorthy(error)
) {
throw error;
}
replicaFailoverCounter.add(1);
result = await this.db.execute(query, params);
}
if (!result) return [];
return (result[0] as Record<string, unknown>[]) ?? [];
}
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
override async pread(
query: string,
params: unknown[] = [],
@@ -150,6 +167,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
return (result[0] as Record<string, unknown>[]) ?? [];
}
@Span('db.write', (query: string) => ({ 'db.statement': query }))
override async write(
query: string,
params: unknown[] = [],
@@ -167,6 +185,9 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
};
}
@Span('db.batchWrite', (entries: unknown[]) => ({
'db.batch_size': entries.length,
}))
override async batchWrite(
entries: { statement: string; values: unknown[] }[],
): Promise<void> {
@@ -192,6 +213,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
}
}
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
override async tryHardRead(
query: string,
params: unknown[] = [],
@@ -226,9 +248,9 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
/**
* Apply `.sql` files from each configured migration directory in order.
* Files within a directory are sorted lexically. Files MUST be
* idempotent — there is no per-file applied-state tracking. Failures
* abort startup so operators see schema problems loud.
* Files within a directory are sorted lexically. Files MUST be idempotent —
* there is no per-file applied-state tracking. Failures abort startup so
* operators see schema problems loud.
*/
private async runMigrations(): Promise<void> {
const paths = this.config.database?.migrationPaths;
@@ -247,7 +269,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
.filter(
(f) => f.endsWith('.sql') && f.startsWith('mysql'),
)
.sort();
.sort(compareMigrationFilenames);
} catch (e) {
throw new Error(
`[mysql] migration path is unreadable: ${dir}`,
@@ -293,12 +315,53 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
// ------------------------------------------------------------------
private createPool(poolConfig: PoolConfig): Pool {
return createPool({
const pool = createPool({
maxPreparedStatements: 900,
connectionLimit: 30,
enableKeepAlive: true,
...poolConfig,
multipleStatements: true,
} as PoolConfig);
// Server-side kill switch for runaway reads: MySQL applies
// max_execution_time to SELECT statements only, so this is
// write-safe. Without it, a stalled database turns reads into
// indefinite hangs that no client-side timeout ever converts
// into a failure. 0 disables.
const selectTimeoutMs = Math.floor(
Number(
this.config.database?.selectTimeoutMs ??
DEFAULT_SELECT_TIMEOUT_MS,
),
);
if (selectTimeoutMs > 0) {
pool.on('connection', (conn) => {
conn.query(
`SET SESSION max_execution_time = ${selectTimeoutMs}`,
);
});
}
return pool;
}
private createPrimaryBatcher(pool: Pool): SQLBatcher {
return new SQLBatcher(pool, {
maxTimeInQueue: 30,
maxBatchSize: 5,
poolLabel: 'primary',
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
});
}
private createReplicaBatcher(pool: Pool): SQLBatcher {
return new SQLBatcher(pool, {
maxTimeInQueue: 10,
maxBatchSize: 5,
poolLabel: 'replica',
readOnly: true,
acquireTimeoutMs: this.config.database?.acquireTimeoutMs,
});
}
/** Reinitialize the primary pool (e.g. after a health-check failure). */
@@ -314,11 +377,11 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
password: dbConf.password ?? '',
database: dbConf.database ?? 'puter',
});
this.db = new SQLBatcher(this.primaryPool, 30, 5);
this.db = this.createPrimaryBatcher(this.primaryPool);
if (this.configuration === Configuration.SINGLE) {
this.replicaPool = this.primaryPool;
this.dbReplica = new SQLBatcher(this.primaryPool, 10, 5);
this.dbReplica = this.createReplicaBatcher(this.primaryPool);
}
if (previous && previous !== this.primaryPool) {
@@ -332,7 +395,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
const previous = this.replicaPool;
this.replicaPool = this.createPool(this.config.database.replica);
this.dbReplica = new SQLBatcher(this.replicaPool, 10, 5);
this.dbReplica = this.createReplicaBatcher(this.replicaPool);
if (
previous &&
@@ -348,11 +411,16 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
// ------------------------------------------------------------------
static isRetriableError(error: unknown): boolean {
const code = (error as { code?: string })?.code;
if (code && RETRIABLE_ERROR_CODES.has(code)) return true;
return isRetriableError(error);
}
const msg = String((error as Error)?.message ?? '');
return RETRIABLE_ERROR_MESSAGES.some((m) => msg.includes(m));
/**
* Replica failures worth retrying on the primary: batcher load-shed or
* transient connection errors — never deterministic SQL errors.
*/
private static isFailoverWorthy(error: unknown): boolean {
const code = (error as { code?: string })?.code;
return code === 'dbBatchFailed' || isRetriableError(error);
}
async readWithRetry(
@@ -0,0 +1,328 @@
/*
* 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 { Pool } from 'pg';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { IConfig } from '../../types';
import type { PuterServer } from '../../server';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import {
createPgMockPostgresDatabaseClient,
POSTGRES_TEST_MIGRATIONS_PATH,
setupTestServer,
} from '../../testUtil.js';
import { PostgresDatabaseClient } from './PostgresDatabaseClient.js';
const postgresUrl = process.env.PUTER_TEST_POSTGRES_URL;
const postgresMigrationsPath = POSTGRES_TEST_MIGRATIONS_PATH;
const postgresTestSchemaPattern = /^puter_test_[a-f0-9]{32}$/u;
const postgresIntegrationTimeoutMs = 180_000;
let postgresTestSchema: string | undefined;
let postgresTestUrl: string | undefined;
const postgresConfig = (overrides: Partial<IConfig> = {}): IConfig => {
if (postgresUrl && !postgresTestUrl) {
throw new Error('Postgres test schema was not initialized');
}
const { database: databaseOverrides, ...rootOverrides } = overrides;
const database = postgresUrl
? {
engine: 'postgres' as const,
inMemory: false,
connectionString: postgresTestUrl,
migrationPaths: [postgresMigrationsPath],
}
: {
engine: 'postgres' as const,
inMemory: true,
migrationPaths: [postgresMigrationsPath],
};
return {
port: 0,
extensions: [],
database: { ...database, ...(databaseOverrides ?? {}) },
...rootOverrides,
};
};
const quoteTestSchemaIdentifier = (schema: string): string => {
if (!postgresTestSchemaPattern.test(schema)) {
throw new Error(`Unsafe Postgres test schema name: ${schema}`);
}
return `"${schema}"`;
};
const postgresConnectionStringForSchema = (
connectionString: string,
schema: string,
): string => {
if (!postgresTestSchemaPattern.test(schema)) {
throw new Error(`Unsafe Postgres test schema name: ${schema}`);
}
const url = new URL(connectionString);
url.searchParams.set('options', `-c search_path=${schema}`);
return url.toString();
};
const createPostgresTestSchema = async (): Promise<void> => {
if (!postgresUrl) return;
const schema = `puter_test_${uuidv4().replaceAll('-', '')}`;
const pool = new Pool({ connectionString: postgresUrl });
try {
await pool.query(`CREATE SCHEMA ${quoteTestSchemaIdentifier(schema)}`);
postgresTestSchema = schema;
postgresTestUrl = postgresConnectionStringForSchema(
postgresUrl,
schema,
);
} finally {
await pool.end();
}
};
const dropPostgresTestSchema = async (): Promise<void> => {
if (!postgresUrl || !postgresTestSchema) return;
const schema = postgresTestSchema;
postgresTestSchema = undefined;
postgresTestUrl = undefined;
const pool = new Pool({ connectionString: postgresUrl });
try {
await pool.query(
`DROP SCHEMA IF EXISTS ${quoteTestSchemaIdentifier(schema)} CASCADE`,
);
} finally {
await pool.end();
}
};
describe('PostgresDatabaseClient integration', () => {
let server: PuterServer | undefined;
beforeEach(async () => {
await createPostgresTestSchema();
});
afterEach(async () => {
try {
await server?.shutdown();
} finally {
server = undefined;
await dropPostgresTestSchema();
}
});
it(
'applies the native migrations idempotently to an empty database',
async () => {
const config = postgresConfig();
const pgMockClient = postgresUrl
? undefined
: await createPgMockPostgresDatabaseClient(config);
try {
const firstClient =
pgMockClient?.client ?? new PostgresDatabaseClient(config);
try {
await firstClient.onServerStart();
} finally {
await firstClient.onServerShutdown();
}
const secondClient =
pgMockClient?.createClient() ??
new PostgresDatabaseClient(config);
try {
await secondClient.onServerStart();
const [systemUser] = await secondClient.read(
'SELECT `id`, `username` FROM `user` WHERE `username` = ?',
['system'],
);
const [devCenter] = await secondClient.read(
'SELECT `name`, `index_url` FROM `apps` WHERE `name` = ?',
['dev-center'],
);
expect(systemUser).toMatchObject({
id: 1,
username: 'system',
});
expect(devCenter).toMatchObject({
name: 'dev-center',
index_url:
'https://builtins.namespaces.puter.com/dev-center',
});
} finally {
await secondClient.onServerShutdown();
}
} finally {
pgMockClient?.destroy();
}
},
postgresIntegrationTimeoutMs,
);
it('starts the server and exercises user, app, fsentry, permission, and session flows', async () => {
const userStorageAllowance = 123_456_789;
server = await setupTestServer(
postgresConfig({
no_default_user: false,
is_storage_limited: true,
}),
);
const admin = await server.stores.user.getByUsername('admin');
expect(admin?.username).toBe('admin');
const username = `pg-${uuidv4().slice(0, 8)}`;
const createdUser = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: userStorageAllowance,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
createdUser,
);
const user = await server.stores.user.getById(createdUser.id);
if (!user) throw new Error('created user was not readable');
expect(user.username).toBe(username);
await expect(
server.stores.fsEntry.getUserStorageAllowance(user.id),
).resolves.toMatchObject({
max: userStorageAllowance,
});
const otherUsername = `pg-other-${uuidv4().slice(0, 8)}`;
const otherUser = await server.stores.user.create({
username: otherUsername,
uuid: uuidv4(),
password: null,
email: `${otherUsername}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
const authResult = await server.services.auth.createSessionToken(
user,
{
ip: '127.0.0.1',
user_agent: 'postgres-integration-test',
},
);
const authenticated =
await server.services.auth.authenticateFromToken(authResult.token);
expect(authenticated?.user?.id).toBe(user?.id);
const devCenter = await server.stores.app.getByName('dev-center');
if (!devCenter) throw new Error('dev-center app was not seeded');
expect(devCenter.name).toBe('dev-center');
const documents = await server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
);
if (!documents) throw new Error('Documents directory was not created');
expect(documents.isDir).toBe(true);
const folder = await server.stores.fsEntry.createNonFileEntry({
userId: user.id,
parent: documents,
name: 'postgres-folder',
kind: 'directory',
});
const renamedFolder = await server.stores.fsEntry.updateEntry(
folder.uuid,
{
name: 'postgres-folder-renamed',
path: `/${username}/Documents/postgres-folder-renamed`,
},
);
expect(renamedFolder.name).toBe('postgres-folder-renamed');
await server.stores.permission.upsertUserAppPerm(
user.id,
Number(devCenter.id),
'driver:postgres-integration',
{ ok: true },
);
await expect(
server.stores.permission.hasUserAppPerm(
user.id,
Number(devCenter.id),
'driver:postgres-integration',
),
).resolves.toBe(true);
await server.stores.oidc.link(
user.id,
'postgres-integration-test',
'subject-1',
null,
);
await expect(
server.stores.oidc.link(
user.id,
'postgres-integration-test',
'subject-1',
null,
),
).resolves.toBeUndefined();
await expect(
server.stores.oidc.link(
otherUser.id,
'postgres-integration-test',
'subject-1',
null,
),
).rejects.toMatchObject({
statusCode: 409,
legacyCode: 'conflict',
});
const session = await server.stores.session.create(user.id, {
meta: { source: 'postgres-integration-test' },
});
const activeSession = await server.stores.session.getByUuid(
session.uuid,
);
expect(activeSession?.uuid).toBe(session.uuid);
const workerName = `worker-${uuidv4().slice(0, 8)}`;
const workerSession = await server.stores.session.getOrCreateWorker(
user.id,
{ workerName },
);
expect(workerSession?.kind).toBe('worker');
expect(workerSession?.meta?.worker_name).toBe(workerName);
await server.stores.session.removeByUuid(session.uuid);
await expect(
server.stores.session.getByUuid(session.uuid),
).resolves.toBeNull();
}, postgresIntegrationTimeoutMs);
});
@@ -0,0 +1,360 @@
/*
* 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 { FieldDef, QueryResult } from 'pg';
import { describe, expect, it } from 'vitest';
import type { IConfig } from '../../types';
import { DatabaseClientFactory } from './index.js';
import {
mapPostgresWriteResult,
PostgresDatabaseClient,
type PostgresPool,
type PostgresPoolClient,
} from './PostgresDatabaseClient.js';
type QueryCall = {
text: string;
values?: unknown[];
};
const postgresConfig = (): IConfig => ({
port: 0,
extensions: [],
database: {
engine: 'postgres',
migrationPaths: [],
},
});
const postgresReplicaConfig = (): IConfig => ({
port: 0,
extensions: [],
database: {
engine: 'postgres',
migrationPaths: [],
replica: {},
},
});
const field = (name: string, dataTypeID: number): FieldDef => ({
name,
tableID: 0,
columnID: 0,
dataTypeID,
dataTypeSize: -1,
dataTypeModifier: -1,
format: 'text',
});
const int8Field = (name: string): FieldDef => field(name, 20);
const textField = (name: string): FieldDef => field(name, 25);
const queryResult = (
rows: Record<string, unknown>[] = [],
rowCount = rows.length,
fields: FieldDef[] = [],
): QueryResult<Record<string, unknown>> => ({
command: '',
fields,
oid: 0,
rowCount,
rows,
});
class RecordingPoolClient implements PostgresPoolClient {
readonly calls: QueryCall[] = [];
released = false;
constructor(private readonly failOnText?: string) {}
async query(
text: string,
values?: unknown[],
): Promise<QueryResult<Record<string, unknown>>> {
this.calls.push({ text, values });
if (this.failOnText && text.includes(this.failOnText)) {
throw new Error(`forced query failure: ${text}`);
}
return queryResult([], text === 'ROLLBACK' ? 0 : 1);
}
release(): void {
this.released = true;
}
}
class RecordingPool implements PostgresPool {
readonly calls: QueryCall[] = [];
constructor(
private readonly client: RecordingPoolClient =
new RecordingPoolClient(),
private readonly nextResult: QueryResult<Record<string, unknown>> =
queryResult(),
) {}
async query(
text: string,
values?: unknown[],
): Promise<QueryResult<Record<string, unknown>>> {
this.calls.push({ text, values });
if (text === 'SELECT 1') return queryResult([{ ok: 1 }], 1);
return this.nextResult;
}
async connect(): Promise<PostgresPoolClient> {
return this.client;
}
async end(): Promise<void> {}
}
describe('PostgresDatabaseClient', () => {
it('is selected by the database factory', () => {
const client = new DatabaseClientFactory(postgresConfig());
expect(client).toBeInstanceOf(PostgresDatabaseClient);
});
it('maps pg write results to the shared WriteResult shape', () => {
expect(
mapPostgresWriteResult(queryResult([{ id: '42' }], 1)),
).toEqual({
insertId: 42,
affectedRows: 1,
anyRowsAffected: true,
});
expect(mapPostgresWriteResult(queryResult([], 0))).toEqual({
insertId: 0,
affectedRows: 0,
anyRowsAffected: false,
});
});
it('prepares write SQL at the pg boundary', async () => {
const pool = new RecordingPool(
new RecordingPoolClient(),
queryResult([{ id: '7' }], 1),
);
const client = new PostgresDatabaseClient(postgresConfig(), () => pool);
await client.onServerStart();
const result = await client.write(
'INSERT INTO `apps` (`name`) VALUES (?) RETURNING id',
['editor'],
);
expect(result.insertId).toBe(7);
expect(pool.calls.at(-1)).toEqual({
text: 'INSERT INTO "apps" ("name") VALUES ($1) RETURNING id',
values: ['editor'],
});
});
it('normalizes int8 fields on read and primary read rows', async () => {
const pool = new RecordingPool(
new RecordingPoolClient(),
queryResult(
[
{
uuid: 'session-1',
created_at: '1710000000',
last_activity: '1710000001',
expires_at: '1710000002',
revoked_at: null,
},
],
1,
[
textField('uuid'),
int8Field('created_at'),
int8Field('last_activity'),
int8Field('expires_at'),
int8Field('revoked_at'),
],
),
);
const client = new PostgresDatabaseClient(postgresConfig(), () => pool);
await client.onServerStart();
await expect(client.read('SELECT * FROM `sessions`')).resolves.toEqual(
[
{
uuid: 'session-1',
created_at: 1710000000,
last_activity: 1710000001,
expires_at: 1710000002,
revoked_at: null,
},
],
);
await expect(client.pread('SELECT * FROM `sessions`')).resolves.toEqual(
[
{
uuid: 'session-1',
created_at: 1710000000,
last_activity: 1710000001,
expires_at: 1710000002,
revoked_at: null,
},
],
);
});
it('rejects unsafe int8 values instead of losing precision', async () => {
const pool = new RecordingPool(
new RecordingPoolClient(),
queryResult(
[{ id: '9007199254740992' }],
1,
[int8Field('id')],
),
);
const client = new PostgresDatabaseClient(postgresConfig(), () => pool);
await client.onServerStart();
await expect(client.read('SELECT `id` FROM `sessions`')).rejects.toThrow(
'safe integer',
);
});
it('normalizes tryHardRead rows returned by a replica', async () => {
const primaryPool = new RecordingPool(
new RecordingPoolClient(),
queryResult([{ created_at: '1' }], 1, [
int8Field('created_at'),
]),
);
const replicaPool = new RecordingPool(
new RecordingPoolClient(),
queryResult([{ created_at: '2' }], 1, [
int8Field('created_at'),
]),
);
const pools = [primaryPool, replicaPool];
let nextPoolIndex = 0;
const client = new PostgresDatabaseClient(
postgresReplicaConfig(),
() => {
const pool = pools[nextPoolIndex];
nextPoolIndex += 1;
if (!pool) throw new Error('unexpected pool factory call');
return pool;
},
);
await client.onServerStart();
await expect(
client.tryHardRead('SELECT `created_at` FROM `sessions`'),
).resolves.toEqual([{ created_at: 2 }]);
});
it('normalizes tryHardRead rows returned by primary fallback', async () => {
const primaryPool = new RecordingPool(
new RecordingPoolClient(),
queryResult([{ created_at: '3' }], 1, [
int8Field('created_at'),
]),
);
const replicaPool = new RecordingPool(
new RecordingPoolClient(),
queryResult([], 0, [int8Field('created_at')]),
);
const pools = [primaryPool, replicaPool];
let nextPoolIndex = 0;
const client = new PostgresDatabaseClient(
postgresReplicaConfig(),
() => {
const pool = pools[nextPoolIndex];
nextPoolIndex += 1;
if (!pool) throw new Error('unexpected pool factory call');
return pool;
},
);
await client.onServerStart();
await expect(
client.tryHardRead('SELECT `created_at` FROM `sessions`'),
).resolves.toEqual([{ created_at: 3 }]);
});
it('runs batch writes in order and commits', async () => {
const conn = new RecordingPoolClient();
const client = new PostgresDatabaseClient(
postgresConfig(),
() => new RecordingPool(conn),
);
await client.onServerStart();
await client.batchWrite([
{
statement:
'UPDATE `user` SET `username` = ? WHERE `id` = ?',
values: ['ada', 1],
},
{
statement: 'DELETE FROM `sessions` WHERE `uuid` = ?',
values: ['session-1'],
},
]);
expect(conn.calls).toEqual([
{ text: 'BEGIN', values: undefined },
{
text: 'UPDATE "user" SET "username" = $1 WHERE "id" = $2',
values: ['ada', 1],
},
{
text: 'DELETE FROM "sessions" WHERE "uuid" = $1',
values: ['session-1'],
},
{ text: 'COMMIT', values: undefined },
]);
expect(conn.released).toBe(true);
});
it('rolls back and releases the pg connection when a batch write fails', async () => {
const conn = new RecordingPoolClient('UPDATE "user"');
const client = new PostgresDatabaseClient(
postgresConfig(),
() => new RecordingPool(conn),
);
await client.onServerStart();
await expect(
client.batchWrite([
{
statement:
'UPDATE `user` SET `username` = ? WHERE `id` = ?',
values: ['ada', 1],
},
]),
).rejects.toThrow('forced query failure');
expect(conn.calls).toEqual([
{ text: 'BEGIN', values: undefined },
{
text: 'UPDATE "user" SET "username" = $1 WHERE "id" = $2',
values: ['ada', 1],
},
{ text: 'ROLLBACK', values: undefined },
]);
expect(conn.released).toBe(true);
});
});
@@ -0,0 +1,398 @@
/*
* 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 { readdirSync, readFileSync } from 'fs';
import { isAbsolute, resolve as resolvePath } from 'path';
import { Pool, type PoolConfig, type QueryResult } from 'pg';
import { Span } from '../../util/span.js';
import {
AbstractDatabaseClient,
type BatchEntry,
type WriteResult,
} from './DatabaseClient';
import { compareMigrationFilenames } from './migrationFilenames.js';
import { preparePostgresSql } from './preparePostgresSql.js';
import { splitPostgresStatements } from './splitPostgresStatements.js';
import type { IConfig } from '../../types';
type PostgresEndpointConfig = {
host?: string;
port?: number;
user?: string;
password?: string;
database?: string;
connectionString?: string;
url?: string;
};
export interface PostgresQueryable {
query(query: string, values?: unknown[]): Promise<QueryResult>;
}
export interface PostgresPoolClient extends PostgresQueryable {
release(): void;
}
export interface PostgresPool extends PostgresQueryable {
connect(): Promise<PostgresPoolClient>;
end(): Promise<void>;
}
type PostgresPoolFactory = (poolConfig: PoolConfig) => PostgresPool;
enum Configuration {
SINGLE,
REPLICA,
}
const POSTGRES_INT8_OID = 20;
const INTEGER_TEXT_PATTERN = /^-?\d+$/u;
const normalizePostgresInt8 = (
value: unknown,
columnName: string,
): number | null | undefined => {
if (value === null || value === undefined) return value;
const parsed =
typeof value === 'bigint'
? Number(value)
: typeof value === 'number'
? value
: typeof value === 'string' && INTEGER_TEXT_PATTERN.test(value)
? Number(value)
: Number.NaN;
if (!Number.isSafeInteger(parsed)) {
throw new Error(
`[postgres] int8 column ${columnName} is outside JavaScript's safe integer range`,
);
}
return parsed;
};
const normalizePostgresRows = (
result: QueryResult,
): Record<string, unknown>[] => {
const int8Fields = result.fields.filter(
(field) => field.dataTypeID === POSTGRES_INT8_OID,
);
if (int8Fields.length === 0) {
return result.rows as Record<string, unknown>[];
}
return result.rows.map((row) => {
const normalized: Record<string, unknown> = { ...row };
for (const field of int8Fields) {
normalized[field.name] = normalizePostgresInt8(
normalized[field.name],
field.name,
);
}
return normalized;
});
};
export const mapPostgresWriteResult = (result: QueryResult): WriteResult => {
const affectedRows = result.rowCount ?? 0;
const rowId = result.rows[0]?.id;
const insertId =
typeof rowId === 'bigint'
? rowId
: typeof rowId === 'number'
? rowId
: typeof rowId === 'string' && rowId !== ''
? Number(rowId)
: 0;
const normalizedInsertId =
typeof insertId === 'number' && Number.isNaN(insertId) ? 0 : insertId;
return {
insertId: normalizedInsertId,
affectedRows,
anyRowsAffected: affectedRows > 0,
};
};
export class PostgresDatabaseClient extends AbstractDatabaseClient {
override readonly engineName = 'postgres';
private primaryPool!: PostgresPool;
private replicaPool!: PostgresPool;
private configuration = Configuration.SINGLE;
private shutdownStarted = false;
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
config: IConfig,
private readonly poolFactory: PostgresPoolFactory = (poolConfig) =>
new Pool(poolConfig) as unknown as PostgresPool,
) {
super(config);
}
override async onServerStart(): Promise<void> {
const dbConf = this.config.database!;
this.primaryPool = this.createPool(dbConf);
await this.primaryPool.query('SELECT 1');
console.log('[postgres] connected to primary');
if (dbConf.replica) {
this.replicaPool = this.createPool(dbConf.replica);
await this.replicaPool.query('SELECT 1');
this.configuration = Configuration.REPLICA;
console.log('[postgres] connected to read-replica');
} else {
this.replicaPool = this.primaryPool;
this.configuration = Configuration.SINGLE;
}
await this.runMigrations();
}
override async onServerPrepareShutdown(): Promise<void> {
if (this.shutdownStarted) return;
this.shutdownStarted = true;
const drainMs = 60_000;
console.log(
`[postgres] draining in-flight queries (${drainMs}ms) before closing pools`,
);
this.shutdownTimer = setTimeout(() => {
this.shutdownTimer = null;
this.closeCurrentPools().catch((e) =>
console.error('[postgres] error closing pools after drain', e),
);
}, drainMs);
if (typeof this.shutdownTimer.unref === 'function') {
this.shutdownTimer.unref();
}
}
override async onServerShutdown(): Promise<void> {
if (this.shutdownTimer) {
clearTimeout(this.shutdownTimer);
this.shutdownTimer = null;
}
await this.closeCurrentPools();
}
override quoteIdentifier(identifier: string): string {
return identifier
.split('.')
.map((part) => {
if (part === '*') return part;
return `"${part.replaceAll('"', '""')}"`;
})
.join('.');
}
override booleanLiteral(value: boolean): string {
return value ? 'TRUE' : 'FALSE';
}
override booleanValue(value: boolean): boolean {
return value;
}
@Span('db.read', (query: string) => ({ 'db.statement': query }))
override async read(
query: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
const result = await this.query(this.replicaPool, query, params);
return normalizePostgresRows(result);
}
@Span('db.pread', (query: string) => ({ 'db.statement': query }))
override async pread(
query: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
const result = await this.query(this.primaryPool, query, params);
return normalizePostgresRows(result);
}
@Span('db.write', (query: string) => ({ 'db.statement': query }))
override async write(
query: string,
params: unknown[] = [],
): Promise<WriteResult> {
const result = await this.query(this.primaryPool, query, params);
return mapPostgresWriteResult(result);
}
@Span('db.batchWrite', (entries: unknown[]) => ({
'db.batch_size': entries.length,
}))
override async batchWrite(entries: BatchEntry[]): Promise<void> {
if (entries.length === 0) return;
const conn = await this.primaryPool.connect();
try {
await conn.query('BEGIN');
try {
for (const { statement, values } of entries) {
await this.query(conn, statement, values);
}
await conn.query('COMMIT');
} catch (err) {
await conn.query('ROLLBACK').catch(() => {});
throw err;
}
} finally {
conn.release();
}
}
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
override async tryHardRead(
query: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
if (this.configuration === Configuration.SINGLE) {
return this.read(query, params);
}
const primaryPromise = this.query(this.primaryPool, query, params);
try {
const replicaResult = await this.query(
this.replicaPool,
query,
params,
);
if (replicaResult.rows.length > 0) {
primaryPromise.catch(() => {});
return normalizePostgresRows(replicaResult);
}
} catch {
// fall through to primary
}
const primaryResult = await primaryPromise;
return normalizePostgresRows(primaryResult);
}
private async runMigrations(): Promise<void> {
const paths = this.config.database?.migrationPaths;
if (!paths || paths.length === 0) return;
const conn = await this.primaryPool.connect();
try {
for (const rawPath of paths) {
const dir = isAbsolute(rawPath)
? rawPath
: resolvePath(process.cwd(), rawPath);
let files: string[];
try {
files = readdirSync(dir)
.filter(
(f) =>
f.endsWith('.sql') && f.startsWith('postgres'),
)
.sort(compareMigrationFilenames);
} catch (e) {
throw new Error(
`[postgres] migration path is unreadable: ${dir}`,
{ cause: e },
);
}
if (files.length === 0) {
console.log(`[postgres] no migrations in ${dir}`);
continue;
}
console.log(
`[postgres] running migrations from ${dir}: ${files.length} file(s)`,
);
for (const file of files) {
const filePath = resolvePath(dir, file);
const contents = readFileSync(filePath, 'utf8');
const statements = splitPostgresStatements(contents);
await conn.query('BEGIN');
try {
for (let i = 0; i < statements.length; i++) {
try {
await conn.query(statements[i]);
} catch (e) {
throw new Error(
`[postgres] failed to apply ${file} at statement ${i}`,
{ cause: e },
);
}
}
await conn.query('COMMIT');
} catch (e) {
await conn.query('ROLLBACK').catch(() => {});
throw e;
}
console.log(
`[postgres] applied ${file} (${statements.length} statements)`,
);
}
}
} finally {
conn.release();
}
}
private createPool(dbConf: PostgresEndpointConfig): PostgresPool {
const connectionString = dbConf.connectionString ?? dbConf.url;
if (connectionString) {
return this.poolFactory({
connectionString,
max: 30,
});
}
return this.poolFactory({
host: dbConf.host ?? '127.0.0.1',
port: dbConf.port ?? 5432,
user: dbConf.user ?? 'postgres',
password: dbConf.password ?? '',
database: dbConf.database ?? 'puter',
max: 30,
});
}
private async query(
target: PostgresQueryable,
query: string,
params: unknown[] = [],
): Promise<QueryResult> {
const prepared = preparePostgresSql(query);
return target.query(prepared.text, params);
}
private async closeCurrentPools(): Promise<void> {
const tasks: Promise<void>[] = [];
if (this.primaryPool) tasks.push(this.primaryPool.end());
if (this.replicaPool && this.replicaPool !== this.primaryPool) {
tasks.push(this.replicaPool.end());
}
await Promise.all(tasks);
}
}
+161 -28
View File
@@ -1,4 +1,4 @@
/**
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
@@ -18,10 +18,19 @@
*/
import { metrics } from '@opentelemetry/api';
import {
POOL_ACQUIRE_TIMEOUT,
isNeverSentError,
isRetriableError,
} from './retriableErrors.js';
const DEFAULT_MAX_QUEUE_SIZE = 1000;
const DEFAULT_FAILURE_THRESHOLD = 5;
const DEFAULT_COOLDOWN_MS = 5_000;
const DEFAULT_ACQUIRE_TIMEOUT_MS = 5_000;
const ACQUIRE_ATTEMPTS = 3;
const ITEM_RETRY_ATTEMPTS = 2;
const RETRY_BASE_BACKOFF_MS = 100;
const FALLBACK_RETRY_CONCURRENCY = 8;
const meter = metrics.getMeter('puter-backend');
@@ -53,6 +62,15 @@ const fallbackItemFailuresCounter = meter.createCounter(
'Per-item failures observed during SQLBatcher per-item retry',
},
);
const fallbackItemRetriesCounter = meter.createCounter(
'sql_batcher.fallback.item_retries',
{
description:
'Transient per-item failures retried during SQLBatcher fallback',
},
);
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export class SQLBatcher {
dbPool;
@@ -61,18 +79,45 @@ export class SQLBatcher {
maxQueueSize;
failureThreshold;
cooldownMs;
poolLabel;
readOnly;
acquireTimeoutMs;
queue = [];
timeouts = [];
#consecutiveFailures = 0;
#lastFailureAt = 0;
#metricAttrs;
/**
* @param {object} dbPool Mysql2 pool
* @param {object} [opts]
* @param {number} [opts.maxTimeInQueue] Ms an item may wait before flush
* @param {number} [opts.maxBatchSize] Items coalesced per flush
* @param {number} [opts.maxQueueSize] Drop-oldest high-water mark
* @param {number} [opts.failureThreshold] Consecutive failures to open the
* breaker
* @param {number} [opts.cooldownMs] Breaker open duration after last
* failure
* @param {'primary' | 'replica'} [opts.poolLabel] Role label on metrics; in
* single-node setups the 'replica' batcher shares the primary pool, so
* this reflects the read/write role rather than a physical instance
* @param {boolean} [opts.readOnly] This batcher only ever carries SELECTs,
* so any transient failure is safe to retry
* @param {number} [opts.acquireTimeoutMs] Max wait for a pooled connection;
* 0 disables the bound
*/
constructor(
dbPool,
maxTimeInQueue = 20,
maxBatchSize = 50,
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
failureThreshold = DEFAULT_FAILURE_THRESHOLD,
cooldownMs = DEFAULT_COOLDOWN_MS,
{
maxTimeInQueue = 20,
maxBatchSize = 50,
maxQueueSize = DEFAULT_MAX_QUEUE_SIZE,
failureThreshold = DEFAULT_FAILURE_THRESHOLD,
cooldownMs = DEFAULT_COOLDOWN_MS,
poolLabel = 'primary',
readOnly = false,
acquireTimeoutMs = DEFAULT_ACQUIRE_TIMEOUT_MS,
} = {},
) {
this.dbPool = dbPool;
this.maxTimeInQueue = maxTimeInQueue;
@@ -80,6 +125,10 @@ export class SQLBatcher {
this.maxQueueSize = maxQueueSize;
this.failureThreshold = failureThreshold;
this.cooldownMs = cooldownMs;
this.poolLabel = poolLabel;
this.readOnly = readOnly;
this.acquireTimeoutMs = acquireTimeoutMs;
this.#metricAttrs = { pool: poolLabel };
}
async execute(sql, values) {
@@ -90,9 +139,13 @@ export class SQLBatcher {
return this;
}
#createPublicBatchError() {
// The public error is deliberately opaque (no SQL, no internals), but
// `reason` distinguishes the load-shed path for logs and callers:
// breakerOpen | queueOverflow | connAcquire.
#createPublicBatchError(reason) {
const error = new Error('Database operation failed');
error.code = 'dbBatchFailed';
error.reason = reason;
return error;
}
@@ -106,8 +159,8 @@ export class SQLBatcher {
async query(sql, values) {
if (this.#isBreakerOpen()) {
enqueueRejectedCounter.add(1);
throw this.#createPublicBatchError();
enqueueRejectedCounter.add(1, this.#metricAttrs);
throw this.#createPublicBatchError('breakerOpen');
}
const { promise, resolve, reject } = Promise.withResolvers();
@@ -117,8 +170,8 @@ export class SQLBatcher {
// to have already exceeded any caller-side timeout anyway.
while (this.queue.length >= this.maxQueueSize) {
const dropped = this.queue.shift();
dropped.reject(this.#createPublicBatchError());
enqueueDroppedCounter.add(1);
dropped.reject(this.#createPublicBatchError('queueOverflow'));
enqueueDroppedCounter.add(1, this.#metricAttrs);
}
this.queue.push({
@@ -142,6 +195,61 @@ export class SQLBatcher {
return promise;
}
// Bounded wait for a pooled connection. Without a bound, a stalled
// database turns every flush into an indefinite hang — nothing fails,
// so neither the breaker nor callers' own timeouts ever engage.
#getConnectionWithTimeout() {
const acquire = this.dbPool.promise().getConnection();
if (!this.acquireTimeoutMs) return acquire;
return new Promise((resolve, reject) => {
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
// A connection that arrives late must go back to the pool.
acquire.then(
(conn) => conn.release(),
() => {},
);
const error = new Error(
'Timed out acquiring database connection',
);
error.code = POOL_ACQUIRE_TIMEOUT;
reject(error);
}, this.acquireTimeoutMs);
acquire.then(
(conn) => {
if (timedOut) return;
clearTimeout(timer);
resolve(conn);
},
(err) => {
if (timedOut) return;
clearTimeout(timer);
reject(err);
},
);
});
}
// Acquisition failures never sent a statement, so retrying is always
// safe regardless of what the batch contains.
async #acquireConnection() {
let lastError;
for (let attempt = 1; attempt <= ACQUIRE_ATTEMPTS; attempt++) {
try {
return await this.#getConnectionWithTimeout();
} catch (error) {
lastError = error;
if (attempt < ACQUIRE_ATTEMPTS) {
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
}
}
}
throw lastError;
}
async flush(batch) {
const timeout = this.timeouts.shift();
if (timeout && !timeout._destroyed) {
@@ -154,17 +262,17 @@ export class SQLBatcher {
let connection;
try {
connection = await this.dbPool.promise().getConnection();
connection = await this.#acquireConnection();
} catch (error) {
this.#consecutiveFailures++;
this.#lastFailureAt = Date.now();
flushFailureCounter.add(1);
flushFailureCounter.add(1, this.#metricAttrs);
console.warn(
'SQLBatcher could not acquire connection for flush:',
error,
);
for (const b of batch) {
b.reject(this.#createPublicBatchError());
b.reject(this.#createPublicBatchError('connAcquire'));
}
return;
}
@@ -206,8 +314,8 @@ export class SQLBatcher {
// committed; re-running each item independently produces clean
// success/failure outcomes for each caller. Concurrency is capped to
// avoid briefly saturating the pool when a large batch fails.
flushFailureCounter.add(1);
fallbackInvocationsCounter.add(1);
flushFailureCounter.add(1, this.#metricAttrs);
fallbackInvocationsCounter.add(1, this.#metricAttrs);
const settled = new Array(batch.length);
let cursor = 0;
@@ -216,17 +324,7 @@ export class SQLBatcher {
async () => {
while (cursor < batch.length) {
const i = cursor++;
const b = batch[i];
try {
settled[i] = {
ok: true,
value: await this.dbPool
.promise()
.query(b.sql, b.values ?? []),
};
} catch (error) {
settled[i] = { ok: false, error };
}
settled[i] = await this.#runFallbackItem(batch[i]);
}
},
);
@@ -246,7 +344,7 @@ export class SQLBatcher {
}
}
if (failureCount > 0) {
fallbackItemFailuresCounter.add(failureCount);
fallbackItemFailuresCounter.add(failureCount, this.#metricAttrs);
}
// Only escalate the breaker when the database itself looks unhealthy
@@ -259,4 +357,39 @@ export class SQLBatcher {
this.#consecutiveFailures++;
}
}
// Run one fallback item, retrying transient failures with backoff.
// A read-only batcher may retry anything transient; a batcher that
// carries writes only retries failures where the statement provably
// never reached the server — a write that died mid-flight may have
// committed, and re-running it would double-apply.
async #runFallbackItem(b) {
let attempt = 0;
while (true) {
let connection;
try {
connection = await this.#acquireConnection();
} catch (error) {
return { ok: false, error };
}
try {
return {
ok: true,
value: await connection.query(b.sql, b.values ?? []),
};
} catch (error) {
const canRetry = this.readOnly
? isRetriableError(error)
: isNeverSentError(error);
if (!canRetry || attempt >= ITEM_RETRY_ATTEMPTS) {
return { ok: false, error };
}
attempt++;
fallbackItemRetriesCounter.add(1, this.#metricAttrs);
await sleep(RETRY_BASE_BACKOFF_MS * attempt);
} finally {
connection.release();
}
}
}
}
@@ -0,0 +1,239 @@
/*
* 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 { describe, expect, it, vi } from 'vitest';
import { SQLBatcher } from './SQLBatcher.js';
const makeError = (code: string, message = code): Error & { code: string } => {
const error = new Error(message) as Error & { code: string };
error.code = code;
return error;
};
interface FakeConnection {
beginTransaction: ReturnType<typeof vi.fn>;
query: ReturnType<typeof vi.fn>;
commit: ReturnType<typeof vi.fn>;
rollback: ReturnType<typeof vi.fn>;
release: ReturnType<typeof vi.fn>;
}
// The batch flush sends one coalesced multi-statement (always suffixed
// `; SELECT 1`); fallback items send their original single statement.
const isBatchQuery = (sql: string) => sql.endsWith('; SELECT 1');
const makeConnection = (
onQuery: (sql: string, values: unknown[]) => unknown,
): FakeConnection => ({
beginTransaction: vi.fn(async () => {}),
query: vi.fn(async (sql: string, values: unknown[]) => onQuery(sql, values)),
commit: vi.fn(async () => {}),
rollback: vi.fn(async () => {}),
release: vi.fn(),
});
const makePool = (connection: FakeConnection | (() => Promise<FakeConnection>)) => {
const getConnection = vi.fn(async () =>
typeof connection === 'function' ? connection() : connection,
);
return {
pool: { promise: () => ({ getConnection }) },
getConnection,
};
};
// Happy-path onQuery: batch returns one result row-set per statement plus
// the trailing SELECT 1 row-set.
const happyBatch = (sql: string) => {
if (!isBatchQuery(sql)) throw new Error('unexpected fallback query');
const statements = sql.split(';').length - 1;
return [
Array.from({ length: statements + 1 }, (_, i) => [{ n: i }]),
undefined,
];
};
describe('SQLBatcher', () => {
it('resolves each batched item with its own result', async () => {
const conn = makeConnection(happyBatch);
const { pool } = makePool(conn);
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
const [a, b] = await Promise.all([
batcher.query('SELECT a', []),
batcher.query('SELECT b', []),
]);
expect(a[0]).toEqual([{ n: 0 }]);
expect(b[0]).toEqual([{ n: 1 }]);
expect(conn.beginTransaction).toHaveBeenCalledTimes(1);
expect(conn.commit).toHaveBeenCalledTimes(1);
expect(conn.release).toHaveBeenCalledTimes(1);
});
it('drops the oldest item with reason queueOverflow at the high-water mark', async () => {
const conn = makeConnection(happyBatch);
const { pool } = makePool(conn);
const batcher = new SQLBatcher(pool, {
maxTimeInQueue: 5,
maxQueueSize: 1,
});
const first = batcher.query('SELECT a', []);
const second = batcher.query('SELECT b', []);
await expect(first).rejects.toMatchObject({
code: 'dbBatchFailed',
reason: 'queueOverflow',
});
await expect(second).resolves.toBeTruthy();
});
it('rejects with reason connAcquire after exhausting acquisition retries', async () => {
const getConnection = vi.fn(async () => {
throw makeError('ECONNREFUSED');
});
const pool = { promise: () => ({ getConnection }) };
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
await expect(batcher.query('SELECT a', [])).rejects.toMatchObject({
code: 'dbBatchFailed',
reason: 'connAcquire',
});
expect(getConnection).toHaveBeenCalledTimes(3);
});
it('bounds connection acquisition and rejects when the pool never answers', async () => {
const getConnection = vi.fn(
() => new Promise(() => {}), // pool never yields a connection
);
const pool = { promise: () => ({ getConnection }) };
const batcher = new SQLBatcher(pool, {
maxTimeInQueue: 5,
acquireTimeoutMs: 30,
});
await expect(batcher.query('SELECT a', [])).rejects.toMatchObject({
code: 'dbBatchFailed',
reason: 'connAcquire',
});
expect(getConnection).toHaveBeenCalledTimes(3);
});
it('opens the breaker after consecutive failures and rejects with reason breakerOpen', async () => {
// Batch and fallback both fail with an ambiguous (non-retriable for
// writes) connection error, so no item gets through.
const conn = makeConnection(() => {
throw makeError('ECONNRESET');
});
const { pool } = makePool(conn);
const batcher = new SQLBatcher(pool, {
maxTimeInQueue: 5,
failureThreshold: 1,
cooldownMs: 60_000,
});
await expect(batcher.query('INSERT x', [])).rejects.toMatchObject({
code: 'ECONNRESET',
});
await expect(batcher.query('INSERT y', [])).rejects.toMatchObject({
code: 'dbBatchFailed',
reason: 'breakerOpen',
});
});
it('retries transient fallback failures when readOnly', async () => {
let fallbackAttempts = 0;
const conn = makeConnection((sql) => {
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
fallbackAttempts++;
if (fallbackAttempts === 1) throw makeError('ECONNRESET');
return [[{ ok: 1 }], undefined];
});
const { pool } = makePool(conn);
const batcher = new SQLBatcher(pool, {
maxTimeInQueue: 5,
readOnly: true,
});
const result = await batcher.query('SELECT a', []);
expect(result[0]).toEqual([{ ok: 1 }]);
expect(fallbackAttempts).toBe(2);
});
it('does not retry ambiguous failures on a batcher that carries writes', async () => {
let fallbackAttempts = 0;
const conn = makeConnection((sql) => {
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
fallbackAttempts++;
throw makeError('ECONNRESET');
});
const { pool } = makePool(conn);
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
await expect(batcher.query('INSERT x', [])).rejects.toMatchObject({
code: 'ECONNRESET',
});
expect(fallbackAttempts).toBe(1);
});
it('retries never-sent failures even on a batcher that carries writes', async () => {
let fallbackAttempts = 0;
const conn = makeConnection((sql) => {
if (isBatchQuery(sql)) throw makeError('ECONNRESET');
fallbackAttempts++;
if (fallbackAttempts === 1) throw makeError('ECONNREFUSED');
return [[{ ok: 1 }], undefined];
});
const { pool } = makePool(conn);
const batcher = new SQLBatcher(pool, { maxTimeInQueue: 5 });
const result = await batcher.query('INSERT x', []);
expect(result[0]).toEqual([{ ok: 1 }]);
expect(fallbackAttempts).toBe(2);
});
it('never retries deterministic row-level errors and does not escalate the breaker', async () => {
let fallbackAttempts = 0;
const conn = makeConnection((sql) => {
if (isBatchQuery(sql)) throw makeError('ER_DUP_ENTRY');
fallbackAttempts++;
if (sql === 'INSERT dup') throw makeError('ER_DUP_ENTRY');
return [[{ ok: 1 }], undefined];
});
const { pool } = makePool(conn);
const batcher = new SQLBatcher(pool, {
maxTimeInQueue: 5,
failureThreshold: 1,
cooldownMs: 60_000,
readOnly: true,
});
const dup = batcher.query('INSERT dup', []);
const fine = batcher.query('INSERT fine', []);
await expect(dup).rejects.toMatchObject({ code: 'ER_DUP_ENTRY' });
await expect(fine).resolves.toBeTruthy();
expect(fallbackAttempts).toBe(2);
// One fallback item succeeded, so the breaker must stay closed.
const conn2 = makeConnection(happyBatch);
// reuse same batcher/pool: next query must not be rejected upfront
conn.query.mockImplementation(conn2.query.getMockImplementation()!);
await expect(batcher.query('SELECT a', [])).resolves.toBeTruthy();
});
});
@@ -0,0 +1,437 @@
/**
* 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 Database from 'better-sqlite3';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { IConfig } from '../../types';
import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
const CURRENT_SCHEMA_VERSION = 60;
const SYSTEM_USER_UUID = '5d4adce0-a381-4982-9c02-6e2540026238';
const sqliteConfig = (
database: Partial<NonNullable<IConfig['database']>> = {},
): IConfig =>
({
port: 0,
extensions: [],
database: { engine: 'sqlite', inMemory: true, ...database },
}) as IConfig;
const bootClient = async (
database: Partial<NonNullable<IConfig['database']>> = {},
): Promise<SqliteDatabaseClient> => {
const client = new SqliteDatabaseClient(sqliteConfig(database));
await client.onServerStart();
return client;
};
const userVersionOf = async (client: SqliteDatabaseClient): Promise<number> => {
const [row] = await client.read('PRAGMA user_version');
return row.user_version as number;
};
describe('SqliteDatabaseClient — boot and migrations', () => {
let client: SqliteDatabaseClient;
beforeEach(async () => {
client = await bootClient();
});
afterEach(() => {
client.onServerShutdown();
});
it('is what the factory picks for the sqlite engine', () => {
expect(new DatabaseClientFactory(sqliteConfig())).toBeInstanceOf(
SqliteDatabaseClient,
);
});
it('migrates a fresh database all the way to the current version', async () => {
expect(await userVersionOf(client)).toBe(CURRENT_SCHEMA_VERSION);
});
it('runs the javascript migrations, not just the .sql ones', async () => {
// The `system` user only exists because 0025 (a .dbmig.js file) ran
// inside the migration VM.
const rows = await client.read(
'SELECT `username` FROM `user` WHERE `uuid` = ?',
[SYSTEM_USER_UUID],
);
expect(rows).toEqual([{ username: 'system' }]);
});
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');
try {
const first = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path }),
);
await first.onServerStart();
await first.write(
'INSERT INTO `kv` (`user_id`, `kkey_hash`, `kkey`, `value`) ' +
'VALUES (?, ?, ?, ?)',
[1, 1, 'boot-marker', '"kept"'],
);
first.onServerShutdown();
expect(existsSync(path)).toBe(true);
const second = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path }),
);
await second.onServerStart();
await expect(
second.read('SELECT `value` FROM `kv` WHERE `kkey` = ?', [
'boot-marker',
]),
).resolves.toEqual([{ value: '"kept"' }]);
expect(await userVersionOf(second)).toBe(CURRENT_SCHEMA_VERSION);
second.onServerShutdown();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('stops early at a configured target version', async () => {
const partial = await bootClient({ targetVersion: 5 });
try {
expect(await userVersionOf(partial)).toBe(5);
// 0005 landed (apps.background); 0011 (notification) did not.
await expect(
partial.read(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'notification'",
),
).resolves.toEqual([]);
await expect(
partial.read(
"SELECT 1 FROM pragma_table_info('apps') WHERE name = 'background'",
),
).resolves.toHaveLength(1);
} finally {
partial.onServerShutdown();
}
});
});
describe('SqliteDatabaseClient — legacy version inference', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-legacy-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('treats user_version=0 with no bootstrap tables as uninitialized', async () => {
const path = join(dir, 'blank.sqlite');
new Database(path).close();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const client = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path }),
);
try {
await client.onServerStart();
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('bootstrap tables are missing'),
);
// A full migration run followed, so the schema is current.
expect(await userVersionOf(client)).toBe(CURRENT_SCHEMA_VERSION);
} finally {
client.onServerShutdown();
warn.mockRestore();
}
});
it('infers the schema version from table and column markers', async () => {
const path = join(dir, 'legacy.sqlite');
const seed = new Database(path);
seed.exec(`
CREATE TABLE user (
id INTEGER PRIMARY KEY,
username TEXT,
otp_secret TEXT,
otp_enabled INTEGER,
otp_recovery_codes TEXT
);
CREATE TABLE apps (
id INTEGER PRIMARY KEY,
uid TEXT,
background INTEGER,
metadata TEXT
);
CREATE TABLE user_to_user_permissions (id INTEGER PRIMARY KEY);
CREATE TABLE audit_user_to_user_permissions (
id INTEGER PRIMARY KEY,
issuer_user_id INTEGER,
holder_user_id INTEGER
);
CREATE TABLE sessions (
id INTEGER PRIMARY KEY,
created_at INTEGER,
last_activity INTEGER
);
CREATE TABLE kv (id INTEGER PRIMARY KEY, value JSON);
INSERT INTO apps (uid)
VALUES ('app-e3ac5486-da8c-42ad-8377-8728086e0980');
`);
seed.close();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
// targetVersion just past the inferred version keeps the run a
// no-op, so the assertion is about inference alone.
const client = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path, targetVersion: 22 }),
);
try {
await client.onServerStart();
expect(warn).toHaveBeenCalledWith(
'[sqlite] user_version=0; inferred legacy schema version 21',
);
// Nothing was applied, so user_version stays where it was.
expect(await userVersionOf(client)).toBe(0);
} finally {
client.onServerShutdown();
warn.mockRestore();
}
});
it('reports version 0 when only the bootstrap tables exist', async () => {
const path = join(dir, 'bootstrap-only.sqlite');
const seed = new Database(path);
seed.exec(`
CREATE TABLE user (id INTEGER PRIMARY KEY);
CREATE TABLE apps (id INTEGER PRIMARY KEY);
`);
seed.close();
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
const client = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path, targetVersion: 1 }),
);
try {
await client.onServerStart();
expect(log).toHaveBeenCalledWith('[sqlite] database version: 0');
} finally {
client.onServerShutdown();
log.mockRestore();
}
});
});
describe('SqliteDatabaseClient — query interface', () => {
let client: SqliteDatabaseClient;
beforeEach(async () => {
client = await bootClient();
await client.write(
'CREATE TABLE `widget` (`id` INTEGER PRIMARY KEY, ' +
'`name` TEXT, `enabled` INTEGER, `seen_at` TEXT)',
);
});
afterEach(() => {
client.onServerShutdown();
});
it('reports lastInsertRowid and change counts on write', async () => {
const inserted = await client.write(
'INSERT INTO `widget` (`name`) VALUES (?)',
['spanner'],
);
expect(inserted).toEqual({
insertId: 1,
affectedRows: 1,
anyRowsAffected: true,
});
const missed = await client.write(
'UPDATE `widget` SET `name` = ? WHERE `id` = ?',
['nope', 999],
);
expect(missed).toMatchObject({
affectedRows: 0,
anyRowsAffected: false,
});
});
it('binds booleans as sqlite integers', async () => {
await client.write(
'INSERT INTO `widget` (`name`, `enabled`) VALUES (?, ?)',
['toggled', true],
);
await client.write(
'INSERT INTO `widget` (`name`, `enabled`) VALUES (?, ?)',
['untoggled', false],
);
await expect(
client.read('SELECT `name`, `enabled` FROM `widget` ORDER BY `id`'),
).resolves.toEqual([
{ name: 'toggled', enabled: 1 },
{ name: 'untoggled', enabled: 0 },
]);
});
it('rewrites now() into the sqlite equivalent', async () => {
await client.write(
'INSERT INTO `widget` (`name`, `seen_at`) VALUES (?, NOW())',
['stamped'],
);
const [row] = await client.read(
'SELECT `seen_at` FROM `widget` WHERE `name` = ?',
['stamped'],
);
expect(row.seen_at).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
});
it('generates an INSERT from a data object', async () => {
await client.insert('widget', { name: 'generated', enabled: true });
await expect(
client.read('SELECT `name`, `enabled` FROM `widget`'),
).resolves.toEqual([{ name: 'generated', enabled: 1 }]);
});
it('primary-reads through the same single-node connection', async () => {
await client.write('INSERT INTO `widget` (`name`) VALUES (?)', ['p']);
await expect(
client.pread('SELECT `name` FROM `widget`'),
).resolves.toEqual([{ name: 'p' }]);
});
it('applies a batch write atomically', async () => {
await client.batchWrite([
{
statement:
'INSERT INTO `widget` (`name`, `enabled`) VALUES (?, ?)',
values: ['one', true],
},
{
statement: 'INSERT INTO `widget` (`name`) VALUES (?)',
values: ['two'],
},
]);
await expect(
client.read('SELECT `name` FROM `widget` ORDER BY `id`'),
).resolves.toEqual([{ name: 'one' }, { name: 'two' }]);
});
it('rolls the whole batch back when one statement fails', async () => {
await expect(
client.batchWrite([
{
statement: 'INSERT INTO `widget` (`name`) VALUES (?)',
values: ['kept-if-broken'],
},
{
statement: 'INSERT INTO `nonexistent` (`name`) VALUES (?)',
values: ['boom'],
},
]),
).rejects.toThrow(/no such table/i);
await expect(
client.read('SELECT `name` FROM `widget`'),
).resolves.toEqual([]);
});
it('surfaces sqlite errors for malformed SQL', async () => {
await expect(client.read('SELEC oops')).rejects.toThrow(
/syntax error/i,
);
});
it('closes the database on shutdown', async () => {
const closable = await bootClient();
closable.onServerShutdown();
await expect(closable.read('SELECT 1')).rejects.toThrow(
'The database connection is not open',
);
});
});
// Single-node engines have no replica to race, so `tryHardRead` must not
// issue the query twice — the base-class default fires `pread()` and
// `read()` in parallel, which on sqlite is the same connection.
describe('SqliteDatabaseClient — tryHardRead', () => {
let client: SqliteDatabaseClient;
beforeEach(async () => {
client = await bootClient();
await client.write(
'CREATE TABLE `widget` (`id` INTEGER PRIMARY KEY, `name` TEXT)',
);
});
afterEach(() => {
client.onServerShutdown();
});
it('executes the statement exactly once', async () => {
await client.write('INSERT INTO `widget` (`name`) VALUES (?)', ['one']);
const readSpy = vi.spyOn(client, 'read');
const preadSpy = vi.spyOn(client, 'pread');
await expect(
client.tryHardRead('SELECT `name` FROM `widget`'),
).resolves.toEqual([{ name: 'one' }]);
expect(readSpy).toHaveBeenCalledTimes(1);
expect(preadSpy).not.toHaveBeenCalled();
});
it('executes the statement exactly once when it matches no rows', async () => {
const readSpy = vi.spyOn(client, 'read');
const preadSpy = vi.spyOn(client, 'pread');
await expect(
client.tryHardRead('SELECT `name` FROM `widget`'),
).resolves.toEqual([]);
expect(readSpy).toHaveBeenCalledTimes(1);
expect(preadSpy).not.toHaveBeenCalled();
});
it('throws from requireRead when nothing matches', async () => {
await expect(
client.requireRead(
'SELECT `name` FROM `widget` WHERE `id` = ?',
[42],
),
).rejects.toThrow('required read returned no rows');
});
it('returns the rows from requireRead when something matches', async () => {
await client.write('INSERT INTO `widget` (`name`) VALUES (?)', ['ok']);
await expect(
client.requireRead('SELECT `name` FROM `widget`'),
).resolves.toEqual([{ name: 'ok' }]);
});
});
@@ -1,4 +1,4 @@
/**
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
@@ -21,14 +21,15 @@ import { existsSync, mkdirSync, readFileSync } from 'fs';
import { basename, dirname, extname, join, resolve } from 'path';
import { createContext, runInContext } from 'vm';
import type { IConfig } from '../../types';
import { Span } from '../../util/span.js';
import { AbstractDatabaseClient, type WriteResult } from './DatabaseClient';
const MIGRATIONS_DIR = resolve(__dirname, './migrations/sqlite');
/**
* Ordered list of [threshold_version, files[]] pairs.
* A database whose `user_version` is <= threshold_version will have
* the corresponding files applied.
* Ordered list of [threshold_version, files[]] pairs. A database whose
* `user_version` is <= threshold_version will have the corresponding files
* applied.
*/
const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[-1, ['0001_create-tables.sql', '0002_add-default-apps.sql']],
@@ -81,6 +82,18 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[46, ['0050_add_preamble_version.sql']],
[47, ['0051_sessions_v2.sql']],
[48, ['0052_sessions_v2_lookups.sql']],
[49, ['0053_sessions_access_token_uid.sql']],
[50, ['0054_sessions_workers.sql']],
[50, ['0055_username_nocase_unique.sql']],
[51, ['0056_sessions_kind_worker.sql']],
[52, ['0057_add_user_reputation.sql']],
[53, ['0058_add_phone_verification.sql']],
[54, ['0059_add_card_verification.sql']],
[55, ['0060_add_card_fingerprint.sql']],
[56, ['0061_add_suspended_at.sql']],
[57, ['0062_blocked-app-origins.sql']],
[58, ['0063_add_suspended_reason.sql']],
[59, ['0064_abuse-moderation-events.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -124,6 +137,7 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
// Query interface
// ------------------------------------------------------------------
@Span('db.read', (query: string) => ({ 'db.statement': query }))
override async read(
query: string,
params: unknown[] = [],
@@ -144,6 +158,17 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
return this.read(query, params);
}
override async tryHardRead(
query: string,
params: unknown[] = [],
): Promise<Record<string, unknown>[]> {
// No replica to race, so the base class's parallel
// primary-plus-replica read would run the same statement twice on
// the one connection.
return this.read(query, params);
}
@Span('db.write', (query: string) => ({ 'db.statement': query }))
override async write(
query: string,
params: unknown[] = [],
@@ -160,6 +185,9 @@ export class SqliteDatabaseClient extends AbstractDatabaseClient {
};
}
@Span('db.batchWrite', (entries: unknown[]) => ({
'db.batch_size': entries.length,
}))
override async batchWrite(
entries: { statement: string; values: unknown[] }[],
): Promise<void> {
+8 -4
View File
@@ -1,4 +1,4 @@
/**
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
@@ -24,16 +24,18 @@ export {
} from './DatabaseClient';
export { SqliteDatabaseClient } from './SqliteDatabaseClient';
export { MySQLDatabaseClient } from './MySQLDatabaseClient';
export { PostgresDatabaseClient } from './PostgresDatabaseClient';
import type { IConfig } from '../../types';
import { AbstractDatabaseClient } from './DatabaseClient';
import { MySQLDatabaseClient } from './MySQLDatabaseClient';
import { PostgresDatabaseClient } from './PostgresDatabaseClient';
import { SqliteDatabaseClient } from './SqliteDatabaseClient';
/**
* Factory class registered in `puterClients`. PuterServer calls
* `new DatabaseClientFactory(config)` — the constructor returns the
* concrete subclass selected by `config.database.engine`.
* Factory class registered in `puterClients`. PuterServer calls `new
* DatabaseClientFactory(config)` — the constructor returns the concrete
* subclass selected by `config.database.engine`.
*/
export const DatabaseClientFactory = class DatabaseClientFactory {
constructor(config: IConfig) {
@@ -41,6 +43,8 @@ export const DatabaseClientFactory = class DatabaseClientFactory {
switch (engine) {
case 'mysql':
return new MySQLDatabaseClient(config);
case 'postgres':
return new PostgresDatabaseClient(config);
case 'sqlite':
return new SqliteDatabaseClient(config);
default:
@@ -0,0 +1,43 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Comparator for migration filenames named `<engine>_mig_<N>.sql`.
*
* Existing MySQL files use unpadded integers, so lexical sorting places
* `*_mig_10.sql` before `*_mig_2.sql`. Pull the trailing integer out and sort
* numerically. Anything that does not match the `_<digits>.sql` shape falls
* back to lexical comparison and sorts after numbered files.
*/
export const compareMigrationFilenames = (a: string, b: string): number => {
const numericIndex = (name: string): number => {
const m = /_(\d+)\.sql$/.exec(name);
return m ? Number.parseInt(m[1], 10) : Number.NaN;
};
const na = numericIndex(a);
const nb = numericIndex(b);
if (Number.isFinite(na) && Number.isFinite(nb)) {
if (na !== nb) return na - nb;
} else if (Number.isFinite(na)) {
return -1;
} else if (Number.isFinite(nb)) {
return 1;
}
return a.localeCompare(b);
};
@@ -24,7 +24,9 @@
--
--
-- Idempotent column-ensure helper (used by CALLs below; dropped at end of file)
-- Idempotent column-ensure helper. Used by the CALLs below AND by later
-- migration files, which CALL it as a one-line idempotent column add. Left
-- resident (not dropped at end of file) so it outlives this migration.
--
DROP PROCEDURE IF EXISTS _puter_add_col;
@@ -1333,7 +1335,10 @@ CALL _puter_add_col('user_update_audit', 'new_username', '`new_username` varchar
CALL _puter_add_col('user_update_audit', 'reason', '`reason` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_col;
-- _puter_add_col is intentionally NOT dropped here: later migration files CALL
-- it as a one-line idempotent column add, and migrations replay on every boot
-- with no applied-state tracking, so the helper must outlive this file. The
-- DROP-before-CREATE at the top keeps this migration itself replay-safe.
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
@@ -0,0 +1,53 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Mirrors SQLite migration 0053. Adds the
-- `access_token_uid` reverse-lookup column on `sessions` so raw-uuid
-- revoke can find the matching session row when only the v2 token_uid
-- (no JWT) is presented.
--
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration
-- directory can be replayed safely.
DROP PROCEDURE IF EXISTS _puter_sessions_access_token_uid;
DELIMITER //
CREATE PROCEDURE _puter_sessions_access_token_uid()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'access_token_uid'
) THEN
ALTER TABLE `sessions`
ADD COLUMN `access_token_uid` VARCHAR(64) DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND INDEX_NAME = 'idx_sessions_access_token_uid'
) THEN
ALTER TABLE `sessions`
ADD INDEX `idx_sessions_access_token_uid` (`access_token_uid`);
END IF;
END//
DELIMITER ;
CALL _puter_sessions_access_token_uid();
DROP PROCEDURE IF EXISTS _puter_sessions_access_token_uid;
@@ -0,0 +1,70 @@
-- 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/>.
-- Worker session uniqueness. Each Puter worker is a separately-deployed
-- code unit (its own subdomain), so one user can have many workers under
-- the same app — distinguished by `meta.worker_name`. The natural unique
-- key for an active worker session is therefore
-- (user_id, app_uid, worker_name), and app_uid is allowed NULL for
-- user-scoped workers that aren't bound to any specific app.
--
-- Implemented the same way as `app_unique_key` from mig_9: a VIRTUAL
-- generated column that's non-NULL only for the rows under the rule,
-- then a UNIQUE INDEX on the column. NULLs don't conflict in MySQL
-- UNIQUE indexes, so soft-revoked / non-worker rows fall out
-- automatically. IFNULL normalises NULL `app_uid` so two user-scoped
-- workers with the same name still dedupe. JSON_UNQUOTE strips the
-- JSON value quoting from JSON_EXTRACT so the concatenated key is a
-- plain string that matches the SELECT path's binding.
--
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration
-- directory can be replayed safely.
DROP PROCEDURE IF EXISTS _puter_sessions_worker_unique;
DELIMITER //
CREATE PROCEDURE _puter_sessions_worker_unique()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'worker_unique_key'
) THEN
ALTER TABLE `sessions`
ADD COLUMN `worker_unique_key` VARCHAR(550)
GENERATED ALWAYS AS (
IF(`kind` = 'worker' AND `revoked_at` IS NULL,
CONCAT(`user_id`, '|', IFNULL(`app_uid`, ''), '|',
IFNULL(JSON_UNQUOTE(JSON_EXTRACT(`meta`, '$.worker_name')), '')),
NULL)
) VIRTUAL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND INDEX_NAME = 'idx_sessions_user_worker_active'
) THEN
ALTER TABLE `sessions`
ADD UNIQUE INDEX `idx_sessions_user_worker_active` (`worker_unique_key`);
END IF;
END//
DELIMITER ;
CALL _puter_sessions_worker_unique();
DROP PROCEDURE IF EXISTS _puter_sessions_worker_unique;
@@ -0,0 +1,50 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Add 'worker' to the `sessions.kind` ENUM. mig_11 already added the
-- worker uniqueness index and the application code in SessionStore
-- inserts rows with kind='worker', but the ENUM defined in mig_8 never
-- listed 'worker' as a permitted value. Under STRICT_TRANS_TABLES the
-- INSERT fails outright; under a relaxed sql_mode the value is coerced
-- to '' (and the worker SELECT-by-kind path then misses the row).
-- Mirrors SQLite migration 0056.
--
-- Idempotent: guarded against COLUMN_TYPE so re-running the directory is
-- a no-op once 'worker' is in the ENUM.
DROP PROCEDURE IF EXISTS _puter_sessions_kind_worker;
DELIMITER //
CREATE PROCEDURE _puter_sessions_kind_worker()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND COLUMN_NAME = 'kind'
AND FIND_IN_SET('worker', REPLACE(REPLACE(REPLACE(COLUMN_TYPE, 'enum(', ''), ')', ''), '''', '')) > 0
) THEN
ALTER TABLE `sessions`
MODIFY COLUMN `kind`
ENUM('web', 'app', 'access_token', 'asset', 'worker')
NOT NULL DEFAULT 'web';
END IF;
END//
DELIMITER ;
CALL _puter_sessions_kind_worker();
DROP PROCEDURE IF EXISTS _puter_sessions_kind_worker;
@@ -0,0 +1,47 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the
-- E.164 number collected during verification (indexed like `email`);
-- `requires_phone_verification` gates account use for low-reputation signups
-- (not indexed, mirroring `requires_email_confirmation`).
--
-- Idempotent: column adds use _puter_add_col (defined in mig_1, which leaves it
-- resident for later migrations); the index add is guarded against
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
CALL _puter_add_col('user', 'phone', '`phone` varchar(20) DEFAULT NULL');
CALL _puter_add_col('user', 'requires_phone_verification', '`requires_phone_verification` tinyint(1) NOT NULL DEFAULT ''0''');
DROP PROCEDURE IF EXISTS _puter_add_user_phone_index;
DELIMITER //
CREATE PROCEDURE _puter_add_user_phone_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND INDEX_NAME = 'idx_user_phone'
) THEN
ALTER TABLE `user` ADD INDEX `idx_user_phone` (`phone`);
END IF;
END//
DELIMITER ;
CALL _puter_add_user_phone_index();
DROP PROCEDURE IF EXISTS _puter_add_user_phone_index;
@@ -0,0 +1,26 @@
-- 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/>.
-- Credit-card verification column. Mirrors SQLite migration 0059.
-- `requires_card_verification` gates account use for low-reputation signups;
-- the card itself never touches our DB, so this is the only column (not
-- indexed, mirroring `requires_phone_verification`).
--
-- Idempotent: the column add uses _puter_add_col (from mig_1) so the
-- directory replays safely.
CALL _puter_add_col('user', 'requires_card_verification', '`requires_card_verification` tinyint(1) NOT NULL DEFAULT ''0''');
@@ -0,0 +1,47 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
-- is the Stripe card fingerprint (stable per card number) recorded when a user
-- clears card verification — the card sibling of `phone`, indexed like it so
-- admin tooling can find the accounts that verified with a given card. The card
-- itself never touches our DB, only Stripe's fingerprint for it.
--
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
-- leaves it resident for later migrations); the index add is guarded against
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
CALL _puter_add_col('user', 'card_fingerprint', '`card_fingerprint` varchar(128) DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
DELIMITER //
CREATE PROCEDURE _puter_add_user_card_fingerprint_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND INDEX_NAME = 'idx_user_card_fingerprint'
) THEN
ALTER TABLE `user` ADD INDEX `idx_user_card_fingerprint` (`card_fingerprint`);
END IF;
END//
DELIMITER ;
CALL _puter_add_user_card_fingerprint_index();
DROP PROCEDURE IF EXISTS _puter_add_user_card_fingerprint_index;
@@ -0,0 +1,46 @@
-- 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/>.
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
-- the account was suspended, as unix seconds (NULL while not suspended) — the
-- timestamp sibling of the boolean `suspended` flag, indexed so the
-- signup-abuse harness can count an IP's recently-suspended accounts.
--
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
-- leaves it resident for later migrations); the index add is guarded against
-- INFORMATION_SCHEMA.STATISTICS so the directory replays safely.
CALL _puter_add_col('user', 'suspended_at', '`suspended_at` bigint DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
DELIMITER //
CREATE PROCEDURE _puter_add_user_suspended_at_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user'
AND INDEX_NAME = 'idx_user_suspended_at'
) THEN
ALTER TABLE `user` ADD INDEX `idx_user_suspended_at` (`suspended_at`);
END IF;
END//
DELIMITER ;
CALL _puter_add_user_suspended_at_index();
DROP PROCEDURE IF EXISTS _puter_add_user_suspended_at_index;
@@ -0,0 +1,36 @@
-- 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/>.
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062.
-- An app whose `index_url` host (or a request origin) matches an entry is
-- denied access to Puter resources: it cannot obtain an app token and
-- already-issued app tokens are rejected on each request. `include_subdomains
-- = 1` also blocks every subdomain of `domain`. Enforced in AuthService via
-- AppOriginBlocklistService.
--
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
`id` INT NOT NULL AUTO_INCREMENT,
`domain` VARCHAR(255) NOT NULL,
`include_subdomains` TINYINT(1) NOT NULL DEFAULT 0,
`reason` TEXT DEFAULT NULL,
`created_by` VARCHAR(255) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_blocked_app_origins_domain` (`domain`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,26 @@
-- 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/>.
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
--
-- Idempotent: the column add uses _puter_add_col (defined in mig_1, which
-- leaves it resident for later migrations).
CALL _puter_add_col('user', 'suspended_reason', '`suspended_reason` VARCHAR(64) DEFAULT NULL');
@@ -0,0 +1,38 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064.
-- Used to measure the abuse system's false-positive rate: `unsuspend` (admin
-- unblock) and `admin_create_user` are the false-positive signals; `suspend`
-- gives the denominator. Preserves history the `user` suspension columns lose
-- on unsuspend. `created_at` is unix seconds (matching `user.suspended_at`).
--
-- Idempotent: `CREATE TABLE IF NOT EXISTS` lets the directory replay safely.
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
`id` INT NOT NULL AUTO_INCREMENT,
`action` VARCHAR(32) NOT NULL,
`target_user_id` BIGINT DEFAULT NULL,
`target_username` VARCHAR(255) DEFAULT NULL,
`admin_username` VARCHAR(255) DEFAULT NULL,
`reason` TEXT DEFAULT NULL,
`source` VARCHAR(64) DEFAULT NULL,
`created_at` BIGINT NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_abuse_moderation_events_created_at` (`created_at`),
KEY `idx_abuse_moderation_events_action` (`action`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -15,7 +15,7 @@
-- 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/>.
-- AUTH-2 (PUT-1014) — composite-key lookups + audit columns. Mirrors
-- Composite-key lookups + audit columns. Mirrors
-- SQLite migration 0052. MySQL has no partial unique indexes, so the
-- "at most one active row per (user_id, app_uid)" / "one active row
-- per legacy_token_uid" semantics are encoded via VIRTUAL generated
@@ -0,0 +1,724 @@
-- 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/>.
CREATE TABLE IF NOT EXISTS "user" (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uuid varchar(36) NOT NULL UNIQUE,
username varchar(50) UNIQUE,
email varchar(256),
password varchar(225),
free_storage bigint,
max_subdomains integer,
taskbar_items text,
desktop_uuid varchar(36),
appdata_uuid varchar(36),
documents_uuid varchar(36),
pictures_uuid varchar(36),
videos_uuid varchar(36),
trash_uuid varchar(36),
trash_id integer,
appdata_id integer,
desktop_id integer,
documents_id integer,
pictures_id integer,
videos_id integer,
referrer varchar(64),
desktop_bg_url text,
desktop_bg_color varchar(20),
desktop_bg_fit varchar(16),
pass_recovery_token varchar(36),
requires_email_confirmation boolean NOT NULL DEFAULT FALSE,
email_confirm_code varchar(8),
email_confirm_token varchar(36),
email_confirmed boolean NOT NULL DEFAULT FALSE,
dev_first_name varchar(100),
dev_last_name varchar(100),
dev_paypal varchar(100),
dev_approved_for_incentive_program boolean DEFAULT FALSE,
dev_joined_incentive_program boolean DEFAULT FALSE,
suspended boolean,
unsubscribed boolean NOT NULL DEFAULT FALSE,
"timestamp" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_activity_ts timestamp,
referral_code varchar(16) UNIQUE,
referred_by integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
unconfirmed_change_email varchar(256),
change_email_confirm_token varchar(256),
otp_secret text,
otp_enabled boolean DEFAULT FALSE,
otp_recovery_codes text,
stripe_customer_id varchar(40),
public_uuid varchar(36),
public_id integer,
clean_email varchar(256),
audit_metadata jsonb,
signup_ip varchar(45),
signup_ip_forwarded varchar(45),
signup_user_agent varchar(512),
signup_origin varchar(255),
signup_server varchar(255),
metadata jsonb DEFAULT '{}'::jsonb,
reputation smallint DEFAULT 100
);
CREATE INDEX IF NOT EXISTS idx_user_email ON "user" (email);
CREATE INDEX IF NOT EXISTS idx_user_pass_recovery_token ON "user" (pass_recovery_token);
CREATE INDEX IF NOT EXISTS idx_user_referrer ON "user" (referrer);
CREATE INDEX IF NOT EXISTS idx_user_email_confirm_token ON "user" (email_confirm_token);
CREATE INDEX IF NOT EXISTS idx_user_last_activity_ts ON "user" (last_activity_ts);
CREATE INDEX IF NOT EXISTS idx_user_referred_by ON "user" (referred_by);
CREATE INDEX IF NOT EXISTS idx_user_stripe_customer_id ON "user" (stripe_customer_id);
CREATE INDEX IF NOT EXISTS idx_user_clean_email ON "user" (clean_email);
CREATE INDEX IF NOT EXISTS idx_user_signup_ip ON "user" (signup_ip);
CREATE INDEX IF NOT EXISTS idx_user_signup_ip_forwarded ON "user" (signup_ip_forwarded);
CREATE INDEX IF NOT EXISTS idx_user_signup_user_agent ON "user" (signup_user_agent);
CREATE INDEX IF NOT EXISTS idx_user_signup_origin ON "user" (signup_origin);
CREATE INDEX IF NOT EXISTS idx_user_signup_server ON "user" (signup_server);
CREATE TABLE IF NOT EXISTS apps (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid varchar(40) NOT NULL UNIQUE,
owner_user_id integer REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
icon text,
name varchar(100) NOT NULL UNIQUE,
title varchar(100) NOT NULL,
description text,
godmode boolean DEFAULT FALSE,
maximize_on_start boolean DEFAULT FALSE,
index_url text NOT NULL,
approved_for_listing boolean DEFAULT FALSE,
approved_for_opening_items boolean DEFAULT FALSE,
approved_for_incentive_program boolean DEFAULT FALSE,
"timestamp" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_review timestamp,
tags varchar(255),
app_owner integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
background boolean DEFAULT FALSE,
metadata jsonb,
protected boolean DEFAULT FALSE,
is_private boolean DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_apps_owner_user_id ON apps (owner_user_id);
CREATE INDEX IF NOT EXISTS idx_apps_app_owner ON apps (app_owner);
CREATE INDEX IF NOT EXISTS idx_apps_owner_timestamp ON apps (owner_user_id, "timestamp" DESC);
CREATE INDEX IF NOT EXISTS idx_apps_listing_timestamp ON apps (approved_for_listing, "timestamp" DESC);
CREATE TABLE IF NOT EXISTS "group" (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid varchar(40) UNIQUE,
owner_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
extra jsonb,
metadata jsonb,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_group_owner_user_id ON "group" (owner_user_id);
CREATE TABLE IF NOT EXISTS app_filetype_association (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
app_id integer NOT NULL REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
type varchar(60) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_app_filetype_association_app_id ON app_filetype_association (app_id);
CREATE INDEX IF NOT EXISTS idx_app_filetype_association_type ON app_filetype_association (type);
CREATE TABLE IF NOT EXISTS app_opens (
_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
app_uid varchar(40) NOT NULL REFERENCES apps (uid) ON DELETE CASCADE ON UPDATE CASCADE,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
ts integer NOT NULL,
human_ts timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_app_opens_user_id ON app_opens (user_id);
CREATE INDEX IF NOT EXISTS idx_app_opens_app_uid ON app_opens (app_uid);
CREATE INDEX IF NOT EXISTS idx_app_opens_uid_ts ON app_opens (app_uid, ts);
CREATE INDEX IF NOT EXISTS idx_app_opens_app_user ON app_opens (app_uid, user_id);
CREATE TABLE IF NOT EXISTS fsentries (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uuid varchar(36) NOT NULL UNIQUE,
bucket varchar(50),
bucket_region varchar(30),
public_token varchar(36) UNIQUE,
file_request_token varchar(36) UNIQUE,
is_shortcut boolean DEFAULT FALSE,
shortcut_to integer REFERENCES fsentries (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
parent_id integer REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE,
associated_app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
is_dir boolean DEFAULT FALSE,
layout varchar(30),
sort_by varchar(20),
sort_order varchar(10),
is_public boolean,
thumbnail text,
immutable boolean NOT NULL DEFAULT FALSE,
name varchar(767) NOT NULL,
metadata text,
modified integer NOT NULL,
created integer,
accessed integer,
size bigint,
symlink_path varchar(260),
is_symlink boolean DEFAULT FALSE,
parent_uid varchar(36),
path varchar(4096),
CONSTRAINT fsentries_parent_name_unique UNIQUE (parent_id, name)
);
CREATE INDEX IF NOT EXISTS idx_fsentries_name ON fsentries (name);
CREATE INDEX IF NOT EXISTS idx_fsentries_modified ON fsentries (modified);
CREATE INDEX IF NOT EXISTS idx_fsentries_parent_id ON fsentries (parent_id);
CREATE INDEX IF NOT EXISTS idx_fsentries_is_dir ON fsentries (is_dir);
CREATE INDEX IF NOT EXISTS idx_fsentries_user_id ON fsentries (user_id);
CREATE INDEX IF NOT EXISTS idx_fsentries_shortcut_to ON fsentries (shortcut_to);
CREATE INDEX IF NOT EXISTS idx_fsentries_associated_app_id ON fsentries (associated_app_id);
CREATE INDEX IF NOT EXISTS idx_fsentries_bucket ON fsentries (bucket);
CREATE INDEX IF NOT EXISTS idx_fsentries_bucket_region ON fsentries (bucket_region);
CREATE INDEX IF NOT EXISTS idx_fsentries_parent_uid ON fsentries (parent_uid);
CREATE INDEX IF NOT EXISTS idx_fsentries_path ON fsentries (path);
CREATE INDEX IF NOT EXISTS idx_fsentries_accessed ON fsentries (accessed);
CREATE INDEX IF NOT EXISTS idx_fsentries_user_parent_name ON fsentries (user_id, parent_uid, name);
CREATE INDEX IF NOT EXISTS idx_fsentries_parent_uid_name ON fsentries (parent_uid, name);
CREATE TABLE IF NOT EXISTS fsentry_versions (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
fsentry_id integer NOT NULL REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE,
fsentry_uuid varchar(36) NOT NULL,
version_id varchar(60) NOT NULL,
user_id integer REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
message text,
ts_epoch integer
);
CREATE INDEX IF NOT EXISTS idx_fsentry_versions_fsentry_id ON fsentry_versions (fsentry_id);
CREATE INDEX IF NOT EXISTS idx_fsentry_versions_fsentry_uuid ON fsentry_versions (fsentry_uuid);
CREATE INDEX IF NOT EXISTS idx_fsentry_versions_user_id ON fsentry_versions (user_id);
CREATE TABLE IF NOT EXISTS subdomains (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uuid varchar(40) UNIQUE,
subdomain varchar(64) NOT NULL UNIQUE,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
root_dir_id integer REFERENCES fsentries (id) ON DELETE SET NULL ON UPDATE CASCADE,
associated_app_id integer REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
ts timestamp DEFAULT CURRENT_TIMESTAMP,
app_owner integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
protected boolean DEFAULT FALSE,
domain varchar(265),
database_id varchar(40),
preamble_version varchar(64)
);
CREATE INDEX IF NOT EXISTS idx_subdomains_user_id ON subdomains (user_id);
CREATE INDEX IF NOT EXISTS idx_subdomains_root_dir_id ON subdomains (root_dir_id);
CREATE INDEX IF NOT EXISTS idx_subdomains_associated_app_id ON subdomains (associated_app_id);
CREATE INDEX IF NOT EXISTS idx_subdomains_app_owner ON subdomains (app_owner);
CREATE INDEX IF NOT EXISTS idx_subdomains_domain ON subdomains (domain);
CREATE INDEX IF NOT EXISTS idx_subdomains_root_user ON subdomains (root_dir_id, user_id);
CREATE INDEX IF NOT EXISTS idx_subdomains_app_user ON subdomains (associated_app_id, user_id);
CREATE TABLE IF NOT EXISTS kv (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
app varchar(40),
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
kkey_hash bigint NOT NULL,
kkey text NOT NULL,
value text,
migrated boolean DEFAULT FALSE,
"expireAt" timestamp,
CONSTRAINT kv_app_user_kkey_hash_unique UNIQUE (app, user_id, kkey_hash)
);
CREATE INDEX IF NOT EXISTS idx_kv_app ON kv (app);
CREATE INDEX IF NOT EXISTS idx_kv_user_id ON kv (user_id);
CREATE INDEX IF NOT EXISTS idx_kv_kkey_hash ON kv (kkey_hash);
CREATE TABLE IF NOT EXISTS sessions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
uuid varchar(40) NOT NULL,
meta jsonb,
created_at bigint DEFAULT 0,
last_activity bigint DEFAULT 0,
kind varchar(32) NOT NULL DEFAULT 'web'
CHECK (kind IN ('web', 'app', 'access_token', 'asset', 'worker')),
label varchar(255),
parent_session_id varchar(64),
last_ip varchar(64),
last_user_agent varchar(512),
revoked_at bigint,
expires_at bigint,
app_uid varchar(64),
legacy_token_uid varchar(64),
created_via varchar(32),
auth_id varchar(64),
access_token_uid varchar(64)
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_uuid ON sessions (uuid);
CREATE INDEX IF NOT EXISTS idx_sessions_user_revoked ON sessions (user_id, revoked_at);
CREATE INDEX IF NOT EXISTS idx_sessions_parent ON sessions (parent_session_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_user_app_active
ON sessions (user_id, app_uid)
WHERE kind = 'app' AND revoked_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_legacy_token_active
ON sessions (legacy_token_uid)
WHERE legacy_token_uid IS NOT NULL AND revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_kind_user ON sessions (kind, user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_access_token_uid
ON sessions (access_token_uid)
WHERE access_token_uid IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_user_worker_active
ON sessions (user_id, COALESCE(app_uid, ''), (meta #>> ARRAY['worker_name']))
WHERE kind = 'worker' AND revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS user_to_app_permissions (
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
app_id integer NOT NULL REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
permission varchar(255) NOT NULL,
extra jsonb,
dt timestamp DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, app_id, permission)
);
CREATE INDEX IF NOT EXISTS idx_utap_user_permission ON user_to_app_permissions (user_id, permission);
CREATE INDEX IF NOT EXISTS idx_utap_app_permission ON user_to_app_permissions (app_id, permission);
CREATE TABLE IF NOT EXISTS user_to_group_permissions (
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
group_id integer NOT NULL REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE,
permission varchar(255) NOT NULL,
extra jsonb,
PRIMARY KEY (user_id, group_id, permission)
);
CREATE INDEX IF NOT EXISTS idx_user_to_group_permissions_group_id ON user_to_group_permissions (group_id);
CREATE TABLE IF NOT EXISTS user_to_user_permissions (
issuer_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
holder_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
permission varchar(255) NOT NULL,
extra jsonb,
PRIMARY KEY (issuer_user_id, holder_user_id, permission)
);
CREATE INDEX IF NOT EXISTS idx_user_to_user_permissions_holder_user_id ON user_to_user_permissions (holder_user_id);
CREATE TABLE IF NOT EXISTS dev_to_app_permissions (
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
app_id integer NOT NULL REFERENCES apps (id) ON DELETE CASCADE ON UPDATE CASCADE,
permission varchar(255) NOT NULL,
extra jsonb,
PRIMARY KEY (user_id, app_id, permission)
);
CREATE INDEX IF NOT EXISTS idx_dev_to_app_permissions_app_id ON dev_to_app_permissions (app_id);
CREATE INDEX IF NOT EXISTS idx_dev_to_app_permissions_permission ON dev_to_app_permissions (permission);
CREATE TABLE IF NOT EXISTS access_token_permissions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
token_uid varchar(40) NOT NULL,
authorizer_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
authorizer_app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
permission varchar(255) NOT NULL,
extra jsonb,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_access_token_permissions_token_uid ON access_token_permissions (token_uid);
CREATE TABLE IF NOT EXISTS old_app_names (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
app_uid varchar(40) NOT NULL REFERENCES apps (uid) ON DELETE CASCADE,
name varchar(255) NOT NULL,
"timestamp" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_old_app_names_app_uid_name UNIQUE (app_uid, name)
);
CREATE INDEX IF NOT EXISTS idx_old_app_names_app_uid ON old_app_names (app_uid);
CREATE INDEX IF NOT EXISTS idx_old_app_names_name ON old_app_names (name);
CREATE TABLE IF NOT EXISTS jct_user_group (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
group_id integer NOT NULL REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE,
extra jsonb,
metadata jsonb,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_jct_user_group_user_id ON jct_user_group (user_id);
CREATE INDEX IF NOT EXISTS idx_jct_user_group_group_id ON jct_user_group (group_id);
CREATE TABLE IF NOT EXISTS notification (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
uid varchar(40) UNIQUE,
value jsonb NOT NULL,
acknowledged bigint,
shown bigint,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_notification_user_id ON notification (user_id);
CREATE TABLE IF NOT EXISTS share (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid varchar(40) UNIQUE,
issuer_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
recipient_email varchar(255) NOT NULL,
data jsonb,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_share_issuer_user_id ON share (issuer_user_id);
CREATE INDEX IF NOT EXISTS idx_share_recipient_email ON share (recipient_email);
CREATE TABLE IF NOT EXISTS feedback (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
message text,
ts timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_feedback_user_id ON feedback (user_id);
CREATE TABLE IF NOT EXISTS thread (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid varchar(40) NOT NULL UNIQUE,
parent_uid varchar(40) REFERENCES thread (uid) ON DELETE CASCADE ON UPDATE CASCADE,
owner_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
"schema" text,
text text NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_thread_parent_uid ON thread (parent_uid);
CREATE INDEX IF NOT EXISTS idx_thread_owner_user_id ON thread (owner_user_id);
CREATE TABLE IF NOT EXISTS user_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
uid varchar(40) NOT NULL UNIQUE,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
metadata jsonb,
text text NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_comments_user_id ON user_comments (user_id);
CREATE INDEX IF NOT EXISTS idx_user_comments_uid ON user_comments (uid);
CREATE TABLE IF NOT EXISTS user_fsentry_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
fsentry_id integer NOT NULL REFERENCES fsentries (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_fsentry_comments_user_comment_id ON user_fsentry_comments (user_comment_id);
CREATE INDEX IF NOT EXISTS idx_user_fsentry_comments_fsentry_id ON user_fsentry_comments (fsentry_id);
CREATE TABLE IF NOT EXISTS user_fsentry_version_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
fsentry_version_id integer NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_fsentry_version_comments_user_comment_id ON user_fsentry_version_comments (user_comment_id);
CREATE INDEX IF NOT EXISTS idx_user_fsentry_version_comments_version_id ON user_fsentry_version_comments (fsentry_version_id);
CREATE TABLE IF NOT EXISTS user_group_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
group_id integer NOT NULL REFERENCES "group" (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_group_comments_user_comment_id ON user_group_comments (user_comment_id);
CREATE INDEX IF NOT EXISTS idx_user_group_comments_group_id ON user_group_comments (group_id);
CREATE TABLE IF NOT EXISTS user_user_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_comment_id integer NOT NULL REFERENCES user_comments (id) ON DELETE CASCADE ON UPDATE CASCADE,
commented_user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_user_comments_user_comment_id ON user_user_comments (user_comment_id);
CREATE INDEX IF NOT EXISTS idx_user_user_comments_commented_user_id ON user_user_comments (commented_user_id);
CREATE TABLE IF NOT EXISTS user_oidc_providers (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
provider varchar(64) NOT NULL,
provider_sub varchar(255) NOT NULL,
refresh_token text,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_oidc_providers_user_id ON user_oidc_providers (user_id);
CREATE INDEX IF NOT EXISTS idx_user_oidc_providers_provider ON user_oidc_providers (provider);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_oidc_providers_provider_sub_unique ON user_oidc_providers (provider, provider_sub);
CREATE TABLE IF NOT EXISTS app_update_audit (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
app_id_keep integer NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
old_name varchar(50),
new_name varchar(50),
reason varchar(255)
);
CREATE INDEX IF NOT EXISTS idx_app_update_audit_app_id ON app_update_audit (app_id);
CREATE TABLE IF NOT EXISTS user_update_audit (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id_keep integer NOT NULL,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
old_email varchar(256),
new_email varchar(256),
old_username varchar(50),
new_username varchar(50),
reason varchar(255)
);
CREATE INDEX IF NOT EXISTS idx_user_update_audit_user_id ON user_update_audit (user_id);
CREATE TABLE IF NOT EXISTS storage_audit (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id_keep integer NOT NULL,
is_subtract boolean NOT NULL DEFAULT FALSE,
amount bigint NOT NULL,
field_a varchar(16),
field_b varchar(16),
reason varchar(255),
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_storage_audit_user_id ON storage_audit (user_id);
CREATE TABLE IF NOT EXISTS audit_user_to_app_permissions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id_keep integer NOT NULL,
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
app_id_keep integer NOT NULL,
permission varchar(255) NOT NULL,
extra jsonb,
action varchar(16),
reason varchar(255),
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_user_to_app_permissions_user_id ON audit_user_to_app_permissions (user_id);
CREATE INDEX IF NOT EXISTS idx_audit_user_to_app_permissions_app_id ON audit_user_to_app_permissions (app_id);
CREATE TABLE IF NOT EXISTS audit_dev_to_app_permissions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id_keep integer NOT NULL,
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
app_id_keep integer NOT NULL,
permission varchar(255) NOT NULL,
extra jsonb,
action varchar(16),
reason varchar(255),
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_dev_to_app_permissions_user_id ON audit_dev_to_app_permissions (user_id);
CREATE INDEX IF NOT EXISTS idx_audit_dev_to_app_permissions_app_id ON audit_dev_to_app_permissions (app_id);
CREATE TABLE IF NOT EXISTS audit_user_to_group_permissions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id_keep integer NOT NULL,
group_id integer REFERENCES "group" (id) ON DELETE SET NULL ON UPDATE CASCADE,
group_id_keep integer NOT NULL,
permission varchar(255) NOT NULL,
extra jsonb,
action varchar(255),
reason varchar(255),
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_user_to_group_permissions_user_id ON audit_user_to_group_permissions (user_id);
CREATE INDEX IF NOT EXISTS idx_audit_user_to_group_permissions_group_id ON audit_user_to_group_permissions (group_id);
CREATE TABLE IF NOT EXISTS audit_user_to_user_permissions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
issuer_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
issuer_user_id_keep integer NOT NULL,
holder_user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
holder_user_id_keep integer NOT NULL,
permission varchar(255) NOT NULL,
extra jsonb,
action varchar(16),
reason varchar(255),
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_audit_user_to_user_permissions_issuer_user_id ON audit_user_to_user_permissions (issuer_user_id);
CREATE INDEX IF NOT EXISTS idx_audit_user_to_user_permissions_holder_user_id ON audit_user_to_user_permissions (holder_user_id);
CREATE TABLE IF NOT EXISTS ai_usage (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
service_name varchar(64),
model_name varchar(128),
price_modifier varchar(40),
cost integer,
value_uint_1 integer,
value_uint_2 integer,
value_uint_3 integer,
value_uint_4 integer,
value_uint_5 integer,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ai_usage_app_id ON ai_usage (app_id);
CREATE INDEX IF NOT EXISTS idx_ai_usage_service_name ON ai_usage (service_name);
CREATE INDEX IF NOT EXISTS idx_ai_usage_model_name ON ai_usage (model_name);
CREATE INDEX IF NOT EXISTS idx_ai_usage_price_modifier ON ai_usage (price_modifier);
CREATE INDEX IF NOT EXISTS idx_ai_usage_created_at ON ai_usage (created_at);
CREATE INDEX IF NOT EXISTS idx_ai_usage_user_timestamp ON ai_usage (user_id, created_at);
CREATE TABLE IF NOT EXISTS general_analytics (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
uid varchar(40) NOT NULL,
trace_id varchar(40),
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
user_id_keep integer,
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
app_id_keep integer,
server_id varchar(40),
actor_type varchar(40),
tags jsonb,
fields jsonb
);
CREATE INDEX IF NOT EXISTS idx_general_analytics_user_id ON general_analytics (user_id);
CREATE INDEX IF NOT EXISTS idx_general_analytics_app_id ON general_analytics (app_id);
CREATE TABLE IF NOT EXISTS monthly_usage_counts (
year integer NOT NULL,
month integer NOT NULL,
service_type varchar(40) NOT NULL,
service_name varchar(40) NOT NULL,
actor_key varchar(255) NOT NULL,
pricing_category jsonb NOT NULL,
pricing_category_hash bytea NOT NULL,
"count" integer DEFAULT 0,
value_uint_1 integer,
value_uint_2 integer,
value_uint_3 integer,
PRIMARY KEY (year, month, service_type, service_name, actor_key, pricing_category_hash)
);
CREATE TABLE IF NOT EXISTS service_usage_monthly (
"key" varchar(255) NOT NULL,
year integer NOT NULL,
month integer NOT NULL,
user_id integer REFERENCES "user" (id) ON DELETE SET NULL ON UPDATE CASCADE,
app_id integer REFERENCES apps (id) ON DELETE SET NULL ON UPDATE CASCADE,
"count" integer NOT NULL,
extra jsonb,
PRIMARY KEY ("key", year, month)
);
CREATE INDEX IF NOT EXISTS idx_service_usage_monthly_user_id ON service_usage_monthly (user_id);
CREATE INDEX IF NOT EXISTS idx_service_usage_monthly_app_id ON service_usage_monthly (app_id);
CREATE TABLE IF NOT EXISTS per_user_credit (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL UNIQUE REFERENCES "user" (id) ON DELETE CASCADE ON UPDATE CASCADE,
amount bigint NOT NULL,
last_updated_at bigint NOT NULL
);
INSERT INTO "user" (id, uuid, username, metadata)
VALUES (1, '5d4adce0-a381-4982-9c02-6e2540026238', 'system', '{}'::jsonb)
ON CONFLICT (uuid) DO UPDATE SET username = EXCLUDED.username;
SELECT setval(pg_get_serial_sequence('"user"', 'id'), COALESCE((SELECT MAX(id) FROM "user"), 1), true);
INSERT INTO "group" (uid, owner_user_id, extra, metadata)
VALUES
('26bfb1fb-421f-45bc-9aa4-d81ea569e7a5', 1, '{"critical": true, "type": "default", "name": "system"}'::jsonb, '{"title": "System", "color": "#000000"}'::jsonb),
('ca342a5e-b13d-4dee-9048-58b11a57cc55', 1, '{"critical": true, "type": "default", "name": "admin"}'::jsonb, '{"title": "Admin", "color": "#a83232"}'::jsonb),
('78b1b1dd-c959-44d2-b02c-8735671f9997', 1, '{"critical": true, "type": "default", "name": "user"}'::jsonb, '{"title": "User", "color": "#3254a8"}'::jsonb),
('b7220104-7905-4985-b996-649fdcdb3c8f', 1, '{"critical": true, "type": "default", "name": "temp"}'::jsonb, '{"title": "Temp", "color": "#888888"}'::jsonb),
('3c2dfff7-d22a-41aa-a193-59a61dac4b64', 1, '{"type": "default", "name": "moderator"}'::jsonb, '{"title": "Moderator", "color": "#a432a8"}'::jsonb),
('5e8f251d-3382-4b0d-932c-7bb82f48652f', 1, '{"type": "default", "name": "developer"}'::jsonb, '{"title": "Developer", "color": "#32a852"}'::jsonb)
ON CONFLICT (uid) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
extra = EXCLUDED.extra,
metadata = EXCLUDED.metadata;
INSERT INTO apps (
uid, owner_user_id, icon, name, title, description, index_url,
godmode, maximize_on_start, background,
approved_for_listing, approved_for_opening_items,
approved_for_incentive_program, tags, "timestamp"
)
VALUES
('app-3920851d-bda8-479b-9407-8517293c7d44', 1, NULL, 'pdf', 'PDF', '', 'https://pdf.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-5584fbf7-ed69-41fc-99cd-85da21b1ef51', 1, NULL, 'camera', 'Camera', 'Camera in the browser.', 'https://online-camera.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-11edfba2-1ed3-4e22-8573-47e88fb87d70', 1, NULL, 'player', 'Player', 'A free video player app in the browser.', 'https://simple-player.puter.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-7bdca1a4-6373-4c98-ad97-03ff2d608ca1', 1, NULL, 'recorder', 'Recorder', 'Online voice recorder in the browser with cloud storage.', 'https://voice-recorder.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-d7e9471f-e441-4d72-a5ab-75e96573b76b', 1, NULL, 'music-player', 'Music Player', 'A free music player app in the browser.', 'https://player.puter.com', FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, NULL, '2026-05-10 00:00:00'),
('app-e3ac5486-da8c-42ad-8377-8728086e0980', 1, NULL, 'git', 'Git', 'Puter Git client', 'https://builtins.namespaces.puter.com/git', FALSE, FALSE, TRUE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-0b37f054-07d4-4627-8765-11bd23e889d4', 1, NULL, 'dev-center', 'Dev Center', 'This is the app that makes apps', 'https://builtins.namespaces.puter.com/dev-center', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-fbbdb72b-ad08-4cb4-86a1-de0f27cf2e1e', 1, NULL, 'puter-linux', 'Puter Linux', 'Linux emulator for Puter', 'https://builtins.namespaces.puter.com/emulator', TRUE, FALSE, FALSE, TRUE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-838dfbc4-bf8b-48c2-b47b-c4adc77fab58', 1, NULL, 'editor', 'Editor', 'Text editor', 'https://online-notepad.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-58282b08-990a-4906-95f7-fa37ff92452b', 1, NULL, 'draw', 'Draw', 'Image editor', 'https://draw.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'graphics', '2020-01-01 00:00:00'),
('app-0bef044f-918f-4cbf-a0c0-b4a17ee81085', 1, NULL, 'about', 'About', 'About Puter', 'https://about.puter.com', TRUE, FALSE, FALSE, FALSE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-a2ae72a4-1ba3-4a29-b5c0-6de1be5cf178', 1, NULL, 'app-center', 'App Center', 'Discover apps for Puter', 'https://app-center.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, NULL, '2020-01-01 00:00:00'),
('app-93005ce0-80d1-50d9-9b1e-9c453c375d56', 1, NULL, 'markus', 'Markus', 'Markdown editor', 'https://markus.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-6f79ef7b-52b7-4b31-91c6-fc07b62e9396', 1, NULL, 'code', 'Code', 'Code editor', 'https://code.puter.com', TRUE, TRUE, FALSE, TRUE, TRUE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-862fc09e-c7b8-4c30-b5b4-47b3cc5f5232', 1, NULL, 'memos', 'Memos', 'Notes and memos', 'https://memos.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-aeb8c03e-1144-4c57-bff5-3a1a7c17f9f0', 1, NULL, 'word-processor', 'Word Processor', 'Write documents', 'https://word-processor.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-c10a1999-22f5-4644-9960-9aaac0d4934e', 1, NULL, 'spreadsheet', 'Spreadsheet', 'Work with spreadsheets', 'https://spreadsheet.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-e9c1d58d-3d8d-4f0a-a85c-e383ef63bc29', 1, NULL, 'presentation', 'Presentation', 'Create presentations', 'https://presentation.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00'),
('app-fb7d9e42-8207-4fa0-b680-f0239327097f', 1, NULL, 'pdf-editor', 'PDF Editor', 'Edit PDF documents', 'https://pdf-editor.puter.com', FALSE, TRUE, FALSE, TRUE, FALSE, FALSE, 'productivity', '2020-01-01 00:00:00')
ON CONFLICT (uid) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
name = EXCLUDED.name,
title = EXCLUDED.title,
description = EXCLUDED.description,
index_url = EXCLUDED.index_url,
godmode = EXCLUDED.godmode,
maximize_on_start = EXCLUDED.maximize_on_start,
background = EXCLUDED.background,
approved_for_listing = EXCLUDED.approved_for_listing,
approved_for_opening_items = EXCLUDED.approved_for_opening_items,
approved_for_incentive_program = EXCLUDED.approved_for_incentive_program,
tags = EXCLUDED.tags;
INSERT INTO user_to_group_permissions (user_id, group_id, permission, extra)
SELECT u.id, g.id, 'driver', '{}'::jsonb
FROM "user" u, "group" g
WHERE u.username = 'system'
AND g.uid = 'ca342a5e-b13d-4dee-9048-58b11a57cc55'
ON CONFLICT (user_id, group_id, permission) DO UPDATE SET extra = EXCLUDED.extra;
@@ -0,0 +1,26 @@
-- 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/>.
-- SMS phone verification columns. Mirrors SQLite migration 0058. `phone` is the
-- E.164 number collected during verification (indexed like `email`);
-- `requires_phone_verification` gates account use for low-reputation signups
-- (not indexed, mirroring `requires_email_confirmation`).
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS phone varchar(20);
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_phone_verification boolean NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_user_phone ON "user" (phone);
@@ -0,0 +1,24 @@
-- 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/>.
-- Credit-card verification column. Mirrors SQLite migration 0059.
-- `requires_card_verification` gates account use for low-reputation signups;
-- the card itself never touches our DB, so this is the only column (not
-- indexed, mirroring `requires_phone_verification`).
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS requires_card_verification boolean NOT NULL DEFAULT FALSE;
@@ -0,0 +1,26 @@
-- 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/>.
-- Card fingerprint column. Mirrors SQLite migration 0060. `card_fingerprint`
-- is the Stripe card fingerprint (stable per card number) recorded when a user
-- clears card verification — the card sibling of `phone`, indexed like it so
-- admin tooling can find the accounts that verified with a given card. The card
-- itself never touches our DB, only Stripe's fingerprint for it.
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS card_fingerprint varchar(128);
CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON "user" (card_fingerprint);
@@ -0,0 +1,25 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Suspended-at column. Mirrors SQLite migration 0061. `suspended_at` is when
-- the account was suspended, as unix seconds (NULL while not suspended) — the
-- timestamp sibling of the boolean `suspended` flag, indexed so the
-- signup-abuse harness can count an IP's recently-suspended accounts.
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_at bigint;
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON "user" (suspended_at);
@@ -0,0 +1,36 @@
-- 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/>.
-- Admin-managed blocklist of app origins. Mirrors SQLite migration 0062 /
-- MySQL mysql_mig_17 (which Postgres was originally missed for). An app whose
-- `index_url` host (or a request origin) matches an entry is denied access to
-- Puter resources. `include_subdomains = 1` also blocks every subdomain of
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
--
-- Idempotent via IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS blocked_app_origins (
id SERIAL PRIMARY KEY,
domain VARCHAR(255) NOT NULL,
include_subdomains SMALLINT NOT NULL DEFAULT 0,
reason TEXT DEFAULT NULL,
created_by VARCHAR(255) DEFAULT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_blocked_app_origins_domain
ON blocked_app_origins (domain);
@@ -0,0 +1,24 @@
-- 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/>.
-- Suspension reason column. Mirrors SQLite migration 0063. Why an account was
-- suspended (NULL while not suspended) — companion to the boolean `suspended`
-- flag and `suspended_at` timestamp. Constrained at the application layer to a
-- fixed set of reasons (see extensions/admin suspension_reasons.js).
-- Idempotent via IF NOT EXISTS.
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS suspended_reason text;
@@ -0,0 +1,40 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Append-only log of admin moderation actions. Mirrors SQLite migration 0064 /
-- MySQL mysql_mig_19. Used to measure the abuse system's false-positive rate:
-- `unsuspend` (admin unblock) and `admin_create_user` are the false-positive
-- signals; `suspend` gives the denominator. Preserves history the `user`
-- suspension columns lose on unsuspend. `created_at` is unix seconds.
--
-- Idempotent via IF NOT EXISTS.
CREATE TABLE IF NOT EXISTS abuse_moderation_events (
id BIGSERIAL PRIMARY KEY,
action VARCHAR(32) NOT NULL,
target_user_id BIGINT DEFAULT NULL,
target_username VARCHAR(255) DEFAULT NULL,
admin_username VARCHAR(255) DEFAULT NULL,
reason TEXT DEFAULT NULL,
source VARCHAR(64) DEFAULT NULL,
created_at BIGINT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
ON abuse_moderation_events (created_at);
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
ON abuse_moderation_events (action);
@@ -15,14 +15,14 @@
-- 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/>.
-- AUTH-2 (PUT-1014) — composite-key lookups + audit columns.
-- Composite-key lookups + audit columns.
-- - `app_uid` : binds `kind='app'` rows to their app authorization
-- target. (user_id, app_uid) is the idempotency key.
-- - `legacy_token_uid` : keys lazy-backfilled rows to the v1 token_uid that
-- originally minted them.
-- - `created_via` : audit sentinel (e.g. 'legacy_backfill').
-- - `auth_id` : stable per-user identity that survives re-login
-- (PUT-1010); lets manage-sessions group by identity.
-- - `auth_id` : stable per-user identity that survives re-login;
-- lets manage-sessions group by identity.
ALTER TABLE `sessions` ADD COLUMN `app_uid` TEXT;
ALTER TABLE `sessions` ADD COLUMN `legacy_token_uid` TEXT;
@@ -0,0 +1,29 @@
-- 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/>.
-- Let `kind='access_token'` rows be reverse-looked-up
-- from the `token_uid` claim that lives only in `access_token_permissions`.
-- Required so `POST /auth/revoke-access-token` with a raw token_uid input
-- (no JWT) can find and soft-revoke the session row, matching the JWT
-- input path's coverage. Without it, raw-uuid revoke would only drop the
-- permissions row, leaving the session-row kill switch un-flipped.
ALTER TABLE `sessions` ADD COLUMN `access_token_uid` TEXT;
CREATE INDEX IF NOT EXISTS `idx_sessions_access_token_uid`
ON `sessions` (`access_token_uid`)
WHERE `access_token_uid` IS NOT NULL;
@@ -0,0 +1,30 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Worker session uniqueness. One active kind='worker' row per
-- (user_id, app_uid, worker_name).
--
-- SQLite UNIQUE indexes treat NULLs as distinct (per the SQL standard),
-- so user-scoped workers (where `app_uid` is NULL) would otherwise be
-- allowed to duplicate. IFNULL collapses NULL `app_uid` to empty
-- string in the index expression so two user-scoped workers with the
-- same worker_name correctly conflict. MySQL handles the same case
-- via the IFNULL in mig_11's generated column.
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_worker_active`
ON `sessions` (`user_id`, IFNULL(`app_uid`, ''), json_extract(`meta`, '$.worker_name'))
WHERE `kind` = 'worker' AND `revoked_at` IS NULL;
@@ -0,0 +1,31 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Enforce case-insensitive uniqueness on user.username.
--
-- SQLite defaults varchar columns to BINARY collation, so without this
-- index `Admin` and `admin` can coexist as separate rows even though the
-- reserved-name check and adminOnly gate both treat usernames as
-- case-insensitive. Prod MySQL already uses ascii_general_ci on this
-- column; this brings self-hosted SQLite to the same invariant.
--
-- If this CREATE fails because the DB already contains case-collision
-- duplicates, resolve them manually before re-running the migration —
-- there is no safe automatic merge of two user accounts.
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_username_nocase
ON user(username COLLATE NOCASE)
WHERE username IS NOT NULL;
@@ -0,0 +1,90 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Add 'worker' to the `kind` CHECK constraint on `sessions`. Mirrors the
-- MySQL ENUM extension in mysql_mig_12.
--
-- SQLite cannot ALTER a CHECK constraint in place, so we follow the
-- standard 12-step rebuild: create `sessions_new` with the corrected
-- constraint, copy rows, drop the old table, rename, then recreate every
-- index that lived on the original (indexes are auto-dropped with their
-- table).
CREATE TABLE `sessions_new` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"user_id" INTEGER NOT NULL,
"uuid" TEXT NOT NULL,
"meta" JSON DEFAULT NULL,
"created_at" INTEGER DEFAULT 0,
"last_activity" INTEGER DEFAULT 0,
"kind" TEXT NOT NULL DEFAULT 'web'
CHECK (`kind` IN ('web', 'app', 'access_token', 'asset', 'worker')),
"label" TEXT,
"parent_session_id" TEXT,
"last_ip" TEXT,
"last_user_agent" TEXT,
"revoked_at" INTEGER,
"expires_at" INTEGER,
"app_uid" TEXT,
"legacy_token_uid" TEXT,
"created_via" TEXT,
"auth_id" TEXT,
"access_token_uid" TEXT,
FOREIGN KEY("user_id") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO `sessions_new` (
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
`created_via`, `auth_id`, `access_token_uid`
)
SELECT
`id`, `user_id`, `uuid`, `meta`, `created_at`, `last_activity`,
`kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`,
`revoked_at`, `expires_at`, `app_uid`, `legacy_token_uid`,
`created_via`, `auth_id`, `access_token_uid`
FROM `sessions`;
DROP TABLE `sessions`;
ALTER TABLE `sessions_new` RENAME TO `sessions`;
CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked`
ON `sessions` (`user_id`, `revoked_at`);
CREATE INDEX IF NOT EXISTS `idx_sessions_parent`
ON `sessions` (`parent_session_id`);
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_app_active`
ON `sessions` (`user_id`, `app_uid`)
WHERE `kind` = 'app' AND `revoked_at` IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_legacy_token_active`
ON `sessions` (`legacy_token_uid`)
WHERE `legacy_token_uid` IS NOT NULL AND `revoked_at` IS NULL;
CREATE INDEX IF NOT EXISTS `idx_sessions_kind_user`
ON `sessions` (`kind`, `user_id`);
CREATE INDEX IF NOT EXISTS `idx_sessions_access_token_uid`
ON `sessions` (`access_token_uid`)
WHERE `access_token_uid` IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_worker_active`
ON `sessions` (`user_id`, IFNULL(`app_uid`, ''), json_extract(`meta`, '$.worker_name'))
WHERE `kind` = 'worker' AND `revoked_at` IS NULL;
@@ -0,0 +1,21 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Abuse v2 reputation score (0–100+) recorded at signup. Mirrors the
-- `reputation smallint DEFAULT 100` column already present in the mysql and
-- postgres baseline migrations.
ALTER TABLE `user` ADD COLUMN `reputation` SMALLINT DEFAULT 100;
@@ -0,0 +1,25 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- SMS phone verification. `phone` holds the E.164 number collected during
-- verification (indexed like `email`). `requires_phone_verification` gates
-- account use until verified — the abuse v2 harness sets it for low-reputation
-- signups instead of blocking them. (Not indexed, mirroring
-- `requires_email_confirmation`.)
ALTER TABLE `user` ADD COLUMN `phone` varchar(20) DEFAULT NULL;
ALTER TABLE `user` ADD COLUMN `requires_phone_verification` tinyint(1) NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_user_phone ON `user` (`phone`);
@@ -0,0 +1,23 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Credit-card verification ($0 authorization handled by a payments
-- extension). `requires_card_verification` gates account use until verified —
-- the abuse v2 harness sets it for low-reputation signups instead of blocking
-- them. The card itself never touches our DB, so this is the only column.
-- (Not indexed, mirroring `requires_phone_verification`.)
ALTER TABLE `user` ADD COLUMN `requires_card_verification` tinyint(1) NOT NULL DEFAULT 0;
@@ -0,0 +1,25 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Card fingerprint column. `card_fingerprint` is the Stripe card fingerprint
-- (stable per card number) recorded when a user clears card verification — the
-- card sibling of `phone`. Indexed like `phone` so admin tooling can find the
-- accounts that verified with a given card. The card itself never touches our
-- DB, only Stripe's fingerprint for it. Server-only (not on the whoami
-- allowlist), so it never reaches clients.
ALTER TABLE `user` ADD COLUMN `card_fingerprint` varchar(128) DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_user_card_fingerprint ON `user` (`card_fingerprint`);
@@ -0,0 +1,23 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- When the account was suspended, as unix seconds; NULL while not suspended.
-- The timestamp sibling of the existing boolean `suspended` flag, stamped by
-- every suspension site. Indexed so the signup-abuse harness can count an IP's
-- recently-suspended accounts (see extensions/abuse/v2 suspendedAccountsByIp).
ALTER TABLE `user` ADD COLUMN `suspended_at` INTEGER DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_user_suspended_at ON `user` (`suspended_at`);
@@ -0,0 +1,34 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Admin-managed blocklist of app origins. An app whose `index_url` host (or
-- a request origin) matches an entry is denied access to Puter resources:
-- it cannot obtain an app token and already-issued app tokens are rejected
-- on each request. `include_subdomains = 1` also blocks every subdomain of
-- `domain`. Enforced in AuthService via AppOriginBlocklistService.
CREATE TABLE IF NOT EXISTS `blocked_app_origins` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"domain" TEXT NOT NULL,
"include_subdomains" INTEGER NOT NULL DEFAULT 0,
"reason" TEXT DEFAULT NULL,
"created_by" TEXT DEFAULT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS `idx_blocked_app_origins_domain`
ON `blocked_app_origins` (`domain`);
@@ -0,0 +1,23 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Why an account was suspended, NULL while not suspended. Companion to the
-- boolean `suspended` flag and `suspended_at` timestamp. Constrained at the
-- application layer to a fixed set of reasons (see extensions/admin
-- suspension_reasons.js) — kept as free TEXT here so adding a reason later
-- needs no migration.
ALTER TABLE `user` ADD COLUMN `suspended_reason` TEXT DEFAULT NULL;
@@ -0,0 +1,43 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Append-only log of admin moderation actions, used to measure the abuse
-- system's false-positive rate: `unsuspend` (admin unblock) and
-- `admin_create_user` (an account minted by an admin, typically to recover a
-- legitimately-blocked signup) are the false-positive signals; `suspend` gives
-- the denominator. Unlike the `suspended`/`suspended_at`/`suspended_reason`
-- columns on `user` — which are cleared on unsuspend and so keep no history —
-- this table preserves every event. `created_at` is unix seconds (matching
-- `user.suspended_at`), stamped by the recorder in
-- extensions/admin/moderation_events.js. Written best-effort at the admin
-- suspend/unsuspend/create-user sites; surfaced on the /admin/abuse dashboard.
CREATE TABLE IF NOT EXISTS `abuse_moderation_events` (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"action" TEXT NOT NULL, -- 'suspend' | 'unsuspend' | 'admin_create_user'
"target_user_id" INTEGER DEFAULT NULL,
"target_username" TEXT DEFAULT NULL,
"admin_username" TEXT DEFAULT NULL, -- the acting admin (req.actor.user.username)
"reason" TEXT DEFAULT NULL,
"source" TEXT DEFAULT NULL, -- 'user_page' | 'email_hostname_blacklist' | 'create_user'
"created_at" INTEGER NOT NULL -- unix seconds
);
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_created_at
ON `abuse_moderation_events` (`created_at`);
CREATE INDEX IF NOT EXISTS idx_abuse_moderation_events_action
ON `abuse_moderation_events` (`action`);
@@ -0,0 +1,85 @@
/*
* 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 { describe, expect, it } from 'vitest';
import { preparePostgresSql } from './preparePostgresSql.js';
describe('preparePostgresSql', () => {
it('converts placeholders and backtick identifiers outside SQL literals', () => {
const prepared = preparePostgresSql(
'SELECT `user`.`id` FROM `user` WHERE `email` = ? AND `username` = ?',
);
expect(prepared).toEqual({
text: 'SELECT "user"."id" FROM "user" WHERE "email" = $1 AND "username" = $2',
parameterCount: 2,
});
});
it('leaves question marks inside strings and comments untouched', () => {
const prepared = preparePostgresSql(`
SELECT '?' AS literal, ?
-- ? in a comment
FROM \`apps\`
WHERE \`description\` = 'is this ok?'
/* and ? in a block comment */
AND \`name\` = ?
`);
expect(prepared.text).toContain("SELECT '?' AS literal, $1");
expect(prepared.text).toContain('-- ? in a comment');
expect(prepared.text).toContain('"description" = \'is this ok?\'');
expect(prepared.text).toContain('/* and ? in a block comment */');
expect(prepared.text).toContain('"name" = $2');
expect(prepared.parameterCount).toBe(2);
});
it('escapes double quotes inside converted identifiers', () => {
const prepared = preparePostgresSql(
'SELECT `odd"name` FROM `odd``table` WHERE `id` = ?',
);
expect(prepared).toEqual({
text: 'SELECT "odd""name" FROM "odd`table" WHERE "id" = $1',
parameterCount: 1,
});
});
it('does not scan placeholders inside dollar-quoted strings', () => {
const prepared = preparePostgresSql(
"SELECT $$?$$, $tag$`not_ident` ?$tag$, `id` FROM `user` WHERE `id` = ?",
);
expect(prepared).toEqual({
text: 'SELECT $$?$$, $tag$`not_ident` ?$tag$, "id" FROM "user" WHERE "id" = $1',
parameterCount: 1,
});
});
it('does not treat Postgres JSON operators as comments', () => {
const prepared = preparePostgresSql(
"SELECT * FROM `sessions` WHERE `user_id` = ? AND `meta` #>> ARRAY['worker_name'] = ? AND `expires_at` > ?",
);
expect(prepared).toEqual({
text: 'SELECT * FROM "sessions" WHERE "user_id" = $1 AND "meta" #>> ARRAY[\'worker_name\'] = $2 AND "expires_at" > $3',
parameterCount: 3,
});
});
});
@@ -0,0 +1,169 @@
/*
* 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/>.
*/
export interface PreparedPostgresSql {
text: string;
parameterCount: number;
}
type ScannerState =
| { kind: 'normal' }
| { kind: 'singleQuote' }
| { kind: 'doubleQuote' }
| { kind: 'backtickIdentifier' }
| { kind: 'lineComment' }
| { kind: 'blockComment' }
| { kind: 'dollarQuote'; tag: string };
const isLineCommentStart = (sql: string, index: number): boolean => {
if (sql[index] !== '-' || sql[index + 1] !== '-') return false;
const after = sql[index + 2];
return after === undefined || /\s/u.test(after);
};
const readDollarQuoteTag = (sql: string, index: number): string | null => {
const match = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/u.exec(sql.slice(index));
return match?.[0] ?? null;
};
export const preparePostgresSql = (sql: string): PreparedPostgresSql => {
let state: ScannerState = { kind: 'normal' };
let parameterCount = 0;
let out = '';
for (let i = 0; i < sql.length; i++) {
const char = sql[i]!;
const next = sql[i + 1];
switch (state.kind) {
case 'normal': {
const dollarTag =
char === '$' ? readDollarQuoteTag(sql, i) : null;
if (dollarTag) {
out += dollarTag;
i += dollarTag.length - 1;
state = { kind: 'dollarQuote', tag: dollarTag };
break;
}
if (char === "'") {
out += char;
state = { kind: 'singleQuote' };
break;
}
if (char === '"') {
out += char;
state = { kind: 'doubleQuote' };
break;
}
if (char === '`') {
out += '"';
state = { kind: 'backtickIdentifier' };
break;
}
if (isLineCommentStart(sql, i)) {
out += char;
if (char === '-') {
out += next ?? '';
i += 1;
}
state = { kind: 'lineComment' };
break;
}
if (char === '/' && next === '*') {
out += '/*';
i += 1;
state = { kind: 'blockComment' };
break;
}
if (char === '?') {
parameterCount += 1;
out += `$${parameterCount}`;
break;
}
out += char;
break;
}
case 'singleQuote':
out += char;
if (char === "'" && next === "'") {
out += next;
i += 1;
} else if (char === '\\' && next !== undefined) {
out += next;
i += 1;
} else if (char === "'") {
state = { kind: 'normal' };
}
break;
case 'doubleQuote':
out += char;
if (char === '"' && next === '"') {
out += next;
i += 1;
} else if (char === '"') {
state = { kind: 'normal' };
}
break;
case 'backtickIdentifier':
if (char === '`' && next === '`') {
out += '`';
i += 1;
} else if (char === '`') {
out += '"';
state = { kind: 'normal' };
} else if (char === '"') {
out += '""';
} else {
out += char;
}
break;
case 'lineComment':
out += char;
if (char === '\n') {
state = { kind: 'normal' };
}
break;
case 'blockComment':
out += char;
if (char === '*' && next === '/') {
out += '/';
i += 1;
state = { kind: 'normal' };
}
break;
case 'dollarQuote':
if (sql.startsWith(state.tag, i)) {
out += state.tag;
i += state.tag.length - 1;
state = { kind: 'normal' };
} else {
out += char;
}
break;
}
}
return { text: out, parameterCount };
};
@@ -0,0 +1,76 @@
/*
* 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/>.
*/
/** Error code set by the pool-acquisition timeout in SQLBatcher. */
export const POOL_ACQUIRE_TIMEOUT = 'POOL_ACQUIRE_TIMEOUT';
const RETRIABLE_ERROR_CODES = new Set([
'PROTOCOL_CONNECTION_LOST',
'PROTOCOL_SEQUENCE_TIMEOUT',
'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR',
'ECONNRESET',
'ETIMEDOUT',
'EPIPE',
'ECONNREFUSED',
'EHOSTUNREACH',
'ENETUNREACH',
'EAI_AGAIN',
POOL_ACQUIRE_TIMEOUT,
]);
const RETRIABLE_ERROR_MESSAGES = [
'Connection lost',
'read ECONNRESET',
'ETIMEDOUT',
];
/**
* Failures where the statement provably never reached the server, so a retry
* can never double-apply it — safe even for writes. Anything that can occur
* after the statement was sent (resets, protocol drops) is deliberately
* excluded: the server may have committed before the connection died.
*/
const NEVER_SENT_ERROR_CODES = new Set([
'ECONNREFUSED',
'EHOSTUNREACH',
'ENETUNREACH',
'EAI_AGAIN',
POOL_ACQUIRE_TIMEOUT,
]);
const errorCode = (error: unknown): string | undefined =>
(error as { code?: string } | null)?.code;
/**
* Transient connection-level failures worth retrying — but only for statements
* that are safe to run twice (reads). Row-level errors (duplicate key,
* constraint violations) are deterministic and never match.
*/
export const isRetriableError = (error: unknown): boolean => {
const code = errorCode(error);
if (code && RETRIABLE_ERROR_CODES.has(code)) return true;
const msg = String((error as Error)?.message ?? '');
return RETRIABLE_ERROR_MESSAGES.some((m) => msg.includes(m));
};
export const isNeverSentError = (error: unknown): boolean => {
const code = errorCode(error);
return Boolean(code && NEVER_SENT_ERROR_CODES.has(code));
};
@@ -1,4 +1,4 @@
/**
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
@@ -1,4 +1,4 @@
/**
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
@@ -20,12 +20,11 @@
/**
* DELIMITER-aware splitter for MySQL dump / migration files.
*
* Splits `sql` into individual statements using the active statement
* delimiter (default `;`). Recognises `DELIMITER X` lines, single-quoted
* strings, double-quoted strings, backtick-quoted identifiers, line
* comments (`-- `, `#`) and block comments (`/* ... *\/`). DELIMITER
* directives are stripped from the output (they're a client-side concept,
* not server SQL).
* Splits `sql` into individual statements using the active statement delimiter
* (default `;`). Recognises `DELIMITER X` lines, single-quoted strings,
* double-quoted strings, backtick-quoted identifiers, line comments (`-- `,
* `#`) and block comments (`/* ... *\/`). DELIMITER directives are stripped
* from the output (they're a client-side concept, not server SQL).
*
* Returns trimmed, non-empty statements without the trailing delimiter.
*/
@@ -0,0 +1,116 @@
/**
* 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 { describe, expect, it } from 'vitest';
import { splitPostgresStatements } from './splitPostgresStatements';
describe('splitPostgresStatements', () => {
it('splits on semicolons and trims each statement', () => {
expect(
splitPostgresStatements('SELECT 1;\n SELECT 2 ;\nSELECT 3'),
).toEqual(['SELECT 1', 'SELECT 2', 'SELECT 3']);
});
it('drops empty statements from stray semicolons', () => {
expect(splitPostgresStatements(';;SELECT 1;;')).toEqual(['SELECT 1']);
});
it('returns nothing for whitespace-only input', () => {
expect(splitPostgresStatements(' \n\t ')).toEqual([]);
});
it('keeps semicolons inside single-quoted literals', () => {
expect(
splitPostgresStatements("INSERT INTO t VALUES ('a;b'); SELECT 1"),
).toEqual(["INSERT INTO t VALUES ('a;b')", 'SELECT 1']);
});
it('handles doubled and backslash-escaped quotes in literals', () => {
expect(
splitPostgresStatements("SELECT 'it''s; fine'; SELECT 2"),
).toEqual(["SELECT 'it''s; fine'", 'SELECT 2']);
expect(splitPostgresStatements("SELECT E'a\\'; b'; SELECT 2")).toEqual([
"SELECT E'a\\'; b'",
'SELECT 2',
]);
});
it('keeps semicolons inside quoted identifiers', () => {
expect(
splitPostgresStatements('SELECT "we;ird" FROM t; SELECT 2'),
).toEqual(['SELECT "we;ird" FROM t', 'SELECT 2']);
});
it('handles a doubled quote inside a quoted identifier', () => {
expect(
splitPostgresStatements('SELECT "we""ird;x" FROM t; SELECT 2'),
).toEqual(['SELECT "we""ird;x" FROM t', 'SELECT 2']);
});
it('ignores semicolons inside line comments', () => {
expect(
splitPostgresStatements('SELECT 1 -- trailing; note\n; SELECT 2'),
).toEqual(['SELECT 1 -- trailing; note', 'SELECT 2']);
});
it('ignores semicolons inside block comments', () => {
expect(
splitPostgresStatements('SELECT /* a; b */ 1; SELECT 2'),
).toEqual(['SELECT /* a; b */ 1', 'SELECT 2']);
});
it('keeps a whole dollar-quoted function body together', () => {
const sql = [
'CREATE FUNCTION bump() RETURNS trigger AS $$',
'BEGIN',
' NEW.updated_at := now();',
' RETURN NEW;',
'END;',
'$$ LANGUAGE plpgsql;',
'SELECT 1',
].join('\n');
const statements = splitPostgresStatements(sql);
expect(statements).toHaveLength(2);
expect(statements[0]).toContain('RETURN NEW;');
expect(statements[0].endsWith('$$ LANGUAGE plpgsql')).toBe(true);
expect(statements[1]).toBe('SELECT 1');
});
it('respects a tagged dollar quote and its nested untagged pair', () => {
const sql = 'SELECT $body$ inner $$ still; inside $body$; SELECT 2';
expect(splitPostgresStatements(sql)).toEqual([
'SELECT $body$ inner $$ still; inside $body$',
'SELECT 2',
]);
});
it('treats a bare dollar sign as ordinary text', () => {
expect(splitPostgresStatements('SELECT 1 $ 2; SELECT 3')).toEqual([
'SELECT 1 $ 2',
'SELECT 3',
]);
});
it('emits an unterminated final statement', () => {
expect(
splitPostgresStatements('SELECT 1; SELECT 2 -- no newline'),
).toEqual(['SELECT 1', 'SELECT 2 -- no newline']);
});
});
@@ -0,0 +1,120 @@
/*
* 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/>.
*/
type ScannerState =
| { kind: 'normal' }
| { kind: 'singleQuote' }
| { kind: 'doubleQuote' }
| { kind: 'lineComment' }
| { kind: 'blockComment' }
| { kind: 'dollarQuote'; tag: string };
const readDollarQuoteTag = (sql: string, index: number): string | null => {
const match = /^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/u.exec(sql.slice(index));
return match?.[0] ?? null;
};
export const splitPostgresStatements = (sql: string): string[] => {
const statements: string[] = [];
let state: ScannerState = { kind: 'normal' };
let start = 0;
for (let i = 0; i < sql.length; i++) {
const char = sql[i]!;
const next = sql[i + 1];
switch (state.kind) {
case 'normal': {
const dollarTag =
char === '$' ? readDollarQuoteTag(sql, i) : null;
if (dollarTag) {
i += dollarTag.length - 1;
state = { kind: 'dollarQuote', tag: dollarTag };
break;
}
if (char === "'") {
state = { kind: 'singleQuote' };
break;
}
if (char === '"') {
state = { kind: 'doubleQuote' };
break;
}
if (char === '-' && next === '-') {
i += 1;
state = { kind: 'lineComment' };
break;
}
if (char === '/' && next === '*') {
i += 1;
state = { kind: 'blockComment' };
break;
}
if (char === ';') {
const statement = sql.slice(start, i).trim();
if (statement !== '') statements.push(statement);
start = i + 1;
}
break;
}
case 'singleQuote':
if (char === "'" && next === "'") {
i += 1;
} else if (char === '\\' && next !== undefined) {
i += 1;
} else if (char === "'") {
state = { kind: 'normal' };
}
break;
case 'doubleQuote':
if (char === '"' && next === '"') {
i += 1;
} else if (char === '"') {
state = { kind: 'normal' };
}
break;
case 'lineComment':
if (char === '\n') {
state = { kind: 'normal' };
}
break;
case 'blockComment':
if (char === '*' && next === '/') {
i += 1;
state = { kind: 'normal' };
}
break;
case 'dollarQuote':
if (sql.startsWith(state.tag, i)) {
i += state.tag.length - 1;
state = { kind: 'normal' };
}
break;
}
}
const finalStatement = sql.slice(start).trim();
if (finalStatement !== '') statements.push(finalStatement);
return statements;
};
@@ -0,0 +1,531 @@
/**
* 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 { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { IConfig } from '../../types';
import { DDBClient } from './DDBClient';
const TABLE = 'kv-items';
const GSI = 'by-kind';
const localConfig = (): IConfig =>
({
port: 0,
extensions: [],
dynamo: { inMemory: true, bootstrapTables: true },
}) as unknown as IConfig;
/**
* The emulator reports a table as CREATING for a tick after `CreateTable`
* returns, so wait until it actually answers a read before using it.
*/
const awaitTable = async (
client: DDBClient,
table: string,
key: Record<string, unknown>,
): Promise<void> => {
for (let attempt = 0; attempt < 100; attempt++) {
try {
await client.get(table, key);
return;
} catch (error) {
if ((error as Error).name !== 'ResourceNotFoundException')
throw error;
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
throw new Error(`table ${table} never became queryable`);
};
const tableSchema = {
TableName: TABLE,
KeySchema: [
{ AttributeName: 'pk', KeyType: 'HASH' as const },
{ AttributeName: 'sk', KeyType: 'RANGE' as const },
],
AttributeDefinitions: [
{ AttributeName: 'pk', AttributeType: 'S' as const },
{ AttributeName: 'sk', AttributeType: 'S' as const },
{ AttributeName: 'kind', AttributeType: 'S' as const },
],
GlobalSecondaryIndexes: [
{
IndexName: GSI,
KeySchema: [{ AttributeName: 'kind', KeyType: 'HASH' as const }],
Projection: { ProjectionType: 'ALL' as const },
ProvisionedThroughput: {
ReadCapacityUnits: 5,
WriteCapacityUnits: 5,
},
},
],
ProvisionedThroughput: { ReadCapacityUnits: 5, WriteCapacityUnits: 5 },
};
describe('DDBClient — item operations', () => {
let client: DDBClient;
beforeAll(async () => {
client = new DDBClient(localConfig());
await client.createTableIfNotExists(tableSchema);
await awaitTable(client, TABLE, { pk: 'ready', sk: 'ready' });
});
it('round-trips an item and reports consumed capacity', async () => {
await client.put(TABLE, { pk: 'u1', sk: 'profile', name: 'ada' });
const result = await client.get(TABLE, { pk: 'u1', sk: 'profile' });
expect(result.Item).toEqual({ pk: 'u1', sk: 'profile', name: 'ada' });
expect(result.ConsumedCapacity?.TableName).toBe(TABLE);
});
it('returns no Item for a key that does not exist', async () => {
const result = await client.get(TABLE, { pk: 'nobody', sk: 'here' });
expect(result.Item).toBeUndefined();
});
it('honours a strongly consistent read', async () => {
await client.put(TABLE, { pk: 'u2', sk: 'profile', name: 'grace' });
const result = await client.get(
TABLE,
{ pk: 'u2', sk: 'profile' },
true,
);
expect(result.Item?.name).toBe('grace');
});
it('applies an update expression and returns the new item', async () => {
await client.put(TABLE, { pk: 'u3', sk: 'counter', hits: 1 });
const updated = await client.update(
TABLE,
{ pk: 'u3', sk: 'counter' },
'SET #h = :next',
{ ':next': 5 },
{ '#h': 'hits' },
);
expect(updated.Attributes).toMatchObject({ hits: 5 });
});
it('supports an update expression with no values or names', async () => {
await client.put(TABLE, { pk: 'u4', sk: 'doc', stale: true });
const updated = await client.update(
TABLE,
{ pk: 'u4', sk: 'doc' },
'REMOVE stale',
);
expect(updated.Attributes).toEqual({ pk: 'u4', sk: 'doc' });
});
it('deletes an item', async () => {
await client.put(TABLE, { pk: 'u5', sk: 'temp' });
await client.del(TABLE, { pk: 'u5', sk: 'temp' });
const result = await client.get(TABLE, { pk: 'u5', sk: 'temp' });
expect(result.Item).toBeUndefined();
});
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 });
const result = await client.batchGet([
{ table: TABLE, items: { pk: 'b1', sk: 'a' } },
{ table: TABLE, items: { pk: 'b1', sk: 'b' } },
]);
const values = (result.Responses?.[TABLE] ?? [])
.map((item) => item.v as number)
.sort();
expect(values).toEqual([1, 2]);
});
});
describe('DDBClient — queries', () => {
let client: DDBClient;
beforeAll(async () => {
client = new DDBClient(localConfig());
await client.createTableIfNotExists(tableSchema);
await awaitTable(client, TABLE, { pk: 'ready', sk: 'ready' });
for (const sk of ['post#1', 'post#2', 'post#3', 'note#1']) {
await client.put(TABLE, {
pk: 'q1',
sk,
kind: sk.split('#')[0],
size: sk === 'post#2' ? 10 : 1,
});
}
});
it('queries by partition key', async () => {
const result = await client.query(TABLE, { pk: 'q1' });
expect(result.Items).toHaveLength(4);
expect(result.ConsumedCapacity?.TableName).toBe(TABLE);
});
it('narrows the sort key with begins_with', async () => {
const result = await client.query(
TABLE,
{ pk: 'q1' },
0,
undefined,
'',
false,
{ beginsWith: { key: 'sk', value: 'post#' } },
);
expect(result.Items?.map((item) => item.sk)).toEqual([
'post#1',
'post#2',
'post#3',
]);
});
it('ignores an empty begins_with value', async () => {
const result = await client.query(
TABLE,
{ pk: 'q1' },
0,
undefined,
'',
false,
{ beginsWith: { key: 'sk', value: '' } },
);
expect(result.Items).toHaveLength(4);
});
it('applies a filter expression with its own names and values', async () => {
const result = await client.query(
TABLE,
{ pk: 'q1' },
0,
undefined,
'',
false,
{
filter: {
expression: '#s > :min',
names: { '#s': 'size' },
values: { ':min': 5 },
},
},
);
expect(result.Items?.map((item) => item.sk)).toEqual(['post#2']);
});
it('counts without returning items', async () => {
const result = await client.query(
TABLE,
{ pk: 'q1' },
0,
undefined,
'',
false,
{ select: 'COUNT' },
);
expect(result.Count).toBe(4);
expect(result.Items).toBeUndefined();
});
it('pages through results with the last evaluated key', async () => {
const seen: string[] = [];
let pageKey: Record<string, unknown> | undefined;
let pages = 0;
do {
const page = await client.query(TABLE, { pk: 'q1' }, 2, pageKey);
pages += 1;
expect(page.Items?.length).toBeLessThanOrEqual(2);
for (const item of page.Items ?? []) seen.push(item.sk as string);
pageKey = page.LastEvaluatedKey;
} while (pageKey && pages < 10);
expect(pages).toBeGreaterThan(1);
expect(new Set(seen).size).toBe(4);
});
it('queries a secondary index', async () => {
const result = await client.query(
TABLE,
{ kind: 'note' },
0,
undefined,
GSI,
);
expect(result.Items?.map((item) => item.sk)).toEqual(['note#1']);
});
});
describe('DDBClient — batch writes', () => {
let client: DDBClient;
beforeAll(async () => {
client = new DDBClient(localConfig());
await client.createTableIfNotExists(tableSchema);
await awaitTable(client, TABLE, { pk: 'ready', sk: 'ready' });
});
it('reports no consumed capacity for an empty batch', async () => {
await expect(client.batchPut([])).resolves.toEqual({
ConsumedCapacity: [],
});
});
it('splits a batch larger than the service limit into chunks', async () => {
const params = Array.from({ length: 60 }, (_, index) => ({
table: TABLE,
item: { pk: 'bulk', sk: `item-${index}`, index },
}));
const result = await client.batchPut(params);
expect(result.ConsumedCapacity).toHaveLength(1);
expect(result.ConsumedCapacity[0].TableName).toBe(TABLE);
expect(result.ConsumedCapacity[0].CapacityUnits).toBeGreaterThan(0);
const stored = await client.query(TABLE, { pk: 'bulk' });
expect(stored.Items).toHaveLength(60);
});
});
describe('DDBClient — expired item sweep', () => {
it('deletes only the items past their ttl when the table already existed', async () => {
const client = new DDBClient(localConfig());
const table = 'ttl-items';
const schema = {
TableName: table,
KeySchema: [{ AttributeName: 'pk', KeyType: 'HASH' as const }],
AttributeDefinitions: [
{ AttributeName: 'pk', AttributeType: 'S' as const },
],
ProvisionedThroughput: {
ReadCapacityUnits: 5,
WriteCapacityUnits: 5,
},
};
await client.createTableIfNotExists(schema, 'expireAt');
await awaitTable(client, table, { pk: 'ready' });
const now = Math.floor(Date.now() / 1000);
await client.put(table, { pk: 'stale', expireAt: now - 60 });
await client.put(table, { pk: 'fresh', expireAt: now + 3600 });
await client.put(table, { pk: 'eternal' });
// Second call finds the table in use, so the sweep runs.
await client.createTableIfNotExists(schema, 'expireAt');
expect((await client.get(table, { pk: 'stale' })).Item).toBeUndefined();
expect((await client.get(table, { pk: 'fresh' })).Item).toBeDefined();
expect((await client.get(table, { pk: 'eternal' })).Item).toBeDefined();
});
});
describe('DDBClient — expired item sweep on an empty table', () => {
it('completes without deleting anything', async () => {
const client = new DDBClient(localConfig());
const table = 'ttl-empty';
const schema = {
TableName: table,
KeySchema: [{ AttributeName: 'pk', KeyType: 'HASH' as const }],
AttributeDefinitions: [
{ AttributeName: 'pk', AttributeType: 'S' as const },
],
ProvisionedThroughput: {
ReadCapacityUnits: 5,
WriteCapacityUnits: 5,
},
};
await client.createTableIfNotExists(schema, 'expireAt');
await awaitTable(client, table, { pk: 'ready' });
await expect(
client.createTableIfNotExists(schema, 'expireAt'),
).resolves.toBeUndefined();
});
});
describe('DDBClient — credentialled configuration', () => {
it('refuses an aws config that is missing a key', () => {
expect(
() =>
new DDBClient({
port: 0,
extensions: [],
dynamo: { aws: { access_key: 'only-one' } },
} as unknown as IConfig),
).toThrow('requires both `access_key` and `secret_key`');
});
it('skips table creation on a managed deployment', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const client = new DDBClient({
port: 0,
extensions: [],
dynamo: {
aws: { access_key: 'a', secret_key: 'b', region: 'us-west-2' },
endpoint: 'http://127.0.0.1:1',
},
} as unknown as IConfig);
// Would fail against the unreachable endpoint if it actually tried.
await client.createTableIfNotExists(tableSchema);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
'Creating DynamoDB tables is disabled by default',
),
);
warn.mockRestore();
});
it('rebinds an aws-credentialled client on recreate', async () => {
const client = new DDBClient({
port: 0,
extensions: [],
dynamo: {
aws: { access_key: 'a', secret_key: 'b' },
endpoint: 'http://127.0.0.1:1',
},
} as unknown as IConfig);
await expect(client.recreateClient()).resolves.toBeUndefined();
});
it('rebinds the underlying client on recreate', async () => {
const client = new DDBClient(localConfig());
await client.createTableIfNotExists(tableSchema);
await awaitTable(client, TABLE, { pk: 'ready', sk: 'ready' });
await client.put(TABLE, { pk: 'recreate', sk: 'a', v: 1 });
await client.recreateClient();
// Same in-memory store: the recreated client sees earlier writes.
const result = await client.get(TABLE, { pk: 'recreate', sk: 'a' });
expect(result.Item?.v).toBe(1);
});
});
// Unprocessed items are a throttling signal the emulator never produces, so
// the retry loop is driven against a stub speaking the DynamoDB wire format.
describe('DDBClient — unprocessed batch items', () => {
let server: Server;
let endpoint: string;
let responses: unknown[];
let requestCount = 0;
beforeAll(async () => {
server = createServer((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
const next = responses.shift() ?? {};
requestCount += 1;
res.writeHead(200, {
'content-type': 'application/x-amz-json-1.0',
});
res.end(JSON.stringify(next));
});
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve),
);
endpoint = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
const stubClient = () =>
new DDBClient({
port: 0,
extensions: [],
dynamo: {
aws: { access_key: 'a', secret_key: 'b', region: 'us-west-2' },
endpoint,
},
} as unknown as IConfig);
const unprocessed = (pk: string) => ({
UnprocessedItems: {
[TABLE]: [{ PutRequest: { Item: { pk: { S: pk } } } }],
},
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 1 }],
});
it('retries the leftover items and sums capacity across attempts', async () => {
requestCount = 0;
responses = [
unprocessed('retry-me'),
{
UnprocessedItems: {},
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 2 }],
},
];
const result = await stubClient().batchPut([
{ table: TABLE, item: { pk: 'retry-me' } },
]);
expect(requestCount).toBe(2);
expect(result.ConsumedCapacity).toEqual([
{ TableName: TABLE, CapacityUnits: 3 },
]);
});
it('gives up with a bad-request error when items never drain', async () => {
requestCount = 0;
responses = Array.from({ length: 12 }, () => unprocessed('stuck'));
await expect(
stubClient().batchPut([{ table: TABLE, item: { pk: 'stuck' } }]),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
message: 'Failed to batch write all items to DynamoDB',
});
// One initial attempt plus the full retry budget.
expect(requestCount).toBe(9);
}, 30_000);
it('tolerates a response that reports no capacity at all', async () => {
requestCount = 0;
responses = [{ UnprocessedItems: {} }];
await expect(
stubClient().batchPut([{ table: TABLE, item: { pk: 'quiet' } }]),
).resolves.toEqual({ ConsumedCapacity: [] });
expect(requestCount).toBe(1);
});
it('ignores capacity entries with no table name', async () => {
requestCount = 0;
responses = [
{
UnprocessedItems: {},
ConsumedCapacity: [{ CapacityUnits: 7 }],
},
];
await expect(
stubClient().batchPut([{ table: TABLE, item: { pk: 'anon' } }]),
).resolves.toEqual({ ConsumedCapacity: [] });
});
});
+57 -8
View File
@@ -1,4 +1,4 @@
/**
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
@@ -43,6 +43,7 @@ import { once } from 'node:events';
import { Agent as httpsAgent } from 'node:https';
import { HttpError } from '../../core/http';
import type { IConfig, IDynamoConfig } from '../../types';
import { Span } from '../../util/span.js';
import { PuterClient } from '../types';
const LOCAL_DYNAMO_MEMORY_PREFIX = ':memory:';
@@ -151,6 +152,7 @@ export class DDBClient extends PuterClient {
await this.#localInitPromise;
}
@Span('ddb.get', (table: string) => ({ 'db.table': table }))
async get<T extends Record<string, unknown>>(
table: string,
key: T,
@@ -167,6 +169,7 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
async put<T extends Record<string, unknown>>(table: string, item: T) {
const command = new PutCommand({
TableName: table,
@@ -178,6 +181,9 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.batchGet', (params: unknown[]) => ({
'db.batch_size': params.length,
}))
async batchGet(
params: { table: string; items: Record<string, unknown> }[],
consistentRead = false,
@@ -212,6 +218,9 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.batchPut', (params: unknown[]) => ({
'db.batch_size': params.length,
}))
async batchPut(params: { table: string; item: Record<string, unknown> }[]) {
const consumedCapacityByTable = new Map<string, number>();
if (params.length === 0) {
@@ -318,6 +327,7 @@ export class DDBClient extends PuterClient {
};
}
@Span('ddb.del', (table: string) => ({ 'db.table': table }))
async del<T extends Record<string, unknown>>(table: string, key: T) {
const command = new DeleteCommand({
TableName: table,
@@ -329,6 +339,7 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.query', (table: string) => ({ 'db.table': table }))
async query<T extends Record<string, unknown>>(
table: string,
keys: T,
@@ -336,7 +347,15 @@ export class DDBClient extends PuterClient {
pageKey?: Record<string, unknown>,
index = '',
consistentRead = false,
options?: { beginsWith?: { key: string; value: string } },
options?: {
beginsWith?: { key: string; value: string };
select?: 'COUNT';
filter?: {
expression: string;
values?: Record<string, unknown>;
names?: Record<string, string>;
};
},
) {
const keyExpressionParts = Object.keys(keys).map(
(key) => `#${key} = :${key}`,
@@ -367,6 +386,11 @@ export class DDBClient extends PuterClient {
expressionAttributeNames[`#${beginsKey}`] = beginsKey;
}
if (options?.filter) {
Object.assign(expressionAttributeValues, options.filter.values);
Object.assign(expressionAttributeNames, options.filter.names);
}
const command = new QueryCommand({
TableName: table,
...(!index ? {} : { IndexName: index }),
@@ -376,6 +400,10 @@ export class DDBClient extends PuterClient {
ConsistentRead: consistentRead,
...(!pageKey ? {} : { ExclusiveStartKey: pageKey }),
...(!limit ? {} : { Limit: limit }),
...(options?.filter
? { FilterExpression: options.filter.expression }
: {}),
...(options?.select ? { Select: options.select } : {}),
ReturnConsumedCapacity: 'TOTAL',
});
@@ -383,6 +411,7 @@ export class DDBClient extends PuterClient {
return client.send(command);
}
@Span('ddb.update', (table: string) => ({ 'db.table': table }))
async update<T extends Record<string, unknown>>(
table: string,
key: T,
@@ -424,6 +453,7 @@ export class DDBClient extends PuterClient {
return;
}
let alreadyExisted = false;
try {
const client = await this.#getDocumentClient();
await client.send(new CreateTableCommand(params));
@@ -431,14 +461,33 @@ export class DDBClient extends PuterClient {
if ((error as Error)?.name !== 'ResourceInUseException') {
throw error;
}
alreadyExisted = true;
}
if (ttlAttribute) {
await this.#deleteExpiredItems(
params.TableName!,
params.KeySchema!,
ttlAttribute,
);
// Only sweep a table that was already there. `CreateTable` returns
// while the table is still CREATING, so scanning it right away races
// its transition to ACTIVE and intermittently throws
// `ResourceNotFoundException` — and a table we just created holds no
// items to expire anyway.
if (ttlAttribute && alreadyExisted) {
try {
await this.#deleteExpiredItems(
params.TableName!,
params.KeySchema!,
ttlAttribute,
);
} catch (error) {
// The sweep is opportunistic cleanup, so a table that isn't
// queryable yet must never fail boot. Emulators differ on
// whether `CreateTable` reports an existing table as in-use,
// which can still land us here on a CREATING table.
if ((error as Error)?.name !== 'ResourceNotFoundException') {
throw error;
}
console.warn(
`[ddb] skipped TTL sweep for ${params.TableName}: table not queryable yet`,
);
}
}
}
Loaded 100 of 1104 files, more files were not shown because too many files have changed in this diff. Show more