122 Commits
Author SHA1 Message Date
devsyncwrldandClaude Opus 5 cc2b8d3d33 feat(mobile): keep voice alive with the screen off
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
A call dropped as soon as the screen locked. The cause is not configuration:
Android freezes background processes, and a frozen WebView stops running the
JavaScript that holds the LiveKit connection up. The only thing the system
accepts as "this app must keep running" is a foreground service, which is why
the persistent notification comes with it.

Two mechanisms, both needed: the service stops the process from being frozen,
and a partial wake lock stops the CPU from sleeping. Either one alone still
drops the call.

The notification carries a hangup action. The service cannot end the call
itself — only the web app knows how to leave a LiveKit room — so it notifies
the plugin's listeners and the web side disconnects through the same path the
in-app button uses.

The web side starts the service on mount when a call is already in progress,
not just on transition: the WebView can reload mid-call, and waiting for the
next connect would leave that call unprotected with no visible symptom until
someone locked their screen.

POST_NOTIFICATIONS is requested at launch on Android 13+. Denying it does not
drop calls — the service still runs — it only hides the notification.

Tests cover connect/disconnect, the already-in-a-call mount, the hangup action,
and that the component is inert in the browser and desktop builds. Verified by
mutation that dropping the start call fails the first one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:44:35 -03:00
devsyncwrldandClaude Opus 5 48e8bc537d ci(mobile): point setup-node at the mobile package's npm lockfile
Without cache-dependency-path the action falls back to the root package.json's
`packageManager: pnpm` field and fails looking for a pnpm this workflow never
installs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:11:46 -03:00
devsyncwrldandClaude Opus 5 f808f2f535 feat(mobile): Android app shell with Capacitor
The web client already has a full mobile UI (MobileShell and 17 sibling
screens) and ships as a PWA, so what was missing to get an APK was a native
container, not an interface.

The WebView loads the instance over the network instead of bundling a copy of
the web build. Bundling would mean republishing an APK on every site deploy —
there is no electron-updater equivalent here — and it would move the page to
https://localhost, turning every same-origin /api and /ws call into a
cross-origin one. The cost is no offline mode, which for a realtime chat app
costs nothing: public/index.html covers the unreachable-server case.

packages/mobile sits outside the pnpm workspace on purpose. Capacitor's CLI and
Gradle both assume a flat node_modules, and keeping it out leaves pnpm-lock.yaml
and the existing web/desktop CI untouched by it.

Icons are generated from the existing brand art. The launch screen is a
layer-list over the brand background rather than Capacitor's per-density
splash.png set, which is light and flashed white before the WebView painted.

CI builds an *unsigned* APK. The signing key stays on the VPS and is applied
there — an APK signature is what proves an update came from us, so the key has
no reason to exist on a second machine.

Background voice (foreground service) is not in this commit: a plain WebView is
throttled when the screen goes off and the call drops. That is the next phase.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:09:15 -03:00
devsyncwrldandClaude Opus 5 aee78c930d fix(desktop): announce staged updates reliably and install them silently
Two separate reasons "Restart to update" only ever showed up after pressing
"check for updates" by hand, and one reason the restart was unpleasant.

1. `update-downloaded` is a one-shot push to whichever page happens to be
   loaded. It is dropped when the window is in the tray (mainWindow is null),
   when the 10s post-launch check beats the web app's listener registration,
   and on every reload. Once an update is staged the poll latches off, so it
   was never re-announced — a manual check was the only way back. The main
   process already tracks this in recoveryStore, so expose it over IPC and have
   the banner ask on mount rather than rely on catching the event live.

2. UpdateBanner was only mounted in the mobile layout. On the desktop app the
   visible one was UpdateToast, which duplicated the same "update ready" card
   in untranslated English. Mount the banner on desktop too and reduce the
   toast to the failure case it does not share.

3. quitAndInstall() defaults to a non-silent install, so on Windows restarting
   meant clicking through the NSIS wizard. Pass silent + force-run-after, and
   switch the installer to one-click per-user, which is what lets the silent
   install proceed without an elevation prompt.

The manual-download link in the failure toast pointed at the upstream GitHub
repository, which is private for this fork and 404'd for everyone. Point it at
the fork's Gitea releases, where the updater feed already lives.

Regression test covers the case the live event never announced; verified by
mutation that removing the status query fails it and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:26:30 -03:00
devsyncwrldandClaude Opus 5 5e7adfb38d chore: version 2.1.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:09:30 -03:00
devsyncwrldandClaude Opus 5 033ba63762 feat(profile): banner repositioning, and the editor rebuilt on the reference's shape
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Three things, from the screenshots.

Banner repositioning, which was a real hole. A banner was always cropped dead
centre, and a GIF could not go through the still-image cropper at all — so
there was no way to frame one. The crop is now two percentages and a zoom
stored in the profile (`banner: {x, y, zoom}`), and the banner renders as an
<img> with object-position plus a scale rather than a CSS background: those two
compose into pan-and-zoom, where `background-size: cover` cannot be zoomed
without knowing the file's dimensions. The editor drags the live element, so a
GIF keeps animating while you frame it, and nothing is ever re-encoded.

The editor now has the reference's shape: a rail of cards on the left, each
showing what it currently holds, and the live profile beside it. A card opens a
dialog with the options on one side and a preview on the other. That is the
part both earlier attempts got wrong — the first put a 360px preview inside a
640px column, the second made you leave the preview to open a section. Here
they are on screen together at full size.

The dialog edits a copy: Cancel discards it, Apply commits to the draft, and
the draft is only persisted by Save. So backing out of a dialog cannot cost
unrelated work, and neither can the per-section reset in its header.

"Surprise me" randomises the look and nothing else. Fonts, effects, decoration,
theme and nameplate change; pronouns, tag, links, board and the banner crop are
the user's own words and framing, and rolling dice over those would be
vandalism. A test asserts exactly that over 40 rolls, and that every roll lands
on ids the normaliser accepts.

Five more nameplates in the y2k direction — holo, chrome, vapor, matrix, ember
— since the existing ten were all plain gradients.

Verified: shared build, web build, server typecheck, 523 web tests (5 new:
banner clamping and round-trip, randomiser bounds).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:02:29 -03:00
devsyncwrldandClaude Opus 5 e6eba7a2b7 fix(profile): align the decorations, and make the effects actually look like something
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Three complaints, three causes.

Decorations sat off-centre. <Avatar>'s box is `size + 2 × ringWidth`, but the
overlay was positioned with a percentage inset of that box — so it drifted by
exactly the ring whenever one was present. That is why the crown floated above
the head on the profile card while looking fine on the editor tiles, which
have no ring. DecoratedAvatar now takes `size` and `ringWidth` and computes
the inset in pixels, so the picture's edge always lands at a known radius in
the SVG viewBox and every decoration is drawn against it.

The ugly ones were ugly. Flames were a blob with a black circle punched out of
it; they are now fourteen tongues of alternating height on a gradient. The
crown's band sits ON the picture's top edge instead of hovering above it.
Frost, stars and hearts were placed by eye and are now on a computed ring.
Nine more: holo disc (a conic gradient — the honest way to draw one, and CSS
has it where SVG does not), chain, cyber brackets, lightning, spikes,
butterflies, sakura, bubbles, flower. Twenty in total.

Name effects were thin. Gradient was a two-stop ramp; it now has a lit middle.
Neon was one shadow, now five layered with a buzz. Glitch was a keyframed
text-shadow, now a real RGB split duplicated through ::before/::after from
data-text. Added chrome and gold (banded metal with a specular line — the y2k
look the reference is after), holo, rainbow, ice, 3D extrude, and outline.
Sixteen effects, thirteen fonts with Audiowide, Bungee and Monoton added.

The editor tiles now sample the user's own name rather than "Gg" or the effect
label: an effect reads differently on a short word than on the name it will
carry. Two per row instead of three, and section rows gained an icon.

One test failed on the catalog growth and was right to: it used 'rainbow' as
an example of an unknown id, and rainbow is now a real effect. It uses an
absurd id instead, and a new test walks every catalog id through the
normaliser so a catalog and the normaliser can never drift apart silently.

Verified: shared build, web build, server typecheck, 518 web tests (1 new).
Not verified: pixels — no browser here; the geometry is now computed rather
than guessed, but a screenshot decides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 09:46:20 -03:00
devsyncwrldandClaude Fable 5.1 723522b90d fix(profile): give the editor room — three panels instead of a cramped column
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The owner's verdict on the first editor was "imprensado", and it was: the
settings shell caps its content at 640px, and the editor put a 360px preview
next to six-column tile grids inside that. Everything was legible and nothing
was usable.

Now it follows the shape of the reference (Discord's profile editor):

  - left, 320px: the list of sections, each showing its current value as a
    mini preview (the nameplate strip, the decorated avatar, the styled name,
    the theme swatch, the effect thumbnail). Picking one replaces the list
    with that section's editor, with a back arrow — a drill-down instead of
    sub-modals, one level deep.
  - centre: the live card, plus two in-context samples — how the name and tag
    read on a chat line, and the row in the member list with its nameplate.
  - right: the Board as others see it, with an Edit shortcut.

Tiles went from 5–6 per row to 2–3, with real size (Gg samples at 26px,
decorations on a 56px avatar, effect thumbnails 64px tall). The settings
shell drops its width cap and lets the panel fill the height for this tab
only; every other panel keeps the single reading column. Stacks to one
column below the lg breakpoint.

Verified: web build, 517 web tests. Not verified: pixels — there is no
browser here, so the proportions are from the reference and the numbers, and
a screenshot decides.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 09:27:38 -03:00
devsyncwrldandClaude Fable 5.1 e7094014b7 fix(shared): profile.ts must not reference the URL global type
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
packages/shared compiles without the DOM or Node type libraries, so `URL` has
no type there even though it exists at runtime everywhere this code runs. The
web and server builds resolved the same file under their own libs and passed;
only the shared package's own `tsc` — which CI runs first — failed, on both
runners. Reach the constructor through globalThis with a minimal structural
type instead of pulling a lib into a types-only package. Runtime unchanged,
so the deployed site (82798d5c) needs no redeploy for this.

Lesson recorded for the local checklist: run `pnpm --filter @backspace/shared
build` too — the root `typecheck` script already does; the web build alone
does not.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:44:35 -03:00
devsyncwrldandClaude Fable 5.1 82798d5c5a chore: version 2.0.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The rebrand to Resenhacord moves the install and the profile directory;
a major bump marks that boundary for anyone reading the version string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:38:30 -03:00
devsyncwrldandClaude Fable 5.1 ef9c73501c feat(profile): a full identity system — name styles, decorations, effects, nameplates, tag, links, board
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The owner asked for profile customisation that is "completo e FODA", pointing
at Discord's profile editor. This is the whole thing, end to end, with a live
preview.

What a user can now set, and where it shows:

  - Display name style: 10 fonts × 9 effects (solid, gradient, neon, toon,
    pop, gummy, prism, glitch, fire) × two colours. Popout and full profile.
  - Avatar decoration: 10 (ring, neon, flames, stars, halo, vines, pixel,
    hearts, frost, crown). Inline SVG — no image assets, scales to any size.
  - Profile effect over the banner: 9 (sparkles, rain, snow, matrix,
    confetti, aurora, embers, bubbles, hearts). CSS particles with seeded
    positions so re-renders do not change the weather.
  - Nameplate behind the member-list row: 10 presets + custom two-colour.
  - Theme: two colours washed behind the card, and the banner fallback.
  - Pronouns, and a clan-style tag (emoji + 2–4 letters) beside the name,
    also on the member row.
  - Links, up to 5, with the brand icon picked from the host.
  - Board: favourite game, up to 5 in rotation, up to 10 wanted, a quote.
    Games come from Steam search through a small server proxy (cached,
    10 min) or are typed by hand; covers only load from Steam's CDN.
  - Badges, derived rather than stored: instance admin, server owner,
    founder (joined in the space's first month), veteran (a year), OG (six
    months), stylist. Nothing to grant, revoke or migrate.

Storage is one JSON column, users.profile_style (migration 0019), and one
normaliser in shared/profile.ts that both the server (on write and on read)
and the client (on render) run. It never throws: unknown ids fall back,
strings are cut to their limits, colours must be #rrggbb, link URLs must be
https, cover URLs must be a Steam CDN host. Kept out of the federation
durable-field set on purpose — peers on older builds do not know the field,
and it is cosmetic.

The editor is a new Settings > Profile tab. Its preview is the same
ProfileCard component the popout renders, with the draft style swapped in —
what you see while editing is, by construction, what others see.

Fonts are OFL builds self-hosted from public/fonts alongside DM Sans, latin
subsets only, ~120 KB in total.

Verified: web build, 517 web tests (11 new: normaliser bounds and fallbacks,
link icon detection, badge derivation), server typecheck, 804 server tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:36:23 -03:00
devsyncwrldandClaude Fable 5.1 872066e5be feat: rebrand to Resenhacord with a new logo, and post a notice when a message is pinned
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Rebrand. The product name is now Resenhacord everywhere a person reads it:
window title, tray, notifications, About panel, PWA manifest, page title,
recovery and instance-picker screens, and the four i18n strings that named
the app. Code identifiers (BackspaceElectronAPI, @backspace/* workspace
names, the federation protocol) are unchanged — they are not a brand, and
renaming them buys nothing but churn. AGPL notices and upstream attribution
stay as they were.

Two rename details that would each have hurt real users:

  - electron-builder's productName changed but appId did not. NSIS keys the
    installation on appId, so the updater replaces the Backspace install in
    place instead of leaving a second app beside it.
  - userData is keyed on app name, so the rename would have moved every
    install to an empty profile — logged out, instance URL gone. main.ts now
    walks a migration chain ("@backspace/desktop" → "Backspace" →
    "Resenhacord"), each step a no-op once its source is gone, and one whose
    target already holds data leaves it alone. Autostart lookups accept the
    old registry entry name too.

Logo. Two speech bubbles in conversation, violet behind, rose in front with
three dots — the front one is talking. Sources are hand-written SVG in
assets/brand (square viewBox; the gap between bubbles is a mask, not paint,
so the alpha-only macOS template keeps the silhouette). PNG sources are
rendered from the icon SVG and the whole set — .icns, .ico, Linux PNGs, tray
variants, favicons, PWA, maskable — comes from the existing gen-icons
pipeline unchanged.

Pin notice. Pinning a message now posts "X pinned a message to this channel.
See all pinned messages." into the channel, the way Discord does, with two
links: the message itself (jumps to it) and the pins panel. Space messages
had no `type` column — only DM messages carried system events — so this adds
one (migration 0018, default 'user', verified against a copy of the
production database: 88 rows, all 'user'). The route inserts a type='system'
row alongside the pin and broadcasts it as message_created, so the client
renders it with the SystemMessage renderer it already has. Unpinning posts
nothing; it is housekeeping, not news. System notices are excluded from the
notification toast and the message sound — they are not from anyone.

The pins panel and jump target are local state in MainContent; a notice deep
in the timeline reaches them through two small uiStore requests (a counter
and an id), neither persisted.

Verified: web build, 506 web tests (2 new), server typecheck, 804 server
tests, desktop typecheck.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 23:15:14 -03:00
devsyncwrldandClaude Opus 5 a41a944742 feat(chat): @everyone mention
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Rides the existing `<@id>` token: `everyone` already matches the parser's
`[a-zA-Z0-9_-]+`, so the markdown pre-processor, the inline renderer and the
badge pick it up with no second syntax. User ids are snowflakes, so the
literal `everyone` cannot collide with one.

Gated on a new MENTION_EVERYONE permission rather than being open to all —
otherwise a role can never take it away. Granted to @everyone by default: on
a small instance the ping is a convenience, not a weapon, and an owner who
disagrees can deny it and grant it to a role instead.

Two things that would each have made the gate a no-op:

  - DEFAULT_EVERYONE_PERMISSIONS only applies at space creation, so spaces
    that predate the bit would have had every @everyone mention silently
    stripped. A boot-time backfill ORs the bit into @everyone roles only
    (`id = space_id`), leaving other roles alone so a later denial is not
    overruled on the next restart. Verified against a copy of the production
    database: three roles moved by exactly 2^15, non-@everyone roles
    untouched, second pass a no-op.
  - Enforcement covers edit as well as create; without that, posting a plain
    message and editing it is the way around the permission.

A message that lacks the permission is not rejected — losing what someone
wrote over one token is worse than posting it. The token is demoted to the
literal `@everyone` text they typed: the message reads the same and rings
nobody.

Code spans and fences never ping. People paste payloads and snippets into
chat, and a paste should not ring everyone's client. The same rule decides
the row highlight and the notification sound, so the three cannot disagree.

Verified: web build, server typecheck, 504 web tests, 804 server tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 22:10:56 -03:00
devsyncwrldandClaude Opus 5 1ec5d88fa2 chore: version 1.5.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:58:05 -03:00
devsyncwrldandClaude Opus 5 699855849d feat(i18n): translate the five systems users actually see
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Ordered by what the group hits, not by string count.

  - Chat system messages ("X added Y to the group") and their sidebar
    preview twin. These are content, not chrome, so they sat in English in
    the middle of conversations.
  - Login, register and the invite landing page — the first screen anyone
    invited to the instance sees.
  - Voice — controls, connection info, the mobile join sheet, settings tab.
  - Mobile screens.
  - Settings navigation across space, user and instance, plus Discovery and
    join requests.

Three call sites build strings outside a component and so read the locale
directly rather than through the hook: the sidebar preview formatter, the
connection-info track labels, and MobileShell's screen map — which holds
plain functions that are *called*, not rendered, where a hook would run
under whichever component invoked the map. Each says so at the definition;
all three re-render often enough that a language switch shows through
promptly.

Adds a test for a mistake made three times in this series: a file with an
exported component plus a private sub-component, where only the exported one
got the hook. tsc catches it, but only after a full build and without naming
the component. The test names it, and also flags the inverse — a hook placed
below an early return, which tsc accepts and React breaks at runtime (this
project shipped that once, as error #310). Verified by mutation: removing a
hook makes it fail and print the component.

Dictionary is at 856 keys, en and pt-BR at exact parity.

Verified: web build (tsc + vite), 492 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:13:54 -03:00
devsyncwrldandClaude Opus 5 94f5da4013 feat(i18n): translate five more systems, and unify the permission labels
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
- Space settings > Overview — identity, icon, banner, transfer, delete
  - Space settings > Members and Bans
  - Space settings > Roles, including the whole permission table
  - Category settings — rename, privacy, overrides, delete
  - Instance settings > General — name, discovery, GIF search

The permission labels were duplicated: the roles panel carried one English
list and the channel/category override editors carried another. Both now
read a single `perm.*` set, so renaming a permission lands in all three at
once instead of one and a half. This removes the `channel.perm.*` keys added
two commits ago rather than adding a third copy beside them.

Same pattern as before for anything chosen outside the render: the roles
panel's permission groups and the category's override definitions hold keys
and resolve them where they render. The category delete dialog emphasises
the category name, so its sentence is split around the placeholder rather
than interpolated.

Dictionary is at 723 keys, en and pt-BR at exact parity.

Verified: web build (tsc + vite), 490 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:49:25 -03:00
devsyncwrldandClaude Opus 5 4fd6ac890c feat(i18n): translate five more systems
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
- Settings > Voice: video — preview, permission states, camera picker.
    This closes the Voice & Video tab; input, output and video are all done.
  - Create channel / category / space
  - Group DMs — new DM, add friends, group settings, leave
  - Join a space — discovery, invite code, and both federated auth phases
  - Delete account, transfer ownership, and the top-level error screen

Three patterns worth noting. Strings that are chosen in a helper or held in
state (the camera's hard-blocked copy, its preview errors, the space
visibility options, the invite failure reasons) now travel as translation
keys and are resolved where they render, so switching language re-labels
what is already on screen. Sentences that wrap an emphasised literal — the
host name when joining, the space and member names when transferring — are
split around their placeholder instead of interpolated, leaving word order
to the translation. And `CreateChannel` mapped over `(t) =>` for the channel
type, which shadowed the translator; the loop variable is now `channelType`.

The error boundary in main.tsx is a class component, so it reads the locale
once per render via `translate` rather than the hook — correct here anyway,
since an error screen has no reason to re-render on a language switch.

Two GroupDmSettings tests located the name input by its aria-label, which
this change translates. They now use the `data-group-dm-name-input`
attribute the input already carries, matching how the rest of that file
locates elements — and no longer break when copy changes.

Dictionary is at 595 keys, en and pt-BR at exact parity.

Verified: web build (tsc + vite), 490 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:38:54 -03:00
devsyncwrldandClaude Opus 5 115c81cba3 feat(i18n): translate five more systems, and check for updates more often
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Update cadence: the desktop app checked once ten seconds after launch and
then every four hours. A release published while the app sits open went
unnoticed until the next poll — which is exactly what happened with 1.4.0.
It now polls every 30 minutes and also checks when the window regains focus,
throttled to one check per five minutes so alt-tabbing does not hammer the
feed. `lastCheckAt` is stamped from the `checking-for-update` event rather
than by the callers, so a manual check from the settings panel counts too.
The focus listener is on `app`, not on mainWindow, because the window is
destroyed and recreated when closed to tray.

Translations, five whole systems:
  - Settings > Desktop (autostart, updates, instance)
  - Settings > Voice: output device, which completes that tab
  - Settings > Keybinds, including the action names and conflict prompt
  - Settings > Account, the largest — profile, password, detached-instance
    notice, danger zone
  - Explore spaces

Two sentences wrap a literal that must not be translated (the `input` group
name, the `user@instance` example). Those are split around a placeholder
rather than interpolated as text, so a translation keeps control of the word
order on both sides of it. Keybind action labels gained a `labelKey`
alongside the English `label`, which stays for non-React callers.

Verified: web build (tsc + vite), desktop typecheck, 490 web tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 13:38:28 -03:00
devsyncwrldandClaude Opus 5 a54d998ba9 chore: version 1.4.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:52:31 -03:00
devsyncwrldandClaude Opus 5 860f02b2c1 feat(channels): rename channels, and quieter defaults for sound and streams
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Four owner requests in one pass.

Rename: the PATCH /api/channels/:id route already accepted a name and a
topic, and the API client already exposed it — the only thing missing was a
way to reach it. The channel settings Overview tab now edits both, gated on
MANAGE_CHANNELS like the server is, with Save/Discard appearing only when
the form is dirty. The server's lowercase-and-hyphenate rule moved to
@backspace/shared so the field can show what a name will actually become
instead of letting it change shape on save; one copy of the rule, used by
both sides.

Stream volume: someone else's screen share now starts at 50 instead of 100
(DEFAULT_STREAM_VOLUME). The publisher boosts system audio before sending,
so the old default arrived far too loud for a viewer who never touched the
slider. Only the default moves — the slider still covers the full range and
a value the user set is still persisted.

Sound effects: SFX_BASE_VOLUME 0.8 -> 0.4, halving every cue. The .ogg files
were left alone; they already peak below full scale, so the harshness was
level, not recorded distortion. Every SFX playback path goes through
getSfxVolume(), so this is the single knob.

Translations: channel settings (including the override editor's permission
labels), the friends page end to end — tabs, lists, requests, the add tab
and discovery cards — the invite modal including its per-invite failure
reasons, and the group-DM member actions. Failure reasons are carried as
translation keys rather than finished text, so results already on screen
re-label when the language changes.

Verified: web build (tsc + vite), server typecheck, 490 web tests, 804
server tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:59:25 -03:00
devsyncwrldandClaude Opus 5 293378293d fix(ci): match release assets by name, and close the guard block
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Two defects, both mine, and the second hid the first.

The cleanup built a regex and matched it against the jq line '<id> <name>', so
the anchor in latest.yml could never match — it sits after the id. Only the
.exe was removed, and each publish left another latest.yml behind. Gitea then
served the older one, so the updater kept reporting the previous version as
current: an update that exists and is never offered, with nothing logged
anywhere. Filtering now happens inside jq, comparing the name directly.

The guard meant to catch exactly that was missing its closing fi, so the step
died on a syntax error before reaching it. Verified with bash -n this time,
which is what should have happened before it ever ran on a runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:14:59 -03:00
devsyncwrldandClaude Opus 5 c5abb6fd64 chore: version 1.3.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Carries the silent native notification, which lives in the Electron main
process and therefore only takes effect from a new installer — the reason this
release is needed beyond exercising the restart prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:07:26 -03:00
devsyncwrldandClaude Opus 5 1fb61377b9 feat(notify): in-app notifications with the app's own sound
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The system balloon carries the OS notification sound, which does not belong to
this app, and it only fired while the window was out of focus — with the app
focused nothing appeared at all.

Notifications now surface inside the window, carry the same synthesised timbre
as the rest of the app's sounds, and clicking one opens the channel. The native
balloon is kept for when the window is not visible, since an in-app card
nobody can see is no notification, but it is now silent: the app plays its own
effect instead.

A focused window is notified only about other channels — announcing the
conversation someone is already reading is noise.

Also fixes the Gitea publish cleanup, which silently deleted nothing: it
interpolated an Actions expression inside a bash , and when the pattern
did not match, the loop passed over every asset. The release ended with two
latest.yml files and the updater served the older one, reporting 1.1.0 as
current — an update that exists but is never offered, with no error anywhere.
The filter is plain bash now, logs what it found, and the job fails if more
than one latest.yml survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:57:02 -03:00
devsyncwrldandClaude Opus 5 1a1067bbb6 chore: version 1.2.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Pins, filtered search, custom emojis and stickers, and the in-app restart
prompt. The prompt itself is only visible once an update is offered, so this
release is what makes it observable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:49:23 -03:00
devsyncwrldandClaude Opus 5 71d2109b2f feat(desktop): in-app prompt to restart for an update
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The main process already showed a system notification when an update finished
downloading, and nothing in the renderer listened — so the only cue vanished on
its own and was missed by anyone not watching the desktop at that moment.

A prompt now stays in the window until it is acted on or dismissed, with the
restart wired to the install-update channel the preload already exposed.

Dismissing is 'not now', not 'never': a later version reopens the prompt, since
the version it refers to has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:31:59 -03:00
devsyncwrldandClaude Opus 5 7f08384372 feat(expressions): pickers and upload for emojis and stickers
Completes the feature: the tables existed but nothing could be put in them.

Space settings gain an Emojis & Stickers panel behind MANAGE_SPACE, with a
512KB ceiling — both are fetched on every message that uses them, so weight
matters more than fidelity. The suggested name is pre-normalised so the common
case needs no typing, and a name collision reports itself distinctly from an
upload failure: the corrective action is different.

Custom emojis join the emoji picker as their own category. They have no native
character, so selecting one inserts :name: — the same text the renderer
resolves back to an image, which also means copying a message yields something
that still reads.

Stickers get a picker tab that only appears inside a space, since that is where
they exist, and send immediately on click: a sticker is the whole message, so
parking it in the composer to await Enter would make no sense.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:25:10 -03:00
devsyncwrldandClaude Opus 5 7d003021d1 feat(expressions): custom emojis and stickers per space
Emojis and stickers get separate tables on purpose: an emoji sits inside a
sentence and a sticker is the whole message, so their sizes, limits and render
paths differ.

Emoji names are unique per space and restricted to letters, digits and
underscore — :name: has to resolve to one image, and a space or colon inside
the name would make the reference impossible to delimit. Sticker names are
labels picked from a grid, so they accept anything.

Rendering reuses the existing markdown pipeline by rewriting :name: into image
syntax, which needs no plugin. Code spans and fences are left alone: text
someone wrapped in backticks was meant to be shown literally, and turning it
into an image would destroy what they wrote.

Messages carry stickerId with ON DELETE set null — removing a sticker from the
space must not delete the history of everyone who used it. The server refuses a
sticker from another space, which would otherwise leak images between unrelated
servers.

Expressions load once per space and stay in memory: the message renderer
consults the map for every :name: it finds, and a request per lookup would turn
each message into a cascade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:57:32 -03:00
devsyncwrldandClaude Opus 5 50a8f12c77 feat(search): inline filters, and translate the search UI
The filters themselves already existed end to end — the server takes q, from,
has, before and after, the API client passes them, and the popover has fields
for each. What it lacked was discovery: the panel sits behind a button, so the
capability was invisible.

Typing 'de:fulano' or 'has:image' straight into the search box now applies the
same filters. Keys are accepted in both languages, since the app is bilingual,
and an unrecognised token falls back to being search text — otherwise a message
containing a URL or 'algo:coisa' would become unfindable.

Inline filters win over the panel's: whoever just typed one is expressing the
more recent intent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:49:05 -03:00
devsyncwrldandClaude Opus 5 4ceb5cd66a feat(pins): pin messages to a channel
Pinned state lives on the message rather than a join table: a message is
pinned in exactly one channel, its own, so a separate table would add a join to
every lookup and buy nothing.

Every message now carries its pin state, so the timeline can mark a pin without
a second request and the panel and the timeline cannot disagree. Toggling is
deliberately not optimistic — the server refuses past the channel's limit, and
showing it pinned before confirmation would lie in exactly that case.

The pins list reuses the same assembly the channel history uses, extracted into
one helper, so the two cannot drift apart in what they include.

The migration also adds the (channel, user, created) index the filtered search
will need, since both touch the same table and one migration is cheaper than
two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:45:33 -03:00
devsyncwrldandClaude Opus 5 c4158ee245 docs: retire the fixes that shipped and were confirmed in use
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:13:30 -03:00
devsyncwrld 61c68761df i18n: translate the channel and member sidebars
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Clears the two remaining rounds owed under the fork's per-update translation
rule.

Covers channels, direct messages, the context menus, the inline voice settings
and the member list's loading and grouping labels.

The member grouping needed care: one function returns either a fixed label or
a role's own name, and role names are user data that must never be translated.
It now marks only the fixed cases and the component resolves them at render,
leaving role names untouched. The function is not a component, so it cannot
call the hook itself.
2026-09-01 17:03:48 -03:00
devsyncwrld c04b8b47eb i18n: translate the chat composer and messages
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Catching up on the fork's rule that every update also translates one existing
system — three updates went by without one.

The composer's placeholder takes the channel as a parameter rather than being
concatenated, since Portuguese puts the channel elsewhere in the sentence than
English does.

The translation hook is bound to `tr` in these two files, not `t`: both already
use `t` for transfer records in inner scopes, which shadowed the function and
made it uncallable exactly where it was needed.
2026-09-01 16:59:50 -03:00
devsyncwrld ce6bba0510 fix(ci): pulseaudio headers, and an ASCII release payload
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Linux got past pipewire and stopped on pulse/pulseaudio.h — the module
compiles both backends, so both sets of headers are needed.

Windows reached the upload and Gitea rejected the release creation with HTTP
422, 'invalid UTF-8 within /name': the JSON body carried accented text and the
Windows runner's shell mangles the encoding on the way out. The payload is now
ASCII, with a comment saying why, so it does not get 'improved' back into
Portuguese.
2026-09-01 16:04:01 -03:00
devsyncwrld 9ea399ded5 fix(ci): install pipewire headers, and make the Gitea upload diagnosable
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Two separate failures on the first run.

Linux could not compile the native module: it needs libpipewire-0.3-dev, which
its README states and the apt list omitted.

Windows built everything, native module verified, then died on the upload with
a bare JSONDecodeError. The cause was curl -sf, which discards the error body,
so a failed release creation surfaced as an empty pipe and no reason at all.
The step now captures status and body and prints them, treats a failed create
as possibly the other matrix job having just created it, and passes
target_commitish so the tag can be created.

Embedded python gave way to jq: a multi-line heredoc inside a YAML literal
block ends the block at the first unindented line, which is how the file became
invalid YAML in the first place.
2026-09-01 15:58:26 -03:00
devsyncwrld 9f7723d104 ci: build on GitHub, publish updates from Gitea
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
GitHub stays the build machine — it has the Windows runners the native audio
module needs — but the update feed moves to this fork's own Gitea.

The GitHub repository is private, and electron-updater against a private GitHub
repo needs a token inside the shipped app, which is a leaked token. Gitea
serves release assets to anyone, so the installer carries no credential. The
flow was verified end to end against the live instance before writing this:
create release, upload asset, download anonymously.

The release tag is fixed at 'latest' because electron-updater fetches
latest.yml before it knows which version exists, so the URL cannot carry a
version; CI replaces that release's assets each publish. electron-builder runs
with --publish never since it cannot upload to Gitea, but still emits the
latest.yml the updater reads.

Needs a GITEA_TOKEN secret on the GitHub repository.
2026-09-01 15:49:25 -03:00
devsyncwrld 5b84843217 fix(screenshare): make shared system audio audible
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Listeners had to run the participant at 200% with attenuation off. Two causes,
both on the publishing side.

WASAPI returns the process mix at a level well below what a microphone track
arrives at, so the track was quiet before it ever left the machine. A gain
stage now boosts it, followed by a limiter — a compressor with a high ratio and
fast attack — so the boost cannot clip loud passages.

And the track was published with LiveKit's defaults, which are tuned for
speech: mono, low bitrate, and DTX enabled, which stops transmitting during
silence. On game and music audio that reads as thin and clipped. It now
publishes with the music stereo preset, stereo forced, and DTX and RED off.

Lives in packages/web, so it reaches the desktop app through a normal deploy —
no new installer.
2026-09-01 15:36:55 -03:00
devsyncwrld bd8decb4b3 fix(ci): actually compile the native audio module, and prove it
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The previous run went green and produced an installer with no system-audio
capture in it — the exact silent failure this module exists to prevent.

Two causes. electron-rebuild takes -w as one comma-separated list, not a
repeated flag; passing it twice made argv.w an array and the CLI threw
'argv.w.split is not a function', which also broke uiohook-napi's rebuild that
had been working. And pnpm 10 refuses to run a dependency's build script unless
it is listed in onlyBuiltDependencies, so node-gyp never ran for it at all —
the log said 'Ignored build scripts' and nothing else complained.

Neither surfaced because desktop's postinstall ends in , which
exists so contributors without build tools can install. That is reasonable
locally and dangerous in CI, so the workflow now asserts a compiled .node
exists and fails loudly when it does not, instead of trusting an exit code that
was designed to lie.
2026-09-01 15:13:26 -03:00
devsyncwrld 52f43d4d61 build: record electron-native-screenshare in the lockfile
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The dependency was added to packages/desktop/package.json without regenerating
the lockfile, so CI's --frozen-lockfile refused the install — correctly: a
build that resolves fresh versions is a build that differs from what was
tested.
2026-09-01 15:06:59 -03:00
devsyncwrld 7eaba5e3f3 ci: build only the architectures this group runs
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Windows arm64 doubled the slowest job in the matrix — two Electron
distributions downloaded, native modules compiled twice — and nobody here runs
Windows on ARM. Linux arm64 went for the same reason.

Removing arm64 also retires the fpm step: electron-builder's bundled fpm is
x86_64-only and failed on the arm64 runner, which is the only reason a native
fpm was installed (ruby, ruby-dev, build-essential and a gem with native
extensions, on every run). On an x86_64 runner the bundled one works.

Matrix is now Windows x64 and Linux x64.
2026-09-01 14:02:54 -03:00
devsyncwrld 124db82a0c ci: drop the macOS build from the release matrix
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Nobody in this group runs macOS, and on a private repository the macOS runner
bills at 10x — it was the most expensive platform in the matrix, compiled on
every tag for zero users.

This edits a file inherited from upstream, so it will conflict on merges. That
is the accepted cost: the alternative is paying for a macOS build indefinitely.
2026-09-01 14:00:12 -03:00
devsyncwrld b4d001eb26 ci: add a fast Windows-only desktop build
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
release.yml builds four platforms and publishes a release, which is right for a
release and slow for 'give me an exe to test'. Wall time is set by the Windows
job, which compiles native modules and downloads Electron twice because it
targets x64 and arm64 together — and Windows-on-ARM is not something this group
runs.

This path builds one architecture, caches the Electron and electron-builder
downloads (~100MB re-fetched every run otherwise), and uploads the installer as
an artifact, so it needs neither a tag nor a version bump.

Kept as a separate file: release.yml comes from upstream and takes merges, so
editing it would conflict on every update.

Cost matters too on a private repository: macOS runners bill at 10x and Windows
at 2x, so the full matrix was paying for macOS builds on every tag.
2026-09-01 13:29:18 -03:00
devsyncwrld 60c51a1635 chore: version 1.1.0
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
First release built from the fork. electron-builder names artifacts and the
GitHub release from package.json rather than the git tag, so the two have to
agree or electron-updater compares mismatched versions and never offers the
update. v1.0.0 is already taken by upstream's tag in this history.
2026-09-01 13:15:13 -03:00
devsyncwrld d208b0c277 build(desktop): publish releases to syncwrld/resenhacord
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-09-01 13:14:43 -03:00
devsyncwrld 2f836cd366 build(desktop): correct the fork's GitHub owner to syncwrld
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-09-01 13:11:49 -03:00
devsyncwrld f7f2cf75ab build(desktop): point releases at the fork and rebuild the new native module
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
release.yml already builds and publishes desktop installers for every platform
on a v* tag, so no new workflow was needed — it needed adapting to the fork.

electron-builder published to upstream's repository, so releases (and with them
the electron-updater feed, which had no source at all) went nowhere useful.

More consequential: postinstall ran electron-rebuild against uiohook-napi only.
electron-native-screenshare would have been compiled for Node's ABI rather than
Electron's and failed to load at runtime — and since the loader degrades
quietly by design, the symptom would have been screen sharing with no sound and
no error, which is the exact bug this module exists to fix.

Documented in docs/build-desktop.md, including why the build has to run on
GitHub (Gitea Actions provides no hosted runners, and the module needs MSVC)
and why the Windows runner stays pinned to windows-2022.
2026-09-01 13:10:10 -03:00
devsyncwrld 3deed92dd9 feat(sounds): replace call and stream effects
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The originals were all 1.14s — long enough to be intrusive for events that
fire whenever anyone joins, leaves, or watches a stream.

Synthesised rather than sourced: no third-party file, so no licensing question
in a public repository. The parameters are measured, not guessed — envelope,
spectral peaks and decay taken from two reference sounds the instance owner
supplied. The timbre is fundamental plus octave at near-equal strength
(1.00 / 0.85 / 0.10 / 0.02) decaying to 1/e in 0.19s, with no reverb.

Call join rises C4→G4, leave falls D4→G3, matching the references' intervals
and their 100ms spacing. The stream pair reuses those resolution notes as
single tones at ~55% the volume: they fire far more often during a broadcast,
so they have to sit under the call sounds rather than beside them.

Stream start is the one event that happens once per broadcast, so it can be a
chord — with a low thump and a short air layer that both die inside 150ms,
adding weight without length. Stream end mirrors it descending, quieter, and
without the air, since brightness reads as arrival.

Generators and the measured parameters are kept in tools/sfx so these can be
retuned without redoing the analysis. Total size drops from 150KB to 41KB.
2026-09-01 13:01:06 -03:00
devsyncwrld d525bbb8c5 feat(desktop): capture system audio with process isolation
Electron's audio: 'loopback' captures the whole output mix, this app's own
playback included — so everyone else's voices went back out inside the share
and each listener heard themselves. Not acoustic echo but a digital copy of
the output, which is why headphones never helped, and why shareAudio already
defaulted to off in the desktop app.

Electron offers no way to exclude our own audio: the docs allow only
'loopback' or 'loopbackWithMute', and the handler discards the renderer's
constraints (restrictOwnAudio never arrives). electron-native-screenshare does
it at the OS level — WASAPI process loopback on Windows — capturing only the
shared window when its pid resolves, and otherwise everything except us.

The module hands raw PCM to the main process, so it crosses IPC and is
scheduled onto a running cursor in Web Audio to become a MediaStreamTrack,
published as ScreenShareAudio. Loading is optional and failure degrades to a
silent share rather than blocking the app or the screen share.

The browser path is untouched: Chrome honours restrictOwnAudio and has no echo.

Verified by typecheck (web and Electron main) and the web suite. The audio path
itself cannot be exercised here — no Windows, no Electron, no audio device.
2026-09-01 00:56:34 -03:00
devsyncwrld 310e9b86be docs: diagnose the screen-share audio echo
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 22:58:59 -03:00
devsyncwrld e291b5e411 docs: drop delivered rows from the pending sections
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 22:40:44 -03:00
devsyncwrld c899253e52 fix: soundboard upload in Electron, and Spotify sync/disappearing/progress
Soundboard: naming a clip used window.prompt, which Electron does not
implement — it returned nothing, the flow aborted in silence, and adding a
sound worked in the browser while doing nothing at all in the desktop app.
Replaced with a two-step field inside the popover, identical in both.

Spotify, three separate defects behind the two symptoms reported:

Out of sync — a 20s poll stacked on the activity store's 5s debounce left
everyone else on the previous track for up to 25s. The next poll is now
scheduled just past the current track's end instead of on a fixed interval,
and a track change bypasses the debounce (it happens once every few minutes;
the debounce exists for chatty producers).

Vanishing — a paused track, and the silent gap Spotify reports between two
songs, both cleared the activity outright. Pausing is now carried as state
rather than absence, and an empty answer is tolerated for 25s before the
block comes down.

Progress bar — timestamps are computed with the server's clock and were drawn
against the viewer's, so any drift displaced the bar; and it kept advancing
after a pause until the next poll. The ready payload now carries server time
so each client can correct its own offset, and the bar freezes when paused.

Tray, native notifications and system audio in screen share were all found
already implemented and wired end to end; recorded in the roadmap rather than
built again.
2026-08-31 22:39:28 -03:00
devsyncwrld ff55d9d486 docs: plan desktop features and diagnose the Spotify sync bugs
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 14:02:21 -03:00
devsyncwrld e58021408c docs: record the Electron window.prompt soundboard bug
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 14:00:41 -03:00
devsyncwrld 2afe3453f0 docs: record four approved features not yet started
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 13:57:01 -03:00
devsyncwrld e89966435a fix(soundboard): read MANAGE_SPACE from the space bitfield
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The add-sound control was gated on channelPermissions, which carries the
per-channel bitfield; MANAGE_SPACE lives in the space-level one. The check
silently evaluated false for everybody, including owners, so the button never
rendered and there was no way to add a sound at all.

Also raise the clip cap to 2 MB — a 30-second clip at a high bitrate cleared
1 MB — and stop reporting every upload failure as 'too large', which sent
people to shrink a file that was not the problem.
2026-08-31 13:54:00 -03:00
devsyncwrld f5451e1b14 feat: soundboard, account menu, and call timer
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Soundboard: the trigger travels over the WebSocket and every client in the
call plays the clip locally, instead of mixing it into the presser's
microphone or publishing a LiveKit track. No upstream bandwidth, no media
stack changes, and the clip is not degraded by voice processing.

Fan-out uses a new sendToRoomParticipants rather than sendToRoom: the latter
broadcasts a space room to the whole space, which is right for the presence
the sidebar shows and wrong for anything audible. The cooldown is enforced
server-side — a client-side one only slows down people not trying to abuse it,
and a soundboard is the easiest thing here to turn into a weapon. Playing is
open to anyone in the call; deciding what the buttons are needs MANAGE_SPACE.

Account menu: the name in the user bar had cursor-pointer and no handler, so
the interface was already promising a click that did nothing. Offers profile,
status and copy-id — not the Clips or account switching the reference design
shows, which would be dead UI here.

Call timer: startedAt comes from the server, so a late joiner sees the call's
age rather than their own arrival. Empty space rooms are destroyed already,
which is what makes the next call start from zero — no reset logic needed.
2026-08-31 13:45:18 -03:00
devsyncwrld ef5545465d docs: mark GIF favourites, audit log and statistics delivered
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 13:30:26 -03:00
devsyncwrld 1830051732 feat(stats): voice-time and message leaderboards per space
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Voice stays get their own table rather than joining the audit log: that table
records points in time, a call is an interval, and pairing join/leave point
events would leave every query guessing at joins whose leave never arrived.

Sessions are opened and closed inside joinRoom/leaveCurrentRoom rather than at
the seven call sites that reach them, so no path can be missed, and
destroyRoom closes them too — it bypasses leaveCurrentRoom and would otherwise
leak open rows.

A restart leaves sessions open with an unknowable end time. They are closed at
startedAt, discarding that time rather than inventing it: crediting the gap
would hand someone hours they never spent, and the numbers are the point.
Mirrors the existing users.status sweep on boot.

Only closed sessions count, so a figure does not move on every refresh. Bars
scale to the leader, not the total — with five people every share of a total
looks identical. Statistics are readable by any member, since they are the
group's own numbers; the audit log, which names who did what, stays on
MANAGE_SPACE.
2026-08-31 13:29:59 -03:00
devsyncwrld bbb190cbda feat(audit): append-only audit log for spaces
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Records who changed what, and is the mechanism statistics will read — one
event table rather than two logs that drift apart.

The table is deliberately generic (action + target + JSON metadata) so a new
action needs no migration. Writes never throw: a kick must not fail because
its log entry could not be written, since the kick already happened.

Leaving is recorded as a different action from being removed. The same route
serves both, and a log that conflates them misleads exactly when it matters.

Actor is nullable with ON DELETE SET NULL: the event outlives the account, and
a log that vanished with its actor would be worthless. Reads are gated on
MANAGE_SPACE rather than a new permission bit, which would default to nobody
until every role was re-edited. Paging uses the snowflake id, stable even for
two events in the same millisecond, and an action this build does not know
still renders a row.
2026-08-31 13:22:52 -03:00
devsyncwrld fb662bfe12 feat(gif): favourites and category shortcuts
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Favourites are stored server-side per user, so one made on the phone is there
on the desktop — the point of favouriting. The whole result is stored rather
than an id: the provider offers no lookup by id, so an id-only favourite could
not be rendered without re-finding it through search.

Category chips translate their label but not their query, which goes to a
provider that indexes in English.

The star sits beside the tile button rather than inside it: a button within a
button is invalid and swallows the click. Toggling is optimistic and reverts
on failure, and favourites skip both the loading skeleton and the infinite
scroll, which belong to provider-backed browsing only.

Server caps favourites per user and rejects non-http(s) URLs, which become
<img src> in everyone's picker.
2026-08-31 13:12:50 -03:00
devsyncwrld 0fc6abeb6e docs: mark Spotify delivered, drop resolved items
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 13:04:35 -03:00
devsyncwrld 75316b0882 feat(spotify): show the current track as an activity
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
OAuth Authorization Code flow, with tokens kept server-side: refreshing needs
the client secret, so the browser never holds a Spotify token — it asks this
instance what is playing and this instance calls Spotify.

The callback arrives as a plain browser redirect with no Authorization header,
so the OAuth state carries the user id signed with the instance secret and is
compared in constant time; without that, anyone could bind their Spotify
account to another user.

Activities are now tracked per producer. pushActivities replaced the whole
list, so the desktop game detector and Spotify would erase each other — losing
exactly the case this is for, a game and Spotify at once.

Polling backs off when the tab is hidden and keeps the last known track on a
network error rather than reporting 'stopped listening'. A rejected refresh
token (access revoked on Spotify's side) drops the row so the UI stops
claiming a live connection.

Scope is read-only: user-read-currently-playing and user-read-playback-state.

Per the fork's language rule, the new UI ships in en and pt-BR, and this
round also translates the privacy panel.
2026-08-31 12:37:08 -03:00
devsyncwrld c022f2795f docs: record the i18n foundation and translation queue
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 12:25:03 -03:00
devsyncwrld 688a1335cb feat(i18n): language foundation with en and pt-BR
Nothing in the project was translatable — every string sat inline in English.

en.ts is the source dictionary and its type is derived from it, so a typo or a
missing key fails typecheck instead of rendering the raw key at runtime.
pt-BR.ts is deliberately Partial: translation proceeds one system per update
and anything absent falls back to English, so a half-migrated interface is
never broken, only partly English.

Locale is persisted, guessed from the browser on first run, and kept in sync
with <html lang> through a subscription — persisted state rehydrates after
first paint, so a one-off assignment would miss it.

Translates the voice input panel (including the mic test shipped earlier
today) and the profile card as this round's system. Language options are
labelled in the active language, so a wrong pick can always be undone.
2026-08-31 12:24:50 -03:00
devsyncwrld 89e13441c8 docs: record the pt-BR language rule and new system ideas
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 12:18:33 -03:00
devsyncwrld 63afd2fc89 fix(profile): hoist the activity hook above the early return
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The activity selector sat below `if (!isOpen || !user) return null`. `user`
arrives asynchronously, so the hook ran on some renders and not others; React
counts hooks per render and tore the tree down with error #310 as soon as a
profile finished loading.

Move it above the guard and let the selector tolerate a null user. Typecheck
and the suite both passed with the bug in place — TypeScript cannot see hook
order and nothing renders this modal across the null-to-loaded transition.
2026-08-31 12:12:23 -03:00
devsyncwrld d80de49768 chore(caddy): serve the Gitea site alongside the app
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The git server runs as its own stack in /opt/gitea and is reached by container
name over the app's internal network, publishing no host port of its own. Only
the reverse-proxy entry belongs here, where the Caddyfile lives.
2026-08-31 12:07:29 -03:00
devsyncwrld b92a0d837e docs: update roadmap after mic test and activity work
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 12:05:18 -03:00
devsyncwrld d7da0ff203 feat(activity): show the current activity on the profile card
The activity pipeline was already complete end to end — Activity type, store,
WS broadcast, server validation, presence relay, and an ActivityCard used by
four list surfaces — but the profile card rendered none of it, which is the
'Listening to Spotify' block the design calls for.

Add ProfileActivity: richer than ActivityCard because the card has room for
artwork, track and artist, so it reads details/state/assets. All optional, so
it degrades to the bare name that today's process-based detector supplies.

Also scheme-check activity image assets server-side. activity.url was already
restricted to http(s) but assets.largeImage/smallImage were only length-checked
— an asymmetry that was harmless while nothing rendered them, and is not once
they become <img src>: a client could point them at a host it controls and
harvest the IP of everyone opening that profile.
2026-08-31 12:05:01 -03:00
devsyncwrld bfe62d7078 feat(voice): mic test with loopback in voice settings
The level meter only measured a stream a call had already opened, so settings
offered no way to check a mic before joining — the panel said as much.

Add startMicTest/stopMicTest on AudioManager: the processed input bus is
routed to the master output through a dedicated gain node, so the loopback can
be disconnected precisely. Settings had deliberately never opened the mic
itself; a mic test cannot honour that, so the test hands the mic back when it
stops.

Releasing needs two independent guards, because the user may join a call
mid-test: AudioManager only stops the exact stream it opened (identity check,
not a flag), and the caller must consent — the UI reads the call state, which
AudioManager cannot, as it does not import stores. Unmounting mid-test tears
the loopback down too.
2026-08-31 11:59:51 -03:00
devsyncwrld 37407a5ecd feat(voice): open the profile card from voice participants
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The profile popout already existed and was reachable from eleven places —
messages, mentions, avatars, member list, DMs, activity panel — but no voice
surface opened it, so clicking someone during a call did nothing.

Wire it into the voice user rows (VoiceChannel's sidebar list) and the name
label on grid tiles, whose avatar was already a ProfileAvatar; the name beside
it not reacting read as the click failing.

Left mobile alone deliberately: MobileSpacesScreen already opens the profile
from its row wrapper, and MobileVoiceJoinSheet would layer a history-pushed
full-screen profile inside a bottom sheet, which cannot be verified here.
2026-08-31 11:49:57 -03:00
devsyncwrld cad3867027 docs: add fork roadmap
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 11:45:02 -03:00
devsyncwrld 20526e1bc8 feat(gif): outlined GIF glyph, and GIF picker for the profile banner
The composer's GIF button drew a filled rounded rect with the letters knocked
out, which reads as a solid square rather than a picker. Invert it: stroked
outline with filled letters, reusing the original glyph paths scaled to centre.

Banners already accept absolute URLs on both ends (server isValidAssetUrl
allows http(s); the profile render branches on banner.startsWith('http')), so
the picker stores the remote URL directly with no upload path. Previews can now
hold either a blob: or an https: URL, so revoking is guarded — calling
revokeObjectURL on a remote URL is a silent no-op that would hide a mistake.
2026-08-31 11:44:40 -03:00
devsyncwrld c70b0095a9 feat(voice): jump to the call from the voice panel
The channel name under 'Voice Connected' was a plain div. Making it navigate
needed more than an onClick: voiceStore never recorded which space the call
was in, and spaceStore.channels only holds the space currently being viewed —
so after navigating away the call's channel was unresolvable, which is also
why the label degraded to a generic 'Voice Channel'.

Capture space and channel name at join time (the only moment they are
reliable) and use them for both the label and the jump. Covers space calls
and DM calls.
2026-08-31 11:40:45 -03:00
devsyncwrld 08db5374cb build: compile better-sqlite3 from source (no Node 20 prebuilt)
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
better-sqlite3@12.11.1 ships prebuilt binaries for ABI 127/137/141/147 only;
Node 20 is ABI 115, so prebuild-install falls back to node-gyp, which fails on
node:20-slim for lack of python3/make/g++.

Add the toolchain to the builder stage, and in the runtime stage install, use
and purge it inside a single layer so the final image ships no compiler.
2026-08-31 11:09:14 -03:00
TheZwiss 6c7cbee808 docs(pr-template): clarify the CLA checkbox is not the signature (#42)
The CLA Assistant only records a signature when the exact phrase is posted as a comment on the PR, so ticking the checkbox alone left the check red with no explanation (see #39). Spell the required comment out next to the checkbox, using the exact phrase from custom-pr-sign-comment in .github/workflows/cla.yml.
2026-08-25 16:36:24 +02:00
cnrd 8456b8f976 fix(web): stop the profile card re-anchoring to its own avatar (#39)
Avatar opened the profile popout whenever it received a user prop. Since user is how every avatar gets its gradient, colour and status dot, all 22 call sites became profile triggers by accident — including the picture inside the profile card itself, which re-anchored the card to that picture on every click and walked it across the screen (120px right, 36px down, until it pinned at the viewport clamp).

Avatar is now presentational. A new ProfileAvatar carries the open-the-profile behaviour at the five call sites that actually want it. The card's own picture escalates to the full profile modal instead of reopening the card.

The card also places itself off its measured size via the shared computeFloatingPosition engine, replacing six call sites that each hand-computed coordinates against a guessed 460px card height.

Closes #37
2026-08-25 15:51:04 +02:00
TheZwiss 747f1b9c5c ci(cla): store CLA signatures on a dedicated branch (#40)
The CLA Assistant appends each signature as a direct commit to the branch named in 'branch:'. That was main, which the 'Require CI on main' ruleset rejects ('Repository rule violations found'), so signatures were never recorded and the check stayed red however often a contributor signed — a deadlock for every outside contribution.

Point the store at the cla-signatures branch. The ruleset targets the default branch only, so the bot can append there without granting any actor a bypass on main. That branch is seeded with the existing signature and carries its own ruleset blocking deletion and non-fast-forward pushes.
2026-08-25 15:46:11 +02:00
TheZwiss 89467d6f93 fix(server): upgrade better-sqlite3 to 12.x for Node 24 compatibility (#41)
better-sqlite3 11.x removes its environment cleanup hook from Statement::~Statement() after the Node environment is torn down. Node 24.19.0 asserts on the null environment and aborts the worker, so vitest reported 'Worker exited unexpectedly' and exited non-zero on a fully green test run — failing the required check on every PR.

11.x also ships no prebuild for Node 24, so CI compiled it from source on every run. 12.x has prebuilds for that ABI and the V8 13.9 shims 11.x lacks. drizzle-orm declares better-sqlite3 >=7, so the major bump is in range.

Supersedes #26.
2026-08-25 15:41:43 +02:00
TheZwiss 8e13aaa057 Merge pull request #27 from TheZwiss/docs/security-design-record
docs: complete security design record on main (spec + Plan A plan)
2026-07-13 11:40:37 +02:00
Jannis Braun aa5052ba8a docs(security): add scanning/hardening design spec + Plan A implementation plan
Completes the design record on main: Plan B's plan and the federation spec were
already here; this adds the umbrella security spec (source of truth for the
remaining container/web/desktop/remediation workstreams) and Plan A's plan.
2026-07-13 11:36:23 +02:00
TheZwiss 97989fbfb4 Merge pull request #18 from TheZwiss/security/container-hardening
Container hardening: non-root + image scan (report-only)
2026-07-13 11:33:54 +02:00
Jannis Braun bf836e208b Merge main into security/container-hardening (bring up to date for merge) 2026-07-13 11:27:02 +02:00
TheZwiss 53a36cd11a Merge pull request #17 from TheZwiss/security/scanning-pipeline
Security scanning pipeline (report-only)
2026-07-13 11:26:27 +02:00
Jannis Braun d2d6ce9756 Merge main into security/scanning-pipeline (bring up to date for merge) 2026-07-13 11:20:45 +02:00
Jannis Braun 7d1895308d fix(docker): make SARIF upload non-blocking; correct seed-admin/build-stage/restore ownership docs
Final whole-branch review (opus) fixes:
- docker-publish.yml: upload-sarif was if:always() but not continue-on-error, so a
  Trivy SARIF-emit flake would fail the job and SKIP the multi-arch publish. Made it
  non-blocking so a scanner hiccup never blocks a release.
- deployment.md: seed-admin-rotated.txt is root-owned (written via docker exec, which
  bypasses the gosu drop) — reverted an over-correction. Corrected the canonical
  runtime-stage build description (no toolchain; non-root gosu). First-boot chown note.
- restore.sh: comment ownership root -> uid 1000.
2026-07-13 01:21:20 +02:00
Jannis Braun 0b3aa42a09 docs(docker): add maintainer release-gate pull-test note (per review) 2026-07-13 01:13:19 +02:00
Jannis Braun 6d81b63d86 docs(docker): document non-root runtime, data-volume migration, and image scan 2026-07-13 01:09:26 +02:00
Jannis Braun c4929a8b1b ci(docker): scan the amd64 image before publish; attach SBOM + provenance 2026-07-13 01:06:58 +02:00
Jannis Braun 0ec7ddba81 docs(docker): correct stale runtime-deps comment (no build toolchain anymore) 2026-07-13 01:04:13 +02:00
Jannis Braun 3100965c30 fix(docker): run container as non-root (gosu) and drop build toolchain from runtime 2026-07-13 00:58:07 +02:00
Jannis Braun 9d2eeb0963 docs(security): build native arm64 for local Docker verification (host is Apple Silicon; arm64 = Pi target) 2026-07-13 00:54:42 +02:00
Jannis Braun fd8659a964 docs(security): lighter targeted arm64 native-module check (avoid QEMU Vite build timeout) 2026-07-13 00:53:26 +02:00
Jannis Braun 7457146846 docs(security): fold Plan B review findings (proc/1 uid check, arm64 verify, cheap idempotent chown, provenance floor, doc corrections) 2026-07-13 00:52:13 +02:00
Jannis Braun 321428ba1f docs(security): Plan B implementation plan (container hardening + image scan) 2026-07-13 00:42:02 +02:00
Jannis Braun cf4172e81a fix(security): bump trivy-action v0.28.0 -> v0.36.0
v0.28.0's composite action referenced a nested aquasecurity/setup-trivy@v0.2.1
tag that no longer exists, so the action failed to RESOLVE during job setup
(before any step ran) — continue-on-error can't catch a resolution failure, so
both Trivy jobs went red on every run. v0.36.0 pins setup-trivy to a real SHA
(v0.2.6) and still supports scan-type/scan-ref/scanners/format/output.
2026-07-12 23:54:47 +02:00
Jannis Braun e2d09c0d52 fix(security): point OSV-Scanner at runnable subpath action; doc accuracy fixes
- OSV-Scanner ref was google/osv-scanner-action@<sha> (metadata-only root
  action, no runs:) -> subpath google/osv-scanner-action/osv-scanner-action
  which carries the docker action + scan-args input. Root ref would fail to
  load and redden the job on every run (caught in final whole-branch review).
- security-scanning.md: note gitleaks findings land in job log (not SARIF);
  add scorecard branch_protection_rule trigger; mark SBOM/provenance as not-
  yet-live. CLAUDE.md row: image scan is a later plan, not current.
2026-07-12 23:51:00 +02:00
Jannis Braun cb524675cc docs(security): document the scanning pipeline + maintainer checklist 2026-07-12 23:51:00 +02:00
Jannis Braun 4758ca46fa ci(security): SHA-pin all actions and add harden-runner (audit) 2026-07-12 23:51:00 +02:00
Jannis Braun 21d783e257 ci(security): add OpenSSF Scorecard workflow 2026-07-12 23:51:00 +02:00
Jannis Braun 3f75ff6e36 ci(security): add CodeQL SAST workflow (javascript-typescript) 2026-07-12 23:51:00 +02:00
Jannis Braun 74ae929ab4 ci(security): add report-only security scan workflow (gitleaks, OSV, Trivy) 2026-07-12 23:51:00 +02:00
Jannis Braun 807afba45a ci(security): add Dependabot config (npm + actions + docker) 2026-07-12 23:51:00 +02:00
BadAtCaptchasandJannis Braun 1e6c7c6042 Enhance Windows dev support (#13)
Makes local dev work on Windows: cross-env for the server dev port, pnpm --parallel to run server+web together (replacing the POSIX-only '&'), PowerShell setup docs, engines widened to Node >=20, and a Node 20 + 24 CI matrix.

CI keeps a stable required 'Build & test' status via an aggregate gate job so the matrix rename doesn't drop the context the main ruleset requires.

Co-authored-by: BadAtCaptchas <2359196+BadAtCaptchas@users.noreply.github.com>
Co-authored-by: Jannis Braun <151788261+TheZwiss@users.noreply.github.com>
2026-07-12 13:10:43 +02:00
TheZwissandBadAtCaptchas 43cab41e60 fix(spaces): clearer 403 for non-members on invite endpoint (#14)
Non-members already can't mint invite codes (permissions resolve to zero for them since 85e1975f), but hasPermission reports it as a missing CREATE_INVITE permission, which is misleading. Return 'Space membership required' for the non-member case instead.

Message wording from #12 by BadAtCaptchas.

Co-authored-by: BadAtCaptchas <2359196+BadAtCaptchas@users.noreply.github.com>
2026-07-12 12:51:37 +02:00
TheZwiss d76e06a023 refactor(federation): consolidate inbound S2S-auth preamble into one helper (#11)
Six S2S-HMAC endpoints repeated the same inbound-auth preamble verbatim
(parse federation headers -> resolve active peer -> optional per-peer rate
limit -> verify HMAC signature -> nonce replay protection). Extract it into
authenticateS2SPeer() so the trust boundary has a single, tested definition.

Adopters (preamble only; every post-auth side effect, body validation, and
response is unchanged):
- DELETE /api/federation/identity      (no rate limiter; warns on missing nonce)
- POST   /api/federation/relay         (relay limiter; warns; keeps in-handler
                                        epoch-baseline populate + nonce ratchet)
- POST   /api/federation/sync          (no limiter; warns with the [sync] tag;
                                        keeps in-handler nonce ratchet)
- POST   /api/federation/users/lookup       (lookup limiter, Retry-After 60)
- POST   /api/federation/users/by-home-id   (same)
- POST   /api/federation/verify-attach-proof(shares lookup bucket, Retry-After 60)

Deliberate non-adopters, each keeping a load-bearing gate the helper would
flatten (documented at each site + in the helper docstring):
- POST /api/federation/epoch        gates status != 'revoked' (peer recovery),
                                    400 on missing headers, no nonce check
- POST /api/federation/peer/rotate  active-only but no nonce check
- POST /api/federation/peer/denied  awaiting_approval gate (404/409), synthetic
                                    no-grace secret verify

Behavior-preserving. The rate limiter is injected (plain { limited, retryAfter }),
so the limit still fires BEFORE signature verification. The only ordering change:
/relay's opportunistic epoch-baseline populate now runs just after the shared
preamble (i.e. after the nonce check) instead of between signature and nonce.
This is provably equivalent for every reachable honest-peer state (a duplicate
nonce means the baseline is already non-null; a valid-signature-but-no-nonce
request from a nonce-supporting peer is unreachable in transit and carries no
security/correctness consequence) and the populate is documented as not
affecting relay accept/reject.

Adds a dedicated unit test covering the full decision table (headers, peer
status, rate-limit + Retry-After, rate-limit-before-signature ordering,
signature, nonce duplicate/missing, log flag + context suffix, success). Full
server suite green (804 tests).
2026-07-10 03:08:09 +02:00
TheZwiss c79bf91398 refactor(server): dedupe federation rate limiters and response signing (#10)
Phase C cleanup follow-up to the routes/federation split (#9). Behavior-
preserving; full server suite (790 tests) green.

A) rateLimits.ts: the four near-identical sliding-window limiters
   (accept/relay/lookup/ensure) and their duplicated prune loops collapse
   into one createLimiter(windowMs, max) factory. Per-call and periodic-
   sweep semantics are preserved exactly, including that lookup buckets are
   pruned per-call but never swept (unchanged from before). 177 -> 101 lines.

B) Extract sendSignedJson(reply, payload, hmacSecret) — the single
   definition of how this instance signs an S2S JSON response — and use it
   in the /epoch and /verify-attach-proof|reattach handlers, replacing two
   copies of the build-headers-and-send boilerplate.
2026-07-10 02:23:14 +02:00
TheZwiss 94fe73522d refactor(server): split federation routes into cohesive modules (#9)
routes/federation.ts had grown to 7.6k lines, spanning HTTP route
registration, federated identity resolution, ~30 inbound relay event
processors, DM reconciliation, and rate-limiting internals — too large
to review or hold in context, and awkward to change safely.

Split the implementation into 18 focused modules under routes/federation/
(helpers, events/, handlers/) and keep routes/federation.ts as a thin
barrel that re-exports the public API and composes the HTTP registrars
into federationRoutes(). No import paths change anywhere else.

Pure move, no behavior change:
- 61/61 named functions byte-identical; only deltas are 2 dynamic-import
  paths adjusted for the new directory depth
- public export surface unchanged (barrel re-exports all 22 symbols)
- all 30 endpoints preserved (identical verb+path set)
- typecheck, build, and full server suite (790 tests) green

Docs: update federation.md source-file map; add split design doc.
2026-07-10 02:08:03 +02:00
TheZwiss 180228f2d1 ci: bump actions to Node 24 runtimes to clear deprecation warning (#8)
GitHub is deprecating the Node 20 runtime for JS actions; every run printed a
warning that actions/checkout@v4, actions/setup-node@v4 and pnpm/action-setup@v4
were being force-run on Node 24. Bump each to its first Node 24 major (v5) across
all workflows — the smallest jump that clears the warning, avoiding the extra
behavior changes in checkout v6/v7 (credential persistence, fork-PR blocking)
that don't apply here. Our checkout jobs use push/pull_request, not
pull_request_target/workflow_run, so none are affected regardless.

Also bump the GitHub Pages actions in deploy-pages.yml (configure-pages v5->v6,
upload-pages-artifact v3->v5, deploy-pages v4->v5), which were likewise on Node
20. Inputs are unchanged; pnpm still pinned to 10.34.3 via the version input and
the packageManager field.
2026-07-10 01:02:47 +02:00
TheZwiss c7d88481ad test: give federation integration suites a realistic per-test timeout (#7)
The three test/ files boot real federated instances and drive S2S over HTTP,
with several tests deliberately waiting on log matchers (e.g. logMatched(...,
1_000) per remote). Vitest's 5s default per-test timeout is meant for unit tests
and is too tight here: under CI load the multi-remote fan-out tests in
federation-identity-deletion intermittently timed out (observed on the post-merge
main run), producing a flaky red check.

Set a file-level testTimeout of 30s in each of the three harness-based suites via
vi.setConfig. Scoped per-file so unit tests keep the strict 5s default; a genuine
hang still trips the 30s ceiling well before the 90s hook budget. No test logic
changed.
2026-07-10 00:51:18 +02:00
BadAtCaptchasandJannis Braun 9b6d1b18eb Fix federated attachment downloads allowing SSRF via redirects (#1)
* Backspace — initial public release

* chore: pin Node 20 (LTS) and pnpm 10.34.3

Unpinned `pnpm@latest` in the Dockerfile made fresh builds non-reproducible:
`latest` now resolves to pnpm 11, but the committed lockfile targets pnpm 10, so
`pnpm install --frozen-lockfile` fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH on
a clean host. Pin pnpm to 10.34.3 across the Dockerfile, the `packageManager`
field, and the release workflow; pin Node to 20 (LTS) via `.nvmrc` and `engines`
so Docker, CI, and from-source builds all use the same tested toolchain.

Also corrects the docs: the lockfile is v9.0 (requires pnpm 10, not "pnpm 8+"),
and "Node 20+" implied untested newer majors were supported.

* fix(compose): fail fast on missing JWT_SECRET instead of a silent restart loop

Without JWT_SECRET the server throws at boot (config.ts) and `restart:
unless-stopped` turns that into an endless crash loop that is invisible unless
you run `docker compose logs backspace`. Guard it exactly like DOMAIN already is,
so `docker compose up` stops immediately with an actionable message. install.sh
generates the secret before bringing the stack up, so this only affects the
manual `cp .env.example .env && docker compose up` path.

* docs(env): make the JWT_SECRET requirement explicit in .env.example

Spell out that JWT_SECRET is required and must be a strong 32+ char value, and
that the empty default is intentional (docker compose fails fast rather than the
server boot-looping). Pairs with the compose-level guard added in 592c23f.

* fix(desktop): don't let uiohook-napi rebuild abort the whole workspace install

The @backspace/desktop postinstall runs `electron-rebuild -f -w uiohook-napi`,
which compiles a native module from source and needs a C++ toolchain
(make/g++/python3). On a bare host without those — a typical VPS a self-hoster
clones onto — that failure took the ENTIRE `pnpm install` down (exit 255), so the
server never installed either, even though uiohook-napi is desktop-only and
irrelevant to hosting.

Make the rebuild non-fatal: on failure it now prints a clear one-line warning and
continues. Machines with build tools (CI, desktop devs) are byte-identical — the
fallback never fires; only toolless boxes (which aren't building the desktop app
anyway) skip it. Verified on a bare x86 box: full `pnpm install` now exits 0 and
the server builds and boots from source. Docs note the build-tool requirement for
desktop work and point self-hosters at the Docker installer.

* feat(deploy): three deployment modes + prebuilt multi-arch image for robust self-hosting

Make Backspace self-hostable in any homelab environment, not just a clean host
that owns ports 80/443.

install.sh is now mode-aware and auto-detects which fits:
  - allinone (default): bundled Caddy + auto-HTTPS — unchanged behavior
  - proxy: behind your own reverse proxy (nginx / Traefik / Caddy / Nginx Proxy
    Manager / SWAG) — app published on 127.0.0.1:APP_PORT, no bundled Caddy,
    prints paste-ready proxy snippets
  - tunnel: behind a tunnel (Cloudflare / Tailscale) — same, plus a 90MB upload
    cap (under Cloudflare's 100MB body limit) and voice force-disabled (WebRTC
    over UDP can't traverse a tunnel)

Port detection is Docker-aware (consults `docker ps` published ports, not just
`ss`), so a host whose proxy already owns 80/443 via iptables DNAT — with no
listening socket for `ss` to see — is correctly detected as "taken" instead of
dead-ending.

docker-compose.proxy.yml is a small overlay, layered via COMPOSE_FILE (written
into .env so no `-f` flags are ever needed), that publishes the loopback port and
parks Caddy in an inert profile. The base compose file is untouched, so All-in-One
behaves exactly as before.

Prebuilt image: .github/workflows/docker-publish.yml builds and pushes a
multi-arch (linux/amd64 + linux/arm64) image to ghcr.io/thezwiss/backspace on
release tags (and manual dispatch), so weak/ARM hosts skip the ~1.6GB local build
(the Vite build OOMs small ARM boxes). install.sh and docker-compose.yml default
to pulling it, fall back to an image already present on the host, and finally to a
from-source build — AGPL §13 commit stamping preserved on every path. Kept
deliberately separate from the desktop-installer workflow (release.yml).

Docs: README gains a "Deployment modes" section (all three modes, nginx / Caddy /
Traefik snippets, GUI-proxy field-by-field, cloudflared ingress, the update path,
and voice-per-mode caveats); docs/systems/deployment.md updated to match.

Verified live on a throwaway VM: proxy + all-in-one end-to-end through install.sh
(with a real Let's Encrypt cert), tunnel config generation, loopback-only binding,
and the local-image fallback path.

* Fix federation file download SSRF (#1)

---------

Co-authored-by: Jannis Braun <151788261+TheZwiss@users.noreply.github.com>
2026-07-10 00:27:05 +02:00
TheZwiss 628e4dec3e ci: run typecheck, build & tests on PRs and main (#6)
* ci: run typecheck, build & tests on PRs and main

Add a GitHub Actions workflow (.github/workflows/ci.yml) that installs on
Node 20 (the pinned runtime), builds shared/server/web, typechecks desktop,
and runs the full vitest suite across all packages on every pull request and
push to main. The repo already had 500+ tests but only ever ran them locally.

Also wires up the missing test/typecheck plumbing this exposed:
- web package had 55 test files but no test script — add test/test:watch/typecheck
- add root-level test and typecheck aggregate scripts
- fix a latent web type error the new typecheck surfaced: reference
  vite-plugin-pwa/react types so virtual:pwa-register/react is typed
  (useRegisterSW callback params were implicit any; the tsc half of the web
  build script has been failing, masked by vite build ignoring type errors)

* test: fix two latent failures surfaced by CI on Node 20

- server: exclude dist/** from vitest. Vitest 4's default exclude dropped
  dist/, so after `pnpm build` emits compiled .test.js files, vitest ran those
  stale copies alongside src/*.test.ts and they failed (compiled vi.mock paths
  resolve differently).
- web: polyfill Blob.prototype.stream in the jsdom test setup. jsdom's Blob has
  no .stream() on Node 20 (Node 25 provided one, masking this locally); undici's
  Response constructor calls blob.stream(), so new Response(blob) threw
  'object.stream is not a function'.
2026-07-10 00:15:41 +02:00
TheZwiss 0727d5a3b3 Add landing page and Pages deploy workflow 2026-07-08 00:45:31 +02:00
TheZwiss 3513a3dde9 docs: tighten public copy, add comparison, templates, and social preview
* docs: tighten README, CONTRIBUTING, and CLA prose

* docs: add platform comparison and README FAQ

* chore: add issue and pull request templates

* chore: add repository social preview image
2026-07-07 23:39:33 +02:00
Jannis Braun 95b545d8b2 chore(deploy): allow a gitignored local override for deploy targets
deploy.sh now sources ./.deploy.local at runtime if it exists, letting a
maintainer point the script at their own hosts without committing them — the
tracked defaults stay placeholders. .deploy.local and .deploy-local/ are
gitignored so real hostnames/IPs never land in the repo.
2026-07-07 19:45:51 +02:00
Jannis Braun 85e1975fa5 fix(permissions): deny space permissions to non-members (invite-bypass)
computePermissions() returned the space @everyone role's permissions without
verifying the caller had joined the space. Because CREATE_INVITE is in
DEFAULT_EVERYONE_PERMISSIONS, any authenticated user could mint an invite code
for a request-only space — whose id is listed by /api/spaces/explore — and then
self-join via /api/spaces/:id/join, bypassing the join-request approval flow.
The same gap let non-members read message history and search default channels.

Root cause:
- computePermissions now returns 0n for non-members (space owner and instance
  admin still short-circuit first, so they are unaffected).

Defense in depth (request-only spaces are approval-gated, never invite-joinable):
- both invite-code join endpoints reject visibility='request' (private stays
  invite-joinable — its only entry path; public too).
- POST /api/spaces/:id/invite refuses to hand out a code for request spaces.
- POST /api/dm/space-invite refuses to card a local request space, checked by
  space id against the local table so a spoofed spaceInstanceOrigin can't slip
  past it.
- InviteModal hides the invite affordances for request spaces.

Also removes the unused computeCategoryPermissions(), which duplicated the
resolution algorithm without the membership gate.

Adds unit + route + component tests covering non-member/member/owner/admin
resolution and the request/private/public visibility matrix.

Reported-by: BadAtCaptchas (#2)
2026-07-07 19:45:51 +02:00
github-actions[bot] 26bb0be7af @BadAtCaptchas has signed the CLA in TheZwiss/backspace#1 2026-07-07 14:36:01 +00:00
github-actions[bot] e27e52f267 Creating file for storing CLA Signatures 2026-07-07 14:35:11 +00:00
Jannis Braun 531b496618 feat(deploy): three deployment modes + prebuilt multi-arch image for robust self-hosting
Make Backspace self-hostable in any homelab environment, not just a clean host
that owns ports 80/443.

install.sh is now mode-aware and auto-detects which fits:
  - allinone (default): bundled Caddy + auto-HTTPS — unchanged behavior
  - proxy: behind your own reverse proxy (nginx / Traefik / Caddy / Nginx Proxy
    Manager / SWAG) — app published on 127.0.0.1:APP_PORT, no bundled Caddy,
    prints paste-ready proxy snippets
  - tunnel: behind a tunnel (Cloudflare / Tailscale) — same, plus a 90MB upload
    cap (under Cloudflare's 100MB body limit) and voice force-disabled (WebRTC
    over UDP can't traverse a tunnel)

Port detection is Docker-aware (consults `docker ps` published ports, not just
`ss`), so a host whose proxy already owns 80/443 via iptables DNAT — with no
listening socket for `ss` to see — is correctly detected as "taken" instead of
dead-ending.

docker-compose.proxy.yml is a small overlay, layered via COMPOSE_FILE (written
into .env so no `-f` flags are ever needed), that publishes the loopback port and
parks Caddy in an inert profile. The base compose file is untouched, so All-in-One
behaves exactly as before.

Prebuilt image: .github/workflows/docker-publish.yml builds and pushes a
multi-arch (linux/amd64 + linux/arm64) image to ghcr.io/thezwiss/backspace on
release tags (and manual dispatch), so weak/ARM hosts skip the ~1.6GB local build
(the Vite build OOMs small ARM boxes). install.sh and docker-compose.yml default
to pulling it, fall back to an image already present on the host, and finally to a
from-source build — AGPL §13 commit stamping preserved on every path. Kept
deliberately separate from the desktop-installer workflow (release.yml).

Docs: README gains a "Deployment modes" section (all three modes, nginx / Caddy /
Traefik snippets, GUI-proxy field-by-field, cloudflared ingress, the update path,
and voice-per-mode caveats); docs/systems/deployment.md updated to match.

Verified live on a throwaway VM: proxy + all-in-one end-to-end through install.sh
(with a real Let's Encrypt cert), tunnel config generation, loopback-only binding,
and the local-image fallback path.
2026-07-06 13:36:29 +02:00
Jannis Braun e7f41b5609 fix(desktop): don't let uiohook-napi rebuild abort the whole workspace install
The @backspace/desktop postinstall runs `electron-rebuild -f -w uiohook-napi`,
which compiles a native module from source and needs a C++ toolchain
(make/g++/python3). On a bare host without those — a typical VPS a self-hoster
clones onto — that failure took the ENTIRE `pnpm install` down (exit 255), so the
server never installed either, even though uiohook-napi is desktop-only and
irrelevant to hosting.

Make the rebuild non-fatal: on failure it now prints a clear one-line warning and
continues. Machines with build tools (CI, desktop devs) are byte-identical — the
fallback never fires; only toolless boxes (which aren't building the desktop app
anyway) skip it. Verified on a bare x86 box: full `pnpm install` now exits 0 and
the server builds and boots from source. Docs note the build-tool requirement for
desktop work and point self-hosters at the Docker installer.
2026-07-06 01:52:06 +02:00
Jannis Braun 24accc3647 docs(env): make the JWT_SECRET requirement explicit in .env.example
Spell out that JWT_SECRET is required and must be a strong 32+ char value, and
that the empty default is intentional (docker compose fails fast rather than the
server boot-looping). Pairs with the compose-level guard added in 028005d.
2026-07-06 01:20:08 +02:00
Jannis Braun 028005d487 fix(compose): fail fast on missing JWT_SECRET instead of a silent restart loop
Without JWT_SECRET the server throws at boot (config.ts) and `restart:
unless-stopped` turns that into an endless crash loop that is invisible unless
you run `docker compose logs backspace`. Guard it exactly like DOMAIN already is,
so `docker compose up` stops immediately with an actionable message. install.sh
generates the secret before bringing the stack up, so this only affects the
manual `cp .env.example .env && docker compose up` path.
2026-07-06 01:12:14 +02:00
Jannis Braun 9d3f72be75 chore: pin Node 20 (LTS) and pnpm 10.34.3
Unpinned `pnpm@latest` in the Dockerfile made fresh builds non-reproducible:
`latest` now resolves to pnpm 11, but the committed lockfile targets pnpm 10, so
`pnpm install --frozen-lockfile` fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH on
a clean host. Pin pnpm to 10.34.3 across the Dockerfile, the `packageManager`
field, and the release workflow; pin Node to 20 (LTS) via `.nvmrc` and `engines`
so Docker, CI, and from-source builds all use the same tested toolchain.

Also corrects the docs: the lockfile is v9.0 (requires pnpm 10, not "pnpm 8+"),
and "Node 20+" implied untested newer majors were supported.
2026-07-06 01:04:59 +02:00
410 changed files with 68457 additions and 9514 deletions
+43 -1
View File
@@ -4,19 +4,54 @@
# Your server's public domain name (required)
DOMAIN=example.com
# ─── Deployment mode ───────────────────────────────────────
# How this instance is exposed to the internet. ./install.sh sets these for you;
# only touch them for a fully manual setup.
#
# allinone (default) — the bundled Caddy owns ports 80/443 and does automatic
# HTTPS for DOMAIN. Requires 80/443 free on this host.
# proxy — you run your OWN reverse proxy (nginx, Traefik, Caddy,
# Nginx Proxy Manager, SWAG …). No bundled Caddy; the app
# is published on 127.0.0.1:APP_PORT for your proxy to
# forward to. Use with:
# docker compose -f docker-compose.yml \
# -f docker-compose.proxy.yml up -d
# tunnel — same as proxy, but fronted by a tunnel (Cloudflare
# Tunnel, Tailscale …). Point the tunnel's ingress at
# http://127.0.0.1:APP_PORT. NOTE: voice/WebRTC does NOT
# traverse a tunnel, and Cloudflare caps request bodies
# at 100 MB — set MAX_UPLOAD_SIZE below that (see below).
DEPLOY_MODE=allinone
# Host loopback port the app is published on in `proxy`/`tunnel` mode (ignored in
# `allinone` mode). Your reverse proxy / tunnel forwards to 127.0.0.1:APP_PORT.
# APP_PORT=8080
# ─── Server ─────────────────────────────────────────────────
# Production/Docker listen port (Caddy reverse-proxies to it). Local development
# ignores this and uses 3005 — the Vite dev proxy target — set by `pnpm dev`.
PORT=3000
HOST=0.0.0.0
# Authentication — generate with: openssl rand -hex 32
# ─── Authentication (REQUIRED) ──────────────────────────────
# Secret that signs login tokens. This MUST be set to a strong random value.
# Leaving it empty is intentional — `docker compose up` then fails immediately
# with a clear message instead of the server boot-looping. Generate one with:
#
# openssl rand -hex 32
#
# (./install.sh fills this in for you automatically — you only touch this when
# configuring by hand.) Must be at least 32 characters.
JWT_SECRET=
# Registration — set to false to close signups after initial setup
REGISTRATION_OPEN=true
# Max file upload size in bytes (default: 100MB)
# TUNNEL USERS: Cloudflare (free/pro) hard-caps request bodies at 100 MB, so the
# 100 MB default lets uploads fail at the edge. Set this below the cap, e.g.
# 94371840 (90 MB), leaving headroom for multipart overhead. install.sh does
# this automatically when you pick `tunnel` mode.
MAX_UPLOAD_SIZE=104857600
# ─── AGPL-3.0 § 13 Source Offer ────────────────────────────
@@ -31,6 +66,13 @@ MAX_UPLOAD_SIZE=104857600
# dev — the app reports commit=null. Set manually only for custom build pipelines.
# BACKSPACE_COMMIT=
# ─── Prebuilt image (GHCR) ─────────────────────────────────
# By default the stack pulls the prebuilt multi-arch image
# ghcr.io/thezwiss/backspace:latest (published from tagged releases). Pin a
# specific version for reproducibility, or point at your own fork's registry.
# BACKSPACE_IMAGE=ghcr.io/thezwiss/backspace
# BACKSPACE_IMAGE_TAG=latest
# ─── LiveKit Voice/Video ───────────────────────────────────
# To enable voice/video, fill in all three values below and add:
# COMPOSE_PROFILES=voice
+82
View File
@@ -0,0 +1,82 @@
name: Bug report
description: Report something that is broken or behaving incorrectly.
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a bug. Please search existing issues first to avoid duplicates.
For security vulnerabilities, do not open a public issue. See SECURITY.md and report privately.
- type: textarea
id: what-happened
attributes:
label: What happened
description: A clear description of the bug and what you expected instead.
placeholder: When I do X, Y happens. I expected Z.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
description: The exact steps that trigger the problem.
placeholder: |
1. Go to ...
2. Click ...
3. See ...
validations:
required: true
- type: dropdown
id: deploy-mode
attributes:
label: Deployment mode
description: How is this instance deployed?
options:
- All-in-One (bundled Caddy)
- Behind my own reverse proxy
- Behind a tunnel (Cloudflare, Tailscale)
- Local development (pnpm dev)
- Not sure
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Where did it happen
description: Which client were you using?
options:
- Web browser
- Desktop app (Electron)
- Installed PWA on mobile
validations:
required: true
- type: input
id: version
attributes:
label: Backspace version
description: The version shown in the app, or the image tag / commit you deployed.
placeholder: "1.0.0"
validations:
required: true
- type: dropdown
id: subsystems
attributes:
label: Does it involve voice or federation
description: These subsystems have their own moving parts, so it helps to know up front.
multiple: true
options:
- Voice or video (LiveKit)
- Screen sharing
- Federation between instances
- None of these
- type: textarea
id: logs
attributes:
label: Relevant logs
description: Server logs (docker compose logs backspace) or browser console output, if any. This is automatically formatted as code.
render: shell
- type: input
id: environment
attributes:
label: Browser and OS
placeholder: "Firefox 128 on Ubuntu 24.04, or Chrome 126 on Windows 11"
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/TheZwiss/backspace/security/advisories/new
about: Do not open a public issue for security problems. Report privately here.
- name: Question or setup help
url: https://github.com/TheZwiss/backspace/discussions
about: For usage questions, deployment help, and general discussion.
@@ -0,0 +1,56 @@
name: Feature request
description: Suggest a new capability or an improvement to an existing one.
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Open an issue before starting work on anything non-trivial, so we can agree on direction first.
Please check the README feature list and existing issues before filing.
- type: textarea
id: problem
attributes:
label: The problem
description: What are you trying to do that Backspace does not support today?
placeholder: As a space admin, I want to ... so that ...
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: How you imagine it working. Rough ideas are fine.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
description: Which part of the project does this touch?
options:
- Text chat and messaging
- Voice, video, or screen sharing
- Spaces, roles, and permissions
- Direct messages and friends
- Federation between instances
- Admin and moderation
- Desktop app
- Mobile and PWA
- Deployment and self-hosting
- Other
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Workarounds you have tried, or how other tools handle this.
- type: dropdown
id: federation-impact
attributes:
label: Federation compatibility
description: Would this need to work across peered instances? If unsure, leave the default.
options:
- Not sure
- Yes, it should work across federated instances
- No, it is local to a single instance
+30
View File
@@ -0,0 +1,30 @@
<!-- Thanks for contributing. Keep one logical change per pull request. -->
## What this changes
<!-- A short summary of the change and the problem it solves. Link the issue it addresses. -->
Closes #
## Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor or cleanup
- [ ] Documentation
- [ ] Other
## Checklist
- [ ] `pnpm build` succeeds (shared types, server, and web all build)
- [ ] `pnpm dev` starts the server and web client without errors
- [ ] Tests pass where applicable (`pnpm test`)
- [ ] I updated the relevant `docs/systems/` spec if this changed schema, API routes, WebSocket events, the federation protocol, permissions, or the design system
- [ ] This change resolves the correct federated identity where it compares IDs, checks permissions, or talks to remote servers (no assumption of a single global user ID)
- [ ] I have read and agree to the [CLA](../CLA.md) — ticking this box is not the
signature. After opening this PR, post a separate comment containing exactly:
`I have read the CLA Document and I hereby sign the CLA`
## Notes for reviewers
<!-- Anything that helps review: screenshots for UI, migration notes, edge cases, follow-ups. -->
+44
View File
@@ -0,0 +1,44 @@
# Dependabot keeps dependencies and CI actions patched. Three ecosystems:
# - npm → the pnpm workspace (Dependabot reads pnpm-lock.yaml v9)
# - github-actions → action version bumps (feeds the SHA-pin comments)
# - docker → the Dockerfile base image (FROM node:20-slim)
#
# NOTE (intentional): there is NO docker entry for docker-compose.yml. It sits
# at the same "/" directory (a second docker entry would collide on
# ecosystem+directory), and Dependabot's docker ecosystem parses Dockerfiles,
# not `image:` refs in compose. The pinned caddy / livekit-server compose images
# are updated MANUALLY — see the maintainer checklist in
# docs/systems/security-scanning.md.
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10
groups:
# One grouped PR for routine minor/patch bumps to cut PR noise.
npm-minor-patch:
update-types:
- minor
- patch
ignore:
# uiohook-napi is pinned by an exact-version pnpm patch
# (patches/uiohook-napi@1.5.5.patch). A bump makes the patch path stop
# matching, breaking `pnpm install --frozen-lockfile` in CI and both
# Docker stages until the patch is regenerated. Bump it by hand.
- dependency-name: uiohook-napi
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
+92
View File
@@ -0,0 +1,92 @@
# Compila o APK do Android no GitHub e o devolve como artefato.
#
# O runner entra só como máquina de build, pelo mesmo motivo do desktop: a VPS
# tem ~2 GB de RAM livres e roda a produção, então um Gradle com o SDK do
# Android ali dentro arrisca derrubar as chamadas de quem está usando o app.
#
# O APK sai **sem assinatura**. A chave de assinatura fica na VPS e não sobe
# para cá: quem assina é /opt/ops/release-android.sh, depois de baixar este
# artefato. Um APK assinado é instalável por qualquer um, e a chave é o que
# prova que uma atualização veio de nós — ela não tem por que existir em duas
# máquinas.
name: Android APK
on:
workflow_dispatch:
push:
tags: ['android-v*']
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
# cache/cache-dependency-path apontando para o npm deste pacote não é só
# otimização: sem eles o setup-node lê o campo `packageManager` do
# package.json da raiz, tenta usar pnpm — que este workflow não instala —
# e falha antes de rodar qualquer coisa.
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 22
cache: npm
cache-dependency-path: packages/mobile/package-lock.json
# npm, não pnpm: packages/mobile fica fora do workspace de propósito
# (ver pnpm-workspace.yaml). Ele tem o próprio package-lock.json.
- name: Install Capacitor
working-directory: packages/mobile
run: npm ci
# Copia a config e os arquivos de public/ para dentro do projeto nativo.
# Sem isso o APK sai com o que estiver commitado em assets/, que pode
# estar velho — esses arquivos são gerados, não versionados.
- name: Sync Capacitor
working-directory: packages/mobile
run: npx cap sync android
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ hashFiles('packages/mobile/android/**/*.gradle', 'packages/mobile/android/gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-
- name: Build unsigned release APK
working-directory: packages/mobile/android
run: ./gradlew assembleRelease --no-daemon --stacktrace
# O nome do arquivo muda conforme a versão do AGP (app-release-unsigned.apk
# ou app-release.apk quando não há config de assinatura). Localiza em vez
# de supor, e falha alto se nada saiu — um artefato vazio só apareceria
# como erro lá na frente, na hora de assinar.
- name: Collect APK
run: |
set -euo pipefail
mkdir -p out
found=$(find packages/mobile/android/app/build/outputs/apk/release -name '*.apk' | head -1)
[ -n "$found" ] || { echo "::error::o build não produziu APK nenhum"; exit 1; }
cp "$found" out/resenhacord-unsigned.apk
ls -l out/
- name: Upload APK artifact
uses: actions/upload-artifact@v4
with:
name: resenhacord-apk-unsigned
path: out/resenhacord-unsigned.apk
retention-days: 14
if-no-files-found: error
+97
View File
@@ -0,0 +1,97 @@
# Caminho rápido: só Windows x64, disparado à mão.
#
# O `release.yml` continua sendo o release de verdade (todas as plataformas,
# publica release e alimenta o electron-updater). Este aqui existe para quando
# você só quer um .exe para testar, sem marcar versão.
#
# Arquivo separado de propósito: o release.yml veio do upstream e recebe merges;
# mexer nele criaria conflito a cada atualização.
name: Build Windows (rápido)
on:
workflow_dispatch:
inputs:
arch:
description: Arquitetura
required: false
default: x64
type: choice
options: [x64, arm64]
permissions:
contents: read
jobs:
build:
# windows-2022, não windows-latest: a imagem latest traz o Visual Studio 18,
# que o node-gyp embutido no electron-rebuild não detecta ao compilar os
# módulos nativos. Mesma razão documentada no release.yml.
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 20
cache: pnpm
# ~100MB de binários do Electron e das ferramentas do NSIS, baixados a
# cada execução sem isto. É o maior ganho depois de cortar o arm64.
- name: Cache Electron binaries
uses: actions/cache@v4
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: electron-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared package
run: pnpm --filter @backspace/shared build
# O postinstall termina em `|| console.warn` para que quem não tem
# ferramentas de build consiga instalar. No CI isso transforma falha em
# sucesso silencioso: o instalador sai sem o módulo nativo e o app
# compartilha tela sem som, sem erro nenhum. Então verifica-se o
# resultado em vez de confiar no código de saída.
- name: Verify native audio module compiled
shell: bash
run: |
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' 2>/dev/null | head -5)
if [ -z "$found" ]; then
echo "::error::electron-native-screenshare has no compiled .node — the installer would ship without system-audio capture"
find node_modules/.pnpm -maxdepth 1 -name 'electron-native-screenshare*' -printf '%p\n' 2>/dev/null || true
exit 1
fi
echo "OK:"; echo "$found"
- name: Compile desktop TypeScript
working-directory: packages/desktop
run: pnpm exec tsc
# --publish never: sem release, sem precisar de tag nem bump de versão.
# O instalador sai como artefato desta execução.
- name: Build installer
working-directory: packages/desktop
run: pnpm exec electron-builder --win --${{ inputs.arch || 'x64' }} --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload installer
uses: actions/upload-artifact@v4
with:
name: backspace-windows-${{ inputs.arch || 'x64' }}
path: packages/desktop/dist-electron/*.exe
retention-days: 14
if-no-files-found: error
+92
View File
@@ -0,0 +1,92 @@
name: CI
# Runs the workspace's typecheck, production build, and full test suite on every
# pull request and on pushes to main. Purpose: catch compile errors and test
# regressions before merge instead of relying on each contributor running tests
# locally. Once this check is green on a PR, enable branch protection on `main`
# ("Require status checks to pass" → select "Build & test") to make it blocking.
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
# A newer commit on the same branch/PR supersedes in-flight runs — cancel the
# stale one so a rapid push sequence doesn't queue redundant CI.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
name: Build & test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [20, 24]
steps:
- name: Harden the runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ matrix.node-version }}
cache: pnpm
# The desktop postinstall tries to rebuild the native uiohook-napi module.
# It fails gracefully (|| warn) without X11 dev headers, and nothing in CI
# needs the native binary — the desktop tests and TS compile are pure JS —
# so we intentionally skip installing those headers to keep CI fast.
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Compiles shared → server → web (each runs tsc; web also runs the Vite
# production build). This is the typecheck + build gate for those three.
- name: Build shared, server & web
run: pnpm build
# Desktop is not part of `pnpm build` (that produces an Electron installer,
# which release.yml owns). Type-check its source here so desktop TS errors
# surface on PRs rather than only at release-tag time.
- name: Typecheck desktop
run: pnpm --filter @backspace/desktop build:ts
# Runs every package's `test` script (server, web, desktop) via vitest.
- name: Test
run: pnpm -r test
# Aggregate gate reporting a single, matrix-independent "Build & test" status.
# Branch protection on main requires the "Build & test" context, but the matrix
# job above reports per-version contexts ("Build & test (Node 20/24)"). This job
# keeps the stable required context alive and fails unless every matrix leg
# succeeded (if: always() so a matrix failure still reports a definitive result
# instead of leaving the required check pending forever).
build-and-test-required:
name: Build & test
if: always()
needs: build-and-test
runs-on: ubuntu-latest
steps:
- name: Verify matrix result
run: |
if [ "${{ needs.build-and-test.result }}" != "success" ]; then
echo "Matrix build-and-test did not succeed: ${{ needs.build-and-test.result }}"
exit 1
fi
echo "All matrix legs passed."
+12 -4
View File
@@ -2,8 +2,16 @@ name: CLA Assistant
# Requires every contributor to sign the project Contributor License Agreement
# (CLA.md) before their pull request can be merged. Signatures are stored in
# this repository at signatures/cla.json — no external service or database is
# used. A contributor signs by commenting the exact sentence configured below.
# this repository at signatures/cla.json on the `cla-signatures` branch — no
# external service or database is used. A contributor signs by commenting the
# exact sentence configured below.
#
# Why not `main`: the action appends each signature as a direct commit, which
# the "Require CI on main" ruleset rejects ("Repository rule violations found"),
# leaving the signature unrecorded and the check permanently red. Keeping the
# store on its own branch lets the bot append without granting any actor a
# bypass on main. That branch has its own ruleset blocking deletion and
# force-pushes, so the record cannot be rewritten.
on:
issue_comment:
@@ -29,7 +37,7 @@ jobs:
(github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') ||
github.event_name == 'pull_request_target'
uses: contributor-assistant/github-action@v2.6.1
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -37,7 +45,7 @@ jobs:
path-to-signatures: 'signatures/cla.json'
# The CLA document contributors are agreeing to.
path-to-document: 'https://github.com/TheZwiss/backspace/blob/main/CLA.md'
branch: 'main'
branch: 'cla-signatures'
# Accounts that never need to sign (maintainer + automation).
allowlist: 'TheZwiss,dependabot[bot],github-actions[bot]'
# The exact phrase a contributor comments to sign.
+48
View File
@@ -0,0 +1,48 @@
name: CodeQL
# Static application security testing for all TS/JS. Uses build-mode: none — no
# compile needed, which sidesteps the monorepo/native-module build entirely.
# Default (code-scanning) query suite; security-extended is deferred (triage tax).
# CodeQL uploads alerts to the Security tab but does NOT fail the PR by itself —
# blocking is a repo setting (code-scanning merge protection), documented in the
# maintainer checklist in docs/systems/security-scanning.md.
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '27 3 * * 1' # weekly Monday 03:27 UTC
permissions:
contents: read
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: Analyze (javascript-typescript)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # upload SARIF to code scanning
actions: read
steps:
- name: Harden the runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
with:
category: "/language:javascript-typescript"
+33
View File
@@ -0,0 +1,33 @@
name: Deploy landing page
on:
push:
branches: [main]
paths:
- 'site/**'
- '.github/workflows/deploy-pages.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: ./site
- id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
+134
View File
@@ -0,0 +1,134 @@
name: Publish Container Image
# Builds and publishes the Backspace application image to the GitHub Container
# Registry (GHCR) as a multi-architecture (linux/amd64 + linux/arm64) image, so
# self-hosters — including weak/ARM boxes like a Raspberry Pi — can `docker pull`
# a prebuilt image instead of building the ~1.6 GB image locally (the Vite build
# OOMs small ARM hosts). install.sh and docker-compose.yml default to pulling
# this image, with a from-source build as the fallback.
#
# This is intentionally SEPARATE from the desktop-installer workflow
# (release.yml) — they share the `v*` tag trigger but build entirely different
# artifacts and must not be entangled.
on:
push:
tags:
- 'v*'
# Allow a manual rebuild/publish (e.g. to (re)publish `latest` or a moving tag
# without cutting a new release).
workflow_dispatch:
inputs:
tag:
description: 'Extra tag to publish (optional, e.g. "edge")'
required: false
default: ''
permissions:
contents: read
packages: write
security-events: write
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
# The runtime image bakes the git commit for the AGPL-3.0 § 13 source
# offer (config.commit → GET /api/instance/info). The .git dir is not in
# the build context (.dockerignore), so resolve the short SHA here and feed
# it to the build as a --build-arg, matching install.sh / deploy.sh.
- name: Resolve build metadata
id: meta_commit
run: echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Derive image tags and labels
id: docker_meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
# github.repository is "TheZwiss/backspace"; metadata-action lowercases
# it → ghcr.io/thezwiss/backspace (GHCR requires lowercase).
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha
type=raw,value=${{ github.event.inputs.tag }},enable=${{ github.event.inputs.tag != '' }}
labels: |
org.opencontainers.image.title=Backspace
org.opencontainers.image.description=Self-hosted Discord alternative — text, voice, video, and federation.
org.opencontainers.image.source=https://github.com/TheZwiss/backspace
org.opencontainers.image.licenses=AGPL-3.0-only
org.opencontainers.image.revision=${{ github.sha }}
# Build a single-arch amd64 image and LOAD it into the runner's docker
# daemon so Trivy can scan the exact artifact before anything is published.
# A multi-arch manifest cannot be --load'ed, so scanning must happen on a
# single-arch build first; the multi-arch push below reuses these layers
# from the buildx cache, so this is cheap.
- name: Build amd64 image for scanning
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
platforms: linux/amd64
load: true
push: false
tags: backspace:scan
build-args: |
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Trivy image scan (report-only)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
continue-on-error: true # report-only; enforcement flipped on in Plan E
with:
scan-type: image
image-ref: backspace:scan
ignore-unfixed: true
format: sarif
output: trivy-image.sarif
severity: HIGH,CRITICAL
- name: Upload Trivy image SARIF
if: always()
continue-on-error: true # a scanner/SARIF-emit flake must never skip the publish below
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
with:
sarif_file: trivy-image.sarif
category: trivy-image
# Publish the multi-arch image. Reuses the amd64 layers built above via the
# gha cache. Attaches an SBOM and SLSA provenance attestation to the image.
- name: Build and push (linux/amd64, linux/arm64)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
build-args: |
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
sbom: true
provenance: true
# Cache multi-arch layers across runs via the GitHub Actions cache to
# keep the ~1.6 GB build from re-running cold every release.
cache-from: type=gha
cache-to: type=gha,mode=max
+221
View File
@@ -0,0 +1,221 @@
# Compila no GitHub, publica no Gitea.
#
# O GitHub entra só como máquina de build — é dele que vêm os runners Windows
# de que o módulo nativo de áudio precisa. A distribuição fica no Gitea, que
# serve os arquivos a qualquer um: assim o electron-updater não precisa de
# credencial embutida no app, o que aconteceria com um repositório privado no
# GitHub.
name: Publicar no Gitea
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: Tag a publicar (ex. v1.1.0)
required: true
type: string
permissions:
contents: read
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: windows-2022
args: --win --x64
- os: ubuntu-latest
args: --linux --x64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libx11-dev libxtst-dev libxt-dev \
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
libxrandr-dev libxinerama-dev libx11-xcb-dev \
libpipewire-0.3-dev libpulse-dev
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 20
cache: pnpm
- name: Cache Electron binaries
uses: actions/cache@v4
with:
path: |
~/.cache/electron
~/.cache/electron-builder
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: electron-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared package
run: pnpm --filter @backspace/shared build
# O postinstall termina em `|| console.warn` para quem não tem ferramentas
# de build. No CI isso esconde falha: o instalador sairia sem captura de
# áudio do sistema e ninguém saberia. Verifica-se o resultado.
- name: Verify native audio module compiled
if: runner.os == 'Windows'
shell: bash
run: |
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' | head -5)
[ -n "$found" ] || { echo "::error::sem .node compilado — instalador sairia sem áudio do sistema"; exit 1; }
echo "$found"
- name: Compile desktop TypeScript
working-directory: packages/desktop
run: pnpm exec tsc
# --publish never: o electron-builder não sabe enviar para o Gitea. Ele
# gera os instaladores e o latest.yml (o índice que o app consulta), e o
# passo seguinte faz o upload.
- name: Build installers
working-directory: packages/desktop
run: pnpm exec electron-builder ${{ matrix.args }} --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload to Gitea release
shell: bash
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_API: https://git.resenha.website/api/v1/repos/devsyncwrld/backspace
TAG: latest
run: |
set -uo pipefail
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::segredo GITEA_TOKEN não configurado"
exit 1
fi
# Sem -f e imprimindo o corpo: a versão anterior usava `curl -sf`, que
# engole a resposta de erro, então uma falha aqui só aparecia como um
# JSONDecodeError sem dizer o motivo.
api() {
local method=$1 path=$2; shift 2
curl -s -w '\n%{http_code}' -X "$method" \
-H "Authorization: token $GITEA_TOKEN" "$GITEA_API$path" "$@"
}
body() { sed '$d' <<<"$1"; }
code() { tail -n1 <<<"$1"; }
# jq em vez de python embutido: um heredoc multilinha dentro de um
# bloco literal de YAML encerra o bloco na primeira linha sem recuo.
json_id() { jq -r '.id // empty'; }
find_release() {
local r; r=$(api GET "/releases/tags/$TAG")
[ "$(code "$r")" = "200" ] && body "$r" | json_id || echo ""
}
# O corpo do POST fica em ASCII de proposito: o shell do runner
# Windows corrompe UTF-8 na requisicao e o Gitea recusa com
# "invalid UTF-8 within /name" (HTTP 422).
ID=$(find_release)
if [ -z "$ID" ]; then
R=$(api POST "/releases" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Latest build\",\"target_commitish\":\"main\",\"body\":\"Installers published automatically by CI.\"}")
if [ "$(code "$R")" = "201" ]; then
ID=$(body "$R" | json_id)
echo "release criada: $ID"
else
echo "criação retornou HTTP $(code "$R"): $(body "$R")"
# O outro job da matriz pode tê-la criado no mesmo instante.
ID=$(find_release)
[ -n "$ID" ] || { echo "::error::não foi possível obter nem criar a release"; exit 1; }
echo "release encontrada após corrida: $ID"
fi
else
echo "release existente: $ID"
fi
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
# não apagarem o trabalho um do outro.
#
# Padrão montado em bash puro: a versão anterior interpolava uma
# expressão do Actions dentro de um `case`, e quando ela não casou o
# laço passou em silêncio — a release ficou com dois latest.yml e o
# updater serviu o antigo, dizendo que a versão nova não existia.
R=$(api GET "/releases/$ID/assets")
if [ "$(code "$R")" != "200" ]; then
echo "::error::não foi possível listar os anexos — HTTP $(code "$R"): $(body "$R")"
exit 1
fi
# Filtro dentro do jq, comparando o nome direto. A versão anterior
# montava um regex e o casava contra "<id> <nome>", onde o `^` de
# `^latest\.yml$` ancorava no início da linha — depois do id — e nunca
# podia casar. O laço então só removia os .exe e deixava um latest.yml
# duplicado, que é o arquivo que decide se há atualização.
OLD=$(body "$R" | jq -r --arg os "${RUNNER_OS:-}" '
.[]
| select(
if $os == "Windows"
then (.name | endswith(".exe")) or .name == "latest.yml"
else (.name | endswith(".AppImage")) or (.name | endswith(".deb")) or .name == "latest-linux.yml"
end
)
| "\(.id) \(.name)"')
echo "anexos desta plataforma já na release: $(printf '%s' "$OLD" | grep -c . || true)"
if [ -n "$OLD" ]; then
printf '%s\n' "$OLD" | while read -r aid aname; do
[ -n "$aid" ] || continue
echo "removendo anexo antigo: $aname"
D=$(api DELETE "/releases/$ID/assets/$aid")
[ "$(code "$D")" = "204" ] || echo "::warning::falha ao remover $aname — HTTP $(code "$D")"
done
fi
shopt -s nullglob
sent=0
for f in packages/desktop/dist-electron/*.exe \
packages/desktop/dist-electron/*.AppImage \
packages/desktop/dist-electron/*.deb \
packages/desktop/dist-electron/latest*.yml; do
name=$(basename "$f")
R=$(api POST "/releases/$ID/assets?name=$name" -F "attachment=@$f")
if [ "$(code "$R")" != "201" ]; then
echo "::error::falha ao enviar $name — HTTP $(code "$R"): $(body "$R")"
exit 1
fi
echo "enviado: $name"
sent=$((sent+1))
done
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
echo "$sent arquivo(s) publicados"
# O updater busca latest.yml pelo nome. Duas cópias com o mesmo nome
# fazem o Gitea servir a mais antiga, e a atualização deixa de ser
# oferecida — sem erro em lugar nenhum. Falha aqui em vez de publicar
# uma release que parece boa e não atualiza.
if [ "${RUNNER_OS:-}" = "Windows" ]; then
R=$(api GET "/releases/$ID/assets")
DUP=$(body "$R" | jq -r '[.[] | select(.name == "latest.yml")] | length')
if [ "$DUP" != "1" ]; then
echo "::error::a release tem $DUP cópias de latest.yml — o updater serviria a errada"
exit 1
fi
echo "latest.yml: 1 cópia, como esperado"
fi
+21 -26
View File
@@ -14,28 +14,38 @@ jobs:
fail-fast: false
matrix:
include:
# macOS — universal build (arm64 + x64) on Apple Silicon runner
- os: macos-latest
args: --mac --arm64 --x64
# Fork: só as plataformas que o grupo usa. Windows arm64 saiu porque
# dobrava o job mais lento (duas distribuições do Electron, módulos
# nativos compilados duas vezes) e ninguém roda Windows em ARM.
# Linux arm64 saiu pelo mesmo motivo. Com o arm64 fora, o passo que
# instalava o fpm nativo perdeu a razão de existir: ele só era
# necessário porque o fpm embutido no electron-builder é x86_64 e
# falhava no runner arm64.
# macOS removido neste fork: ninguém do grupo usa, e o runner macOS
# é cobrado a 10x num repositório privado — era a plataforma mais cara
# da matriz, compilada em toda tag para zero usuários.
# Windows — x64 + arm64 on x64 runner.
# Pinned to windows-2022 (VS 2022 / v17): the windows-latest image
# ships VS "18", which the node-gyp bundled with electron-rebuild
# cannot detect ("unknown version undefined") when compiling
# uiohook-napi in postinstall.
- os: windows-2022
args: --win --x64 --arm64
args: --win --x64
# Linux — x64 on x64 runner
- os: ubuntu-latest
args: --linux --x64
# Linux — arm64 on arm64 runner
- os: ubuntu-24.04-arm
args: --linux --arm64
runs-on: ${{ matrix.os }}
steps:
- name: Harden the runner
if: runner.os == 'Linux'
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Install Linux build dependencies
if: runner.os == 'Linux'
@@ -53,24 +63,13 @@ jobs:
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
libxrandr-dev libxinerama-dev libx11-xcb-dev
- name: Install fpm for .deb packaging
if: runner.os == 'Linux'
# electron-builder bundles an x86_64-only fpm; on the arm64 runner it
# aborts the .deb target with "cannot execute binary file: Exec format
# error". Install fpm natively and set USE_SYSTEM_FPM (below) so both
# arches package their .deb with a host-native fpm. ruby-dev + make are
# needed for fpm's native gem dependencies.
run: |
sudo apt-get install -y ruby ruby-dev build-essential
sudo gem install --no-document fpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 20
cache: pnpm
@@ -91,7 +90,3 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false"
# Use the host-native fpm installed above instead of electron-builder's
# bundled x86_64 fpm (which can't run on the arm64 runner). No-op on
# macOS/Windows, which don't build .deb.
USE_SYSTEM_FPM: "true"
+48
View File
@@ -0,0 +1,48 @@
name: OpenSSF Scorecard
# Scores the repo's security posture (branch protection, pinned deps, token
# permissions, etc.) and publishes to the OpenSSF public API so a badge can be
# shown (badge is added in Plan E). REQUIRES the canonical repo to be PUBLIC —
# see the maintainer checklist in docs/systems/security-scanning.md.
on:
branch_protection_rule:
schedule:
- cron: '18 4 * * 2' # weekly Tuesday 04:18 UTC
push:
branches: [main]
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
security-events: write # upload SARIF
id-token: write # publish_results OIDC attestation
steps:
- name: Harden the runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Run Scorecard
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- name: Upload artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: SARIF file
path: results.sarif
retention-days: 5
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
with:
sarif_file: results.sarif
+123
View File
@@ -0,0 +1,123 @@
name: Security
# Report-only in this plan: every scanner is non-blocking and uploads SARIF to
# the Security tab. Enforcement (fail on fixable HIGH/CRITICAL, block on secrets)
# is flipped on in Plan E after the remediation pass.
on:
pull_request:
push:
branches: [main]
schedule:
- cron: '32 5 * * 1' # weekly Monday 05:32 UTC
permissions:
contents: read
concurrency:
group: security-${{ github.ref }}
cancel-in-progress: true
jobs:
gitleaks:
name: Secret scan (gitleaks)
runs-on: ubuntu-latest
steps:
- name: Harden the runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout (full history)
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
fetch-depth: 0 # gitleaks scans the whole git history, not just the diff
- name: Run gitleaks
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
continue-on-error: true # report-only; enforcement flipped on in Plan E
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
osv-scanner:
name: Dependency scan (OSV-Scanner)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # upload SARIF to code scanning
steps:
- name: Harden the runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Run OSV-Scanner
uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
continue-on-error: true # report-only; enforcement flipped on in Plan E
with:
scan-args: |-
--lockfile=./pnpm-lock.yaml
--format=sarif
--output=osv-results.sarif
- name: Upload OSV SARIF
if: always()
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
with:
sarif_file: osv-results.sarif
category: osv-scanner
trivy-config:
name: IaC/config scan (Trivy)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Trivy config scan (Dockerfile + docker-compose)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
continue-on-error: true # report-only; enforcement flipped on in Plan E
with:
scan-type: config
scan-ref: .
format: sarif
output: trivy-config.sarif
- name: Upload Trivy config SARIF
if: always()
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
with:
sarif_file: trivy-config.sarif
category: trivy-config
trivy-license:
name: License compliance scan (Trivy)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Trivy license scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
continue-on-error: true # report-only; enforcement flipped on in Plan E
with:
scan-type: fs
scan-ref: .
scanners: license
format: sarif
output: trivy-license.sarif
- name: Upload Trivy license SARIF
if: always()
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
with:
sarif_file: trivy-license.sarif
category: trivy-license
+6
View File
@@ -34,6 +34,12 @@ id_ed25519*
# Generated deployment config (contains secrets)
livekit.yaml
# Local deploy targets & private ops notes — REAL hosts/IPs/users for the
# maintainer's own infra. NEVER commit: this is a public repo. deploy.sh reads
# .deploy.local at runtime if present; the committed defaults stay placeholders.
/.deploy.local
/.deploy-local/
# Raw screenshot originals — optimized WebP copies live in docs/screenshots/
demo-pictures/
+1
View File
@@ -0,0 +1 @@
20
+7 -7
View File
@@ -19,8 +19,8 @@ submit.
**"Contribution"** means any original work of authorship, including any
modification of or addition to an existing work, that You intentionally submit
to the Project in any form — including source code, object code, documentation,
configuration, designs, or assets — through any means, including pull requests,
to the Project in any form (including source code, object code, documentation,
configuration, designs, or assets) through any means, including pull requests,
patches, issues with attached code, or electronic communication, but excluding
any communication You conspicuously mark in writing as "Not a Contribution".
@@ -36,7 +36,7 @@ fully sublicensable, and transferable license to use, reproduce, modify, prepare
derivative works of, publicly display, publicly perform, distribute, relicense
(under any terms, including open-source and commercial licenses), sell, and
otherwise exploit your Contributions, by all means and in all media now known or
later developed — so that the Maintainer may offer the Project under both the
later developed, so that the Maintainer may offer the Project under both the
GNU AGPL-3.0 and one or more commercial licenses.
This grant covers all Contributions You have already submitted and all
@@ -46,17 +46,17 @@ Contributions You submit in the future, effective at the moment each is created.
To the extent that the exclusive license in Section 2 is, in any jurisdiction,
narrower than stated or otherwise limited by law, You grant the Maintainer the
broadest license permissible there, and — only to the extent necessary to give
the Maintainer equivalent rights — assign such rights to the Maintainer, so that
broadest license permissible there, and, only to the extent necessary to give
the Maintainer equivalent rights, assign such rights to the Maintainer, so that
the Maintainer obtains, as nearly as possible, the same rights as the exclusive
license in Section 2.
## 4. Patent License
You grant the Maintainer a perpetual, worldwide, non-exclusive, royalty-free,
irrevocable, sublicensable patent license — under any patent claims You can
irrevocable, sublicensable patent license (under any patent claims You can
license that are necessarily infringed by your Contribution alone or by
combination of your Contribution with the Project — to make, have made, use,
combination of your Contribution with the Project) to make, have made, use,
offer to sell, sell, import, and otherwise transfer your Contributions and the
Project.
+1
View File
@@ -160,6 +160,7 @@ Before modifying any subsystem, read its spec from `docs/systems/`. After making
| [message-list.md](docs/systems/message-list.md) | Auto-scroll model, position memory (session-only), embed renderer dimension contract, known limitations | Touching MessageList.tsx, scroll behavior, embed renderers, position restore |
| [deployment.md](docs/systems/deployment.md) | Hosting pipeline: Docker/Caddy build, admin bootstrap, DB backup/restore, image pinning, env vars | Any deploy, backup/restore, or hosting change |
| [activity-presence.md](docs/systems/activity-presence.md) | Presence states, rich activities, activity types/priorities, broadcast pipeline, visibility control, ActivityCard/Panel | Presence, rich activities, activity display, status management |
| [security-scanning.md](docs/systems/security-scanning.md) | CI security pipeline: Dependabot, CodeQL SAST, gitleaks, OSV-Scanner, Trivy (config/license; image scan in a later plan), OpenSSF Scorecard, SHA-pinning, harden-runner, tiered enforcement policy, maintainer settings checklist | Any CI security work, adding/changing scanners, enabling enforcement, supply-chain hardening |
---
+14 -7
View File
@@ -2,7 +2,7 @@
Thanks for considering a contribution! Backspace is free and open source software
(GNU AGPL-3.0, with a commercial dual-license option), and contributions of all
sizes are welcome — bug reports, fixes, features, documentation, and design.
sizes are welcome: bug reports, fixes, features, documentation, and design.
## Before you start
@@ -22,7 +22,7 @@ Before your first contribution can be merged, you must sign the project's
Backspace is a single-owner project. Under the CLA **you keep the copyright to
your contribution** and grant the maintainer (Jannis Braun) an exclusive,
sublicensable license to it — which is what lets the project be offered under both
sublicensable license to it, which is what lets the project be offered under both
the AGPL and a commercial license. In return, you receive a perpetual license to
reuse the specific code you authored in your own other projects (see CLA §5). You
also confirm that you have the right to contribute the code in the first place.
@@ -36,11 +36,13 @@ Signing is automatic and takes one comment:
> I have read the CLA Document and I hereby sign the CLA
4. The bot records your signature against your GitHub username. You only sign
once — it covers all of your future contributions.
once, and it covers all of your future contributions.
## Development setup
Requirements: **Node.js 20+** and **pnpm 8+**.
Requirements: **Node.js 20 (LTS)** and **pnpm 10**. Run `nvm use` (reads
`.nvmrc`); Corepack activates the pinned pnpm from the `packageManager` field
automatically, so don't install pnpm globally.
```bash
pnpm install # install all workspace dependencies
@@ -50,6 +52,11 @@ pnpm dev # API server on :3005, Vite dev server on :5173
You can run the two halves separately with `pnpm dev:server` and `pnpm dev:web`.
Working on the **desktop** app additionally needs a C++ toolchain (`make`, `g++`,
`python3`) to build the native `uiohook-napi` module. On Debian/Ubuntu:
`sudo apt install build-essential python3`. Without it `pnpm install` just warns
and skips that one rebuild; the server and web client are unaffected.
Voice and video are optional and require a LiveKit server; see the README for
configuration. Text, federation, uploads, and everything else run fully without
it.
@@ -57,7 +64,7 @@ it.
## Coding standards
- **TypeScript strict mode**, no `any`. The codebase compiles cleanly under
strict settings — keep it that way.
strict settings, so keep it that way.
- **Match the surrounding code.** Follow existing patterns, naming, and module
boundaries rather than introducing new ones.
- **Federation-aware.** Never assume a single global user ID. Resolve the
@@ -89,10 +96,10 @@ it.
Use GitHub Issues. For bugs, include reproduction steps, expected vs. actual
behavior, and your environment (deployment method, browser/desktop, and whether
federation or voice is involved). For security issues, please do **not** open a
public issue — see [`SECURITY.md`](SECURITY.md).
public issue. See [`SECURITY.md`](SECURITY.md).
## License
By contributing, you agree that your contributions are licensed under the
[GNU AGPL-3.0](LICENSE) and are subject to the [CLA](CLA.md) — an exclusive-license
[GNU AGPL-3.0](LICENSE) and are subject to the [CLA](CLA.md), an exclusive-license
grant that also enables the project's commercial dual-license.
+6
View File
@@ -10,3 +10,9 @@
# Backspace API, WebSocket, and frontend — Docker DNS resolves "backspace"
reverse_proxy backspace:3000
}
# Gitea — servidor git próprio (stack em /opt/gitea). Alcançado pelo nome do
# container na rede interna; o Gitea não publica porta nenhuma no host.
{$GIT_DOMAIN:git.resenha.website} {
reverse_proxy gitea:3000
}
+32 -6
View File
@@ -5,7 +5,7 @@
# Stage 1: Install dependencies and build frontend
FROM node:20-slim AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
WORKDIR /app
@@ -21,6 +21,15 @@ COPY packages/web/package.json packages/web/
# Copy patches (referenced by pnpm-lock.yaml)
COPY patches/ patches/
# better-sqlite3 publishes no prebuilt binary for Node 20 (ABI 115) — its
# releases cover ABI 127/137/141/147 only — so prebuild-install falls back to
# compiling with node-gyp, which needs python3/make/g++. node:20-slim ships
# none of them. Builder stage only: the runtime stage copies the compiled
# .node and stays slim.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies
RUN pnpm install --frozen-lockfile
@@ -36,11 +45,14 @@ RUN pnpm --filter @backspace/web build
# Stage 2: Production runtime
FROM node:20-slim AS runtime
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
# Install build dependencies for better-sqlite3 native module
# Runtime deps only: ffmpeg (media processing) + gosu (drop to non-root in the
# entrypoint). sharp is N-API (ABI-independent) and loads a prebuilt binary;
# better-sqlite3 no longer ships one for Node 20, so it is compiled below with
# a toolchain that is purged in the same layer.
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 make g++ ffmpeg && \
apt-get install -y --no-install-recommends ffmpeg gosu && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -57,8 +69,16 @@ COPY packages/web/package.json packages/web/
# Copy patches (referenced by pnpm-lock.yaml)
COPY patches/ patches/
# Install production dependencies only (tsx is in server dependencies)
RUN pnpm install --prod --frozen-lockfile
# Install production dependencies only (tsx is in server dependencies).
# better-sqlite3 compiles from source here (no Node 20 prebuilt), so the C
# toolchain is installed, used and purged inside this single layer — the final
# image ships no compiler.
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 make g++ && \
pnpm install --prod --frozen-lockfile && \
apt-get purge -y python3 make g++ && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
# Copy shared source (needed at runtime since server imports types directly)
COPY packages/shared/ packages/shared/
@@ -72,6 +92,11 @@ COPY --from=builder /app/packages/web/dist packages/web/dist
# Create data directories
RUN mkdir -p /app/data/uploads
# Non-root hardening: copy the privilege-dropping entrypoint. It chowns the
# data volume as root, then execs the CMD as the unprivileged `node` user.
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Set environment defaults
ENV NODE_ENV=production
ENV PORT=3000
@@ -94,4 +119,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \
# Run the server using tsx from the server package directory
WORKDIR /app/packages/server
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
+362 -90
View File
@@ -4,52 +4,52 @@
# Backspace
**A self-hosted communication platform — text, voice, video, and federation — that you own.**
**A self-hosted communication platform you own. Text, voice, video, and federation.**
[![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-3da639.svg)](LICENSE)
[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178c6.svg)](https://www.typescriptlang.org/)
[![Node.js](https://img.shields.io/badge/node-%E2%89%A520-339933.svg)](https://nodejs.org/)
[![Node.js](https://img.shields.io/badge/node-20_LTS-339933.svg)](https://nodejs.org/)
[![Version](https://img.shields.io/badge/version-1.0.0-16a34a.svg)](#project-status)
</div>
---
Backspace is a Discord-style chat platform you run on your own hardware. Spaces,
channels, roles, voice and video, screen sharing, direct messages, friends, file
sharing, and message search — plus **server-to-server federation**, so
independent Backspace instances can talk to each other while each stays under
its own control.
Backspace is a self-hosted, open-source Discord alternative: a Discord-style chat
platform you run on your own hardware. Spaces, channels, roles, voice and video,
screen sharing, direct messages, friends, file sharing, and message search. On top
of that, **server-to-server federation** lets independent Backspace instances talk
to each other while each stays under its own control.
It is **free and open source** under the **GNU AGPL-3.0**, and dual-licensed: a
commercial license is available if the AGPL doesn't fit your use. See
[License](#license) for the details.
> **Project status** <a name="project-status"></a>
> Backspace 1.0 — stable, self-hostable, and actively developed.
> Backspace 1.0. Stable, self-hostable, and actively developed.
## What makes Backspace different
Self-hosted chat usually forces a trade-off: gaming-grade voice and video, *or* a
polished Discord-style experience, *or* federation between independent servers —
rarely all three, and rarely with the fine-grained media controls people expect.
polished Discord-style experience, *or* federation between independent servers.
Rarely all three, and rarely with the fine-grained media controls people expect.
Backspace does all three at once:
- **Voice & video with a real control surface.** Not just "it has screen share":
choose resolution, frame rate, codec (VP9 or hardware H.264), and bitrate; set
independent 0–200% volume for every person *and* every screen-share; RNNoise
noise suppression; a live connection inspector (bitrate, codec, ping, packet
loss, jitter); and a per-tile badge showing each stream's measured
resolution/frame-rate. Screen sharing goes up to 4K/120fps within admin-set
- **Voice and video with a real control surface.** This goes past a screen-share
button. Choose resolution, frame rate, codec (VP9 or hardware H.264), and
bitrate; set independent 0-200% volume for every person and every screen-share;
RNNoise noise suppression; a live connection inspector (bitrate, codec, ping,
packet loss, jitter); and a per-tile badge showing each stream's measured
resolution and frame-rate. Screen sharing goes up to 4K/120fps within admin-set
bounds.
- **Federation, not a walled garden.** Run your own instance and peer it with
others: cross-instance friends, DMs, calls, and presence — each server
independently owned, requests HMAC-authenticated.
- **A complete, polished platform — not a demo.** Role-based permissions with
per-category and per-channel overrides, friends and group DMs, inline playable
media, moderation with audit trails, search, a desktop app, and an installable
mobile PWA — all in the warm, calm "Aether Drift" interface.
others for cross-instance friends, DMs, calls, and presence. Each server stays
independently owned, and requests are HMAC-authenticated.
- **A complete platform, not a demo.** Role-based permissions with per-category
and per-channel overrides, friends and group DMs, inline playable media,
moderation with audit trails, search, a desktop app, and an installable mobile
PWA, all in the warm, calm "Aether Drift" interface.
You own the server, the data, and the network it federates into.
@@ -59,7 +59,7 @@ You own the server, the data, and the network it federates into.
<img src="docs/screenshots/voice-video-grid.webp" alt="A voice channel with a grid of camera and screen-share tiles" width="900" />
<sub><em>A voice channel in full swing — camera tiles alongside live screen-shares, each with its own resolution / frame-rate label.</em></sub>
<sub><em>A voice channel in full swing. Camera tiles alongside live screen-shares, each with its own resolution and frame-rate label.</em></sub>
</div>
@@ -67,31 +67,31 @@ You own the server, the data, and the network it federates into.
<tr>
<td width="50%" valign="top">
<img src="docs/screenshots/chat.webp" alt="A text channel with messages and a typing indicator" /><br/>
<sub><b>Text channels</b> — Markdown, replies, reactions, and live typing indicators.</sub>
<sub><b>Text channels.</b> Markdown, replies, reactions, and live typing indicators.</sub>
</td>
<td width="50%" valign="top">
<img src="docs/screenshots/screen-share-settings.webp" alt="The screen-share settings popover" /><br/>
<sub><b>Screen-share controls</b> — resolution, frame rate, codec, and bitrate, within admin-set bounds.</sub>
<sub><b>Screen-share controls.</b> Resolution, frame rate, codec, and bitrate, within admin-set bounds.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/screenshots/space-discovery.webp" alt="The space discovery / Explore view" /><br/>
<sub><b>Spaces &amp; discovery</b> — browse public, request-to-join, and joined spaces.</sub>
<sub><b>Spaces and discovery.</b> Browse public, request-to-join, and joined spaces.</sub>
</td>
<td width="50%" valign="top">
<img src="docs/screenshots/group-dm.webp" alt="A federated group direct message" /><br/>
<sub><b>Direct messages</b> — 1-on-1 and group DMs, including members on peer instances.</sub>
<sub><b>Direct messages.</b> 1-on-1 and group DMs, including members on peer instances.</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/screenshots/user-discovery.webp" alt="The find-people / user discovery view" /><br/>
<sub><b>Friends &amp; social</b> — find people across instances with mutual friends and spaces.</sub>
<sub><b>Friends and social.</b> Find people across instances with mutual friends and spaces.</sub>
</td>
<td width="50%" valign="top">
<img src="docs/screenshots/admin-federation.webp" alt="The federation admin panel showing peered instances" /><br/>
<sub><b>Federation admin</b> — manage peered instances, relay, and secret rotation.</sub>
<sub><b>Federation admin.</b> Manage peered instances, relay, and secret rotation.</sub>
</td>
</tr>
</table>
@@ -106,22 +106,22 @@ You own the server, the data, and the network it federates into.
- Message reactions, replies, editing, deletion, and per-message mark-as-unread
- Rich link embeds (YouTube, Vimeo, Spotify, and generic OpenGraph) with SSRF-protected scraping, plus GIF search (Klipy)
- Typing indicators, unread badges, and presence
- Direct messages — 1-on-1 and group DMs (up to 10 people), with voice/video calls (ring / accept / reject)
- Direct messages: 1-on-1 and group DMs (up to 10 people), with voice/video calls (ring, accept, reject)
**Voice, video & screen sharing** (via [LiveKit](https://livekit.io/)):
- Screen sharing up to 4K / 120fps — VP9 by default, an optional hardware-accelerated H.264 mode, and a VP8 simulcast fallback
- Per-stream quality controls — resolution, frame rate, codec, and bitrate, within admin-set bounds
- Independent 0–200% volume for every participant *and* every screen-share
**Voice, video, and screen sharing** (via [LiveKit](https://livekit.io/)):
- Screen sharing up to 4K/120fps: VP9 by default, an optional hardware-accelerated H.264 mode, and a VP8 simulcast fallback
- Per-stream quality controls: resolution, frame rate, codec, and bitrate, within admin-set bounds
- Independent 0-200% volume for every participant and every screen-share
- RNNoise noise suppression (on by default), plus echo-cancellation and auto-gain toggles and mic/speaker device selection
- Live connection inspector — per-participant bitrate, codec, ping, packet loss, and jitter — plus a per-tile badge showing each stream's measured resolution/frame-rate
- Live connection inspector for per-participant bitrate, codec, ping, packet loss, and jitter, plus a per-tile badge showing each stream's measured resolution and frame-rate
- Screen-share viewer detection ("who's watching") and auto-ducking that lowers stream audio when someone speaks
- Selective subscription — mute or stop watching any camera/stream to save bandwidth
- Selective subscription: mute or stop watching any camera or stream to save bandwidth
- Push-to-talk and fully customizable keybinds (including mouse buttons), in the browser and the desktop app
- Picture-in-Picture for voice and video
### Organization
- Spaces with channel categories
- Role-based permissions — bitwise RBAC with category- and channel-level overrides
- Role-based permissions: bitwise RBAC with category- and channel-level overrides
- Customizable user sidebar layout, with personal color-coded folders that group whole spaces
- Space discovery (public, request-to-join, and private)
- Shareable invite codes
@@ -132,8 +132,8 @@ You own the server, the data, and the network it federates into.
- Mutual friends and mutual spaces
- User profiles with banner, bio, and accent color
- Presence and rich activities (playing, listening, watching, streaming, custom)
- Manual status — Online, Idle, or Do Not Disturb — with a custom status message
- Privacy controls — toggle discoverability and activity-status sharing
- Manual status (Online, Idle, or Do Not Disturb) with a custom status message
- Privacy controls: toggle discoverability and activity-status sharing
### Moderation
- Bans with reason and moderator attribution (who, why, and when)
@@ -144,7 +144,7 @@ You own the server, the data, and the network it federates into.
### Federation
- Multi-instance peering with HMAC-signed server-to-server requests
- Federated identity resolution (`username@instance`)
- Cross-instance DMs — messages, reactions, and membership relay
- Cross-instance DMs: messages, reactions, and membership relay
- Cross-instance friends and presence
- File replication with size validation
- Background workers for outbox delivery, file download, peer health, and cleanup
@@ -152,26 +152,38 @@ You own the server, the data, and the network it federates into.
### Platform
- File uploads with image thumbnails (via `sharp`), drag-and-drop and paste-to-upload, and in-app avatar/banner cropping
- Message search with `from:`, `has:`, `before:`, and `after:` filters, plus jump-to-message
- Admin panel — instance settings, user management, registration controls, storage management, and federation/peering, plus granular streaming controls (a per-resolution × per-frame-rate bitrate matrix, min/max caps, quality-slider step, and an optional user-set-bitrate mode)
- Admin panel: instance settings, user management, registration controls, storage management, and federation/peering, plus granular streaming controls (a per-resolution by per-frame-rate bitrate matrix, min/max caps, quality-slider step, and an optional user-set-bitrate mode)
- Automatic SQLite backups (pre-migration, scheduled, and manual) with restore tooling
- Electron desktop app (Windows, macOS, Linux) with global keybinds (push-to-talk, mute, deafen) and activity detection
- Native desktop notifications and unread badge counts
- Mobile-responsive web UI with a dedicated touch layout (bottom navigation, swipe gestures, full-screen views)
- Installable PWA — add it to your phone's home screen to run it as a standalone app, with service-worker caching and an offline message queue (messages send once you reconnect)
- Account management — password change and account deletion with safeguards
- Installable PWA: add it to your phone's home screen to run it as a standalone app, with service-worker caching and an offline message queue (messages send once you reconnect)
- Account management: password change and account deletion with safeguards
## Installation
The intended way to deploy Backspace is the **interactive installer** — it
The intended way to deploy Backspace is the **interactive installer**. It
configures everything (`.env`, secrets, HTTPS, optional voice) and brings the
stack up for you. Everything you need for a working instance is below.
stack up for you. It **auto-detects your environment** and picks one of three
deployment modes. The default "All-in-One" (below) needs nothing but a host and
a domain, but if ports 80/443 are already taken (an existing reverse proxy, a
tunnel, another app) the installer steers you to the right mode instead of
dead-ending. See [Deployment modes](#deployment-modes) for the full picture.
By default the installer **pulls a prebuilt multi-architecture image** from the
GitHub Container Registry (`linux/amd64` + `linux/arm64`), so weak or ARM boxes
(a Raspberry Pi) skip the heavy local build. It falls back to building from
source automatically if the image can't be pulled.
### Requirements
- A **Linux host** (VPS, VM, or home server) with **Docker** and **Docker Compose**.
- A **domain name** pointed at the host's public IP — Caddy uses it to obtain
HTTPS certificates automatically.
- The ability to open the firewall ports in step 2.
- A **domain name** for your instance. In the default All-in-One mode it must
point at the host's public IP (Caddy obtains HTTPS certificates for it
automatically); behind your own reverse proxy or a tunnel it points at that
edge instead. See [Deployment modes](#deployment-modes).
- The ability to open the firewall ports in step 2 (All-in-One), or a reverse
proxy / tunnel already terminating HTTPS for you.
### 1. Run the installer
@@ -191,34 +203,34 @@ The installer walks you through everything interactively:
### 2. Open the firewall ports
Open these on the host — and, if it's behind a router, port-forward them to the host:
Open these on the host (and, if it's behind a router, port-forward them to the host):
| Port | Proto | When | Purpose |
|------|-------|------|---------|
| `80` | TCP | **Always** | HTTP — Caddy's automatic-HTTPS (ACME) challenge + redirect to HTTPS |
| `443` | TCP | **Always** | HTTPS — web app, REST API, WebSocket, and LiveKit signaling (proxied) |
| `3478` | UDP | If voice enabled | TURN — NAT traversal for WebRTC |
| `80` | TCP | **Always** | HTTP. Caddy's automatic-HTTPS (ACME) challenge + redirect to HTTPS |
| `443` | TCP | **Always** | HTTPS. Web app, REST API, WebSocket, and LiveKit signaling (proxied) |
| `3478` | UDP | If voice enabled | TURN. NAT traversal for WebRTC |
| `7881` | TCP | If voice enabled | WebRTC TCP fallback (clients that can't use UDP) |
| `50000–60000` | UDP | If voice enabled | WebRTC media (voice / video / screen-share streams) |
Without voice, you only need `80` and `443`. The voice ports are required only
when you enable LiveKit. LiveKit's own signaling port (`7880`) stays internal —
it's reverse-proxied through Caddy on `443`, so you do **not** forward it.
when you enable LiveKit. LiveKit's own signaling port (`7880`) stays internal.
It's reverse-proxied through Caddy on `443`, so you do **not** forward it.
> **Do this together with DNS, ideally before (or right after) running the
> installer.** Caddy gets your HTTPS certificate from Let's Encrypt the first
> time the stack starts, which requires your domain to resolve to this host
> **and** ports `80`/`443` reachable from the internet. If they aren't ready
> yet, that's fine — Caddy keeps retrying, and HTTPS comes up automatically once
> yet, that's fine. Caddy keeps retrying, and HTTPS comes up automatically once
> DNS and the ports are in place.
### 3. Create your admin account
Open `https://your-domain` and register. **The first account created becomes the
instance admin** — there is no default username or password.
instance admin**. There is no default username or password.
If the page doesn't load over HTTPS, it's almost always DNS or ports `80`/`443`
not being reachable from outside — check `docker compose logs caddy` for
not being reachable from outside. Check `docker compose logs caddy` for
certificate errors. (The installer's health check confirms the app is up
internally, not that the certificate was issued.)
@@ -232,7 +244,7 @@ backup/restore and image-pinning guide.
### Manual setup (advanced, optional)
The installer above is the supported path. If you'd rather configure everything
by hand, you can skip it and drive Docker Compose directly — but then DNS,
by hand, you can skip it and drive Docker Compose directly, but then DNS,
`.env`, secrets, voice config, and the same firewall ports from step 2 are your
responsibility:
@@ -253,11 +265,210 @@ The stack runs three services via Docker Compose:
|-------------|---------------------------------------------------|
| `backspace` | The app (API + WebSocket + built web client) on internal port `3000` |
| `caddy` | Reverse proxy with automatic HTTPS for your `DOMAIN` (ports `80`/`443`) |
| `livekit` | Voice/video server — optional, enabled with `COMPOSE_PROFILES=voice` |
| `livekit` | Voice/video server; optional, enabled with `COMPOSE_PROFILES=voice` |
## Deployment modes
Homelabs differ. Backspace supports three deployment modes from **one installer**,
which auto-detects which one fits and, in non-obvious cases, asks. The mode is
recorded as `DEPLOY_MODE` in `.env`; you can also set it up front for a
non-interactive install (`DEPLOY_MODE=proxy ./install.sh`).
| Mode | When | HTTPS handled by | Voice |
|------|------|------------------|-------|
| **`allinone`** (default) | Ports 80/443 are free and you have a domain | The bundled **Caddy** (automatic Let's Encrypt) | Yes, with UDP media ports open |
| **`proxy`** | You already run a reverse proxy (nginx, Traefik, Caddy, Nginx Proxy Manager, SWAG…) | **Your** reverse proxy | Yes, if you also proxy `/livekit` and open the media ports |
| **`tunnel`** | You expose the box through a tunnel (Cloudflare Tunnel, Tailscale…) | The **tunnel** provider | No, WebRTC/UDP can't traverse a tunnel |
In `proxy` and `tunnel` mode the bundled Caddy is **not** started; instead the app
is published on **`127.0.0.1:APP_PORT`** (loopback only, never exposed directly)
for your proxy or tunnel to forward to. This is driven by a small overlay,
`docker-compose.proxy.yml`, which the installer wires in for you by setting
`COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml` in `.env`, so every
later `docker compose …` command in the directory keeps working with no `-f`
flags. The installer auto-picks a free `APP_PORT` (3000/8080 are often taken);
override it with `APP_PORT=…`.
The installer prints ready-to-paste config for your mode at the end. The
canonical snippets are below.
### Mode 2: behind your own reverse proxy
The app answers plain HTTP on `127.0.0.1:APP_PORT`; your proxy terminates TLS and
forwards to it. Every snippet already includes the three things people get wrong:
**WebSocket upgrade** (chat and live events won't work without it),
**`X-Forwarded-*`** (the server runs with `trustProxy` and needs the real client
scheme/IP), and a **body-size limit** matching `MAX_UPLOAD_SIZE` (default 100 MB).
Replace `chat.example.com` and `8080` with your domain and `APP_PORT`.
**nginx.** The `map` goes in `http { }` once; the `server` block per site:
```nginx
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
server {
listen 443 ssl;
server_name chat.example.com;
# ssl_certificate /etc/letsencrypt/live/chat.example.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/chat.example.com/privkey.pem;
client_max_body_size 100m; # match MAX_UPLOAD_SIZE
location / {
proxy_pass http://127.0.0.1:8080;
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 Upgrade $http_upgrade; # WebSocket
proxy_set_header Connection $connection_upgrade; # WebSocket
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Voice only: forward LiveKit signaling (strips the /livekit prefix):
# location /livekit/ {
# proxy_pass http://127.0.0.1:7880/;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection $connection_upgrade;
# }
}
```
**Caddy** (if you run your own; it handles WebSocket and `X-Forwarded-*` itself):
```caddy
chat.example.com {
reverse_proxy 127.0.0.1:8080
request_body { max_size 100MB }
# Voice only:
# handle_path /livekit/* { reverse_proxy 127.0.0.1:7880 }
# handle { reverse_proxy 127.0.0.1:8080 }
}
```
**Traefik** (file provider; Traefik handles WebSocket automatically):
```yaml
http:
routers:
backspace:
rule: "Host(`chat.example.com`)"
entryPoints: [websecure]
service: backspace
tls: { certResolver: letsencrypt }
services:
backspace:
loadBalancer:
servers:
- url: "http://127.0.0.1:8080"
# Voice only: add a higher-priority router + stripPrefix middleware for
# PathPrefix(`/livekit`) → http://127.0.0.1:7880.
```
#### GUI proxies (Nginx Proxy Manager, SWAG, etc.)
You can't paste a config file into a point-and-click proxy, so set these fields
by hand. In **Nginx Proxy Manager**, add a **Proxy Host**:
| Field | Value |
|-------|-------|
| **Domain Names** | `chat.example.com` |
| **Scheme** | `http` |
| **Forward Hostname / IP** | `127.0.0.1`, but **if NPM runs in Docker**, `127.0.0.1` is NPM's *own* container. Use the host's LAN IP, or `host.docker.internal` with `extra_hosts: ["host.docker.internal:host-gateway"]` on the NPM container. |
| **Forward Port** | your `APP_PORT` (e.g. `8080`) |
| **Websockets Support** | **ON** (required; chat/live events break without it) |
| **Block Common Exploits** | fine to leave on |
| **SSL tab** | request a Let's Encrypt cert and enable **Force SSL** |
| **Advanced tab** | add `client_max_body_size 100m;` (match `MAX_UPLOAD_SIZE`) |
The same three ideas apply to any GUI proxy: forward to the app's host+port,
enable WebSocket support, and raise the request-body limit.
### Mode 3: behind a tunnel (Cloudflare, Tailscale)
Same loopback publish as Mode 2, but the tunnel daemon on the host reaches
`127.0.0.1:APP_PORT` and no inbound ports are opened at all. For **Cloudflare
Tunnel** (`cloudflared`):
```yaml
# ~/.cloudflared/config.yml
tunnel: <YOUR-TUNNEL-ID>
credentials-file: /root/.cloudflared/<YOUR-TUNNEL-ID>.json
ingress:
- hostname: chat.example.com
service: http://127.0.0.1:8080
- service: http_status:404
```
```bash
cloudflared tunnel route dns <YOUR-TUNNEL-ID> chat.example.com
```
Two tunnel-specific caveats, both handled by the installer:
- **Upload cap.** Cloudflare (free/pro) hard-caps request bodies at **100 MB**, so
the 100 MB default would let large uploads fail *at the edge*. In `tunnel` mode
the installer sets `MAX_UPLOAD_SIZE=94371840` (90 MB) with headroom. Don't raise
it back above ~100 MB behind Cloudflare.
- **No voice.** Voice/video is **WebRTC over UDP**, which a tunnel can't carry, so
it's disabled in `tunnel` mode. If you need voice, use Mode 2 (reverse proxy)
with the media ports opened, or All-in-One.
### Voice per mode
Voice/video (LiveKit) needs its **UDP media ports** reachable from clients.
These carry the actual audio/video and never pass through your HTTP proxy or
tunnel:
| Port | Proto | Purpose |
|------|-------|---------|
| `3478` | UDP | TURN. WebRTC NAT traversal |
| `7881` | TCP | WebRTC TCP fallback |
| `50000–60000` | UDP | WebRTC media (voice / video / screen-share) |
- **All-in-One.** Voice works once those ports are open/forwarded. LiveKit
*signaling* is proxied through Caddy on 443 (`/livekit`); port `7880` stays
internal, never forwarded.
- **Reverse proxy.** You must **also** route `/livekit` to `127.0.0.1:7880` (see
the commented lines in the snippets) **and** open the media ports above.
- **Tunnel.** Voice does **not** work (UDP can't traverse the tunnel). This is a
known, unavoidable limitation, not a misconfiguration.
### Updating a running instance
Back up first. The app auto-snapshots the SQLite DB, and you can take one on
demand with `./backup.sh` (see [`docs/systems/deployment.md`](docs/systems/deployment.md)).
Then, from the install directory:
```bash
git pull # refresh compose files / install.sh / docs
# Prebuilt-image installs (the default):
docker compose pull && docker compose up -d
# From-source installs (a fork, or BACKSPACE_BUILD=true):
docker compose up -d --build
```
Because `COMPOSE_FILE` lives in `.env`, these commands automatically use the
right compose files in every mode, with no `-f` flags to remember. A redeploy
briefly restarts the `backspace` container (clients reconnect automatically).
## Development
Requirements: **Node.js 20+** and **pnpm 8+**.
Requirements: **Node.js 20 or newer** and **pnpm 10**. The `.nvmrc` file keeps
Node 20 as the default development and production baseline; CI additionally
exercises Node 24, and newer majors generally work but are not part of the test
matrix. The Docker image continues to build on Node 20 regardless of your host.
```bash
pnpm install
@@ -265,6 +476,29 @@ cp .env.example .env # set JWT_SECRET (openssl rand -hex 32)
pnpm dev # API server on :3005, Vite dev server on :5173
```
On Windows PowerShell, confirm Node 20 or newer and use the native copy command:
```powershell
node --version
pnpm install
Copy-Item .env.example .env
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
Paste the generated value after `JWT_SECRET=` in `.env`, then start both
development servers with `pnpm dev`. Use `node --version` to confirm the active
version if pnpm reports an engine warning. This covers the server and web dev
servers; building the Electron desktop app still expects a POSIX shell (macOS or
Linux).
> **Server/web only?** `pnpm install` also builds the desktop app's native
> keyboard-hook module (`uiohook-napi`), which needs a C++ toolchain
> (`make`, `g++`, `python3`). If those are missing it now **warns and continues**,
> and the server and web client don't need it. Install a build toolchain
> (Debian/Ubuntu: `sudo apt install build-essential python3`) only if you're
> building the **desktop** app. And to *self-host*, use the Docker installer
> above; it never touches the desktop package.
Run the halves separately if you prefer:
```bash
@@ -287,19 +521,22 @@ The most important:
| Variable | Required | Default | Description |
|----------------------|----------|-------------|-------------|
| `DOMAIN` | yes | — | Public domain name of your instance |
| `JWT_SECRET` | yes | — | Auth signing secret, **min 32 chars** (`openssl rand -hex 32`) |
| `DOMAIN` | yes | none | Public domain name of your instance |
| `JWT_SECRET` | yes | none | Auth signing secret, **min 32 chars** (`openssl rand -hex 32`) |
| `DEPLOY_MODE` | no | `allinone` | `allinone` \| `proxy` \| `tunnel`, see [Deployment modes](#deployment-modes) |
| `APP_PORT` | no | auto | `proxy`/`tunnel` only: host loopback port the app is published on |
| `PORT` | no | `3000` | App listen port (behind Caddy in Docker) |
| `HOST` | no | `0.0.0.0` | Bind address |
| `REGISTRATION_OPEN` | no | `true` | Set `false` to close signups after setup |
| `MAX_UPLOAD_SIZE` | no | `104857600` | Max upload size in bytes (100 MB) |
| `LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | no | — | Enable voice/video |
| `COMPOSE_PROFILES` | no | — | Set to `voice` to start the bundled LiveKit service |
| `MAX_UPLOAD_SIZE` | no | `104857600` | Max upload size in bytes (100 MB; 90 MB in `tunnel` mode) |
| `BACKSPACE_IMAGE` / `BACKSPACE_IMAGE_TAG` | no | `ghcr.io/thezwiss/backspace` / `latest` | Prebuilt image to pull; pin a tag or point at your fork's registry |
| `LIVEKIT_URL` / `LIVEKIT_API_KEY` / `LIVEKIT_API_SECRET` | no | none | Enable voice/video |
| `COMPOSE_PROFILES` | no | none | Set to `voice` to start the bundled LiveKit service |
## Voice & Video
Voice, video, and screen sharing require a [LiveKit](https://livekit.io/) server.
The Docker Compose file bundles one — enable it by setting these in `.env`:
The Docker Compose file bundles one. Enable it by setting these in `.env`:
```bash
COMPOSE_PROFILES=voice
@@ -309,10 +546,10 @@ LIVEKIT_API_SECRET=your-api-secret
```
Enabling voice also requires opening the WebRTC ports (`3478/UDP`, `7881/TCP`,
`50000–60000/UDP`) — see [Open the firewall ports](#2-open-the-firewall-ports).
`50000–60000/UDP`). See [Open the firewall ports](#2-open-the-firewall-ports).
Without LiveKit configured, everything else — text, federation, DMs, uploads,
search — works fully; only voice/video channels won't connect.
Without LiveKit configured, everything else works fully (text, federation, DMs,
uploads, search); only voice/video channels won't connect.
## Federation
@@ -336,11 +573,11 @@ Grab the installer for your platform from the
| Platform | File | Notes |
|----------|------|-------|
| Windows | `Backspace-<version>.exe` | Universal installer (x64 + arm64). SmartScreen may warn on first run — choose "More info" → "Run anyway". Auto-updates. |
| macOS | `Backspace-<version>-arm64.dmg` (Apple Silicon) / `Backspace-<version>-x64.dmg` (Intel) | Builds are currently **unsigned**: on first launch, right-click the app → **Open** → **Open**. Auto-update is not available on macOS yet — check the releases page for new versions. |
| Windows | `Backspace-<version>.exe` | Universal installer (x64 + arm64). SmartScreen may warn on first run; choose "More info" → "Run anyway". Auto-updates. |
| macOS | `Backspace-<version>-arm64.dmg` (Apple Silicon) / `Backspace-<version>-x64.dmg` (Intel) | Builds are currently **unsigned**: on first launch, right-click the app → **Open** → **Open**. Auto-update is not available on macOS yet, so check the releases page for new versions. |
| Linux | `Backspace-<version>-x86_64.AppImage` / `-arm64.AppImage`, or `.deb` (`amd64` / `arm64`) | AppImage auto-updates; `.deb` installs update via new releases. |
On first launch the app asks for your instance URL — enter the address of the
On first launch the app asks for your instance URL. Enter the address of the
Backspace server you use (e.g. `https://chat.example.com`).
### Building from source
@@ -357,14 +594,14 @@ Cross-platform builds are produced for Windows, macOS, and Linux. See
## Mobile
Backspace works on mobile today — just open your instance in a phone browser.
Backspace works on mobile today. Just open your instance in a phone browser.
The UI has a dedicated touch layout (bottom navigation, swipe gestures, and
full-screen views), and because it ships as an installable **PWA** you can use
your browser's **Add to Home Screen** to install it as a standalone app: its own
icon, no browser chrome, and an offline message queue that flushes when you
reconnect.
Native **iOS and Android app-store apps are planned** — once the project gains
Native **iOS and Android app-store apps are planned**, once the project gains
traction and the funding for the developer-program licenses is secured. Until
then, the installable PWA is the supported way to run Backspace on a phone.
@@ -374,10 +611,10 @@ Backspace is a TypeScript monorepo managed with pnpm workspaces.
```
packages/
shared/ — Shared types, permission bits, constants
server/ — Fastify API + WebSocket server, Drizzle/SQLite, federation
web/ — React 18 SPA (Vite, Tailwind, Zustand)
desktop/ — Electron wrapper
shared/ - Shared types, permission bits, constants
server/ - Fastify API + WebSocket server, Drizzle/SQLite, federation
web/ - React 18 SPA (Vite, Tailwind, Zustand)
desktop/ - Electron wrapper
```
| Layer | Technology |
@@ -392,26 +629,61 @@ packages/
| Deployment | Docker Compose + Caddy (auto-HTTPS) |
Every subsystem has a dedicated specification under
[`docs/systems/`](docs/systems/) — database schema, REST API, WebSocket
[`docs/systems/`](docs/systems/): database schema, REST API, WebSocket
protocol, federation, permissions, voice, the design system, and more. **These
are the reference for how Backspace works**; start there if you want to
understand or extend a subsystem.
## FAQ
**Is Backspace a self-hosted Discord alternative?**
Yes. It gives you a Discord-style experience (spaces, channels, roles, voice,
video, screen sharing, DMs, friends) that you run entirely on your own server, so
you own the data and set the rules.
**How is it different from Revolt, Spacebar, Matrix, or Mumble?**
See the full [comparison](docs/comparison.md), including where each of those is the
better choice. In short: Backspace pairs a Discord-style client with a serious
voice and screen-share control surface and optional server-to-server federation.
**Does it have screen sharing and high-quality video?**
Yes. Screen sharing goes up to 4K/120fps within admin-set bounds, with per-stream
codec, bitrate, and resolution controls, RNNoise noise suppression, and a live
connection inspector. Voice and video use [LiveKit](https://livekit.io/) and are
optional; text, federation, DMs, and everything else run fully without them.
**Can I self-host it on a Raspberry Pi?**
Yes. The installer pulls a prebuilt multi-architecture image (amd64 and arm64), so
low-power and ARM boxes skip the heavy local build.
**Is it really open source?**
Yes, under the GNU AGPL-3.0. A separate commercial license is available for cases
the AGPL does not fit. Every released version stays available under the AGPL.
**Does it work on mobile?**
Yes, as an installable PWA with a dedicated touch layout. Native iOS and Android
apps are planned.
**What does "federation" mean here?**
Independent Backspace instances can peer with each other so users on different
servers can be friends, DM, and call across instances, while each server stays
independently owned. Requests between servers are HMAC-authenticated.
## Contributing
Contributions are welcome. Please read [`CONTRIBUTING.md`](CONTRIBUTING.md)
first. Backspace is a single-owner project, so all contributors sign a
[Contributor License Agreement](CLA.md) — a one-time comment on your pull
[Contributor License Agreement](CLA.md), a one-time comment on your pull
request, handled automatically by a bot. **You keep the copyright to your
contribution** and grant the maintainer (Jannis Braun) an exclusive license to
it — which is what lets Backspace be offered under both the AGPL and a commercial
it, which is what lets Backspace be offered under both the AGPL and a commercial
license. You also receive a perpetual license to reuse the specific code you
wrote in your own other projects.
## Security
If you discover a security vulnerability, please **do not** open a public issue.
Report it privately via a GitHub security advisory on this repository — see
Report it privately via a GitHub security advisory on this repository. See
[`SECURITY.md`](SECURITY.md). We'll work with you on a fix and coordinated
disclosure.
@@ -422,23 +694,23 @@ Backspace is **free and open source software**, licensed under the
In plain terms:
- ✅ Self-host, run, study, and modify it — including commercially and inside a business.
- ✅ Redistribute it and your changes under the same AGPL-3.0 license.
- ⚠️ If you run a **modified** version as a network service, you must offer your
users its complete corresponding source (AGPL § 13). Backspace makes this easy —
- Yes: self-host, run, study, and modify it, including commercially and inside a business.
- Yes: redistribute it and your changes under the same AGPL-3.0 license.
- Note: if you run a **modified** version as a network service, you must offer your
users its complete corresponding source (AGPL § 13). Backspace makes this easy:
set `BACKSPACE_SOURCE_URL` to your fork so the in-app "Source code" link points
at what you actually run.
- ⚠️ Preserve the copyright and license notices.
- Note: preserve the copyright and license notices.
**Commercial license.** If the AGPL doesn't fit — embedding Backspace in a
**Commercial license.** If the AGPL doesn't fit (embedding Backspace in a
closed-source product, offering it as a managed service without publishing your
modifications, or an organization that can't use AGPL software — a separate
modifications, or an organization that can't use AGPL software), a separate
commercial license is available on request. See
[`LICENSE-COMMERCIAL.md`](LICENSE-COMMERCIAL.md).
> **Our open-source commitment.** Every released version of Backspace is, and
> will remain, available under the AGPL-3.0. The Contributor License Agreement
> exists to enable a commercial license and optional enterprise add-ons — **not**
> exists to enable a commercial license and optional enterprise add-ons, **not**
> to take the open-source edition private. If this project is ever abandoned, or
> the open-source edition is relicensed under non-free terms, the community stays
> free to fork the last AGPL release.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+32 -25
View File
@@ -1,33 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 147.46 146.59">
<!--
Resenhacord app icon: the mark on the app's own night-blue ground (#0b0b10,
the theme colour), inside the 22 %-radius rounded square the icon pipeline
masks to. A soft violet glow behind the mark keeps the badge from reading as
a flat black square at large sizes.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<style>
.cls-1 {
fill: #1d1d1b;
}
.cls-2 {
fill: url(#Unbenannter_Verlauf_515);
}
.cls-3 {
fill: url(#Unbenannter_Verlauf_547);
}
</style>
<linearGradient id="Unbenannter_Verlauf_515" data-name="Unbenannter Verlauf 515" x1="62.13" y1="77.74" x2="118.16" y2="77.74" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".95" stop-color="#9a86b8"/>
<radialGradient id="glow" cx="512" cy="480" r="420" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#7c6cf6" stop-opacity="0.28"/>
<stop offset="1" stop-color="#7c6cf6" stop-opacity="0"/>
</radialGradient>
<linearGradient id="back" x1="10" y1="12" x2="74" y2="74" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#c4b5fd"/>
<stop offset="1" stop-color="#7c6cf6"/>
</linearGradient>
<linearGradient id="Unbenannter_Verlauf_547" data-name="Unbenannter Verlauf 547" x1="9.21" y1="71.45" x2="135.41" y2="71.45" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".95" stop-color="#9a86b8"/>
<linearGradient id="front" x1="42" y1="42" x2="94" y2="94" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fda4af"/>
<stop offset="1" stop-color="#f472b6"/>
</linearGradient>
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
<mask id="gap" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
<rect width="100" height="100" fill="#fff"/>
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
</mask>
</defs>
<g id="Ebene_1-2" data-name="Ebene 1">
<rect class="cls-1" x="0" y="0" width="147.46" height="146.59" rx="32.42" ry="32.42"/>
<g>
<path class="cls-2" d="M100.85,75.32c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
<path class="cls-3" d="M102.43,119.43c-.64.25-1.29.48-1.93.7l-1.67.39c-.57.14-1.04.17-1.57.26-.51.09-1.02.16-1.51.21-.98.11-1.95.2-2.9.25-.96.07-1.88.12-2.82.16-3.72.14-7.36.14-10.98.07-1.81-.03-3.61-.1-5.41-.16l-2.67-.11c-.83-.03-1.76-.08-2.69-.13l-5.55-.32-.63-.04-.81-.09c-.54-.08-1.08-.16-1.62-.27-1.07-.22-2.13-.5-3.17-.88-2.07-.74-4.05-1.78-5.83-3.11-.9-.66-1.74-1.39-2.53-2.18l-1.91-1.98-7.37-7.7-7.37-7.7c-2.49-2.54-4.81-5.23-7.56-7.52-2.75-2.29-5.15-4.91-7.6-7.49-1.23-1.29-2.4-2.62-3.61-3.93l-.45-.49-.22-.25-.34-.38c-.59-.67-1.07-1.43-1.47-2.23-.77-1.61-1.13-3.41-.99-5.18.11-1.76.72-3.49,1.7-4.96.26-.35.51-.72.81-1.04l.45-.48.25-.25.96-.93,3.83-3.71,7.66-7.42,15.32-14.84,7.66-7.42.99-.95c.4-.38.8-.74,1.23-1.09.85-.69,1.73-1.33,2.67-1.87,1.87-1.11,3.91-1.92,6.02-2.42,1.06-.24,2.13-.4,3.2-.49.54-.04,1.08-.06,1.62-.06h.73s.67.02.67.02l5.33.11,10.66.21,21.32.42,10.72.23c4.1.16,8.16,1.58,11.38,4.07.83.6,1.56,1.31,2.29,2.01.67.76,1.36,1.51,1.93,2.35,1.16,1.65,2.12,3.46,2.7,5.4.59,1.92.97,3.93.94,5.93,0,1.87-.06,3.58-.09,5.38l-.85,42.64-.22,10.68c-.12,3.6-.47,7.31-1.88,10.77-.69,1.72-1.64,3.4-2.98,4.74-1.32,1.36-2.95,2.38-4.68,3.05-1.75.64-3.58.92-5.39,1.06-.9.02-1.82.04-2.71.02-.44-.01-.89,0-1.33-.06-.22-.02-.44-.06-.67-.1l-.33-.07c-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26l.33-.07c.22-.04.44-.08.67-.1.44-.05.89-.04,1.33-.06.89-.02,1.75-.07,2.62-.12,1.72-.2,3.42-.54,5-1.18,1.55-.67,2.95-1.64,4.06-2.88,1.13-1.22,1.92-2.74,2.48-4.33,1.16-3.19,1.37-6.71,1.34-10.18l-.2-10.64-.85-42.65c-.04-1.76-.05-3.61-.12-5.28-.04-1.56-.4-3.08-.9-4.52-.5-1.46-1.29-2.79-2.21-4.01-.45-.62-1-1.16-1.52-1.71-.58-.5-1.14-1.02-1.78-1.43-2.48-1.76-5.49-2.67-8.48-2.67l-10.6.19-21.32.42-10.66.21-5.33.11h-.67c-.23.02-.45.02-.6.03-.35,0-.7.04-1.04.08-.69.09-1.38.22-2.05.4-1.34.38-2.61.95-3.77,1.7-.58.37-1.12.8-1.64,1.25-.26.23-.5.47-.74.71l-.9.94-7.36,7.72-14.72,15.43-7.36,7.72-3.68,3.86-.92.96-.22.23s0,.01,0,.01c-.01,0,.01.02,0,.01.02.02.02.02.05.04,0,.01.05,0,.04,0,.02-.02,0-.01.02-.03l.81.83c1.24,1.28,2.5,2.53,3.71,3.82,2.43,2.59,4.91,5.14,7.04,8.02,2.13,2.88,4.69,5.35,7.08,7.98l7.27,7.8,7.27,7.8,1.76,1.89c.46.48.95.92,1.48,1.32,1.06.8,2.27,1.43,3.54,1.88.64.23,1.3.4,1.97.54.33.07.67.12,1.01.17l.51.06c.33.03.46.03.7.04l5.09.27c.84.05,1.69.1,2.63.12l2.65.1c1.76.06,3.51.12,5.25.15,3.48.06,6.96.07,10.34-.17,1.68-.1,3.36-.28,4.9-.57.4-.06.77-.14,1.12-.23.35-.09.76-.16,1.04-.27l.9-.31c.18-.11.36-.22.54-.3l.65-.29c2.43-1.07,5.26.03,6.33,2.46,1.07,2.43-.03,5.26-2.46,6.33-.05.02-.12.05-.17.07Z"/>
<rect width="1024" height="1024" rx="225" fill="#0b0b10"/>
<rect width="1024" height="1024" rx="225" fill="url(#glow)"/>
<g transform="translate(192 192) scale(6.4)">
<path mask="url(#gap)" fill="url(#back)"
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
<use href="#frontBubble" fill="url(#front)"/>
<g fill="#fff">
<circle cx="58" cy="62" r="3.6"/>
<circle cx="68" cy="62" r="3.6"/>
<circle cx="78" cy="62" r="3.6"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+16 -10
View File
@@ -1,14 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 123.08 100.14">
<!-- Monochrome silhouette of mark.svg for macOS menu-bar templates (alpha only). -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<style>
.cls-1 {
fill: #1d1d1b;
}
</style>
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
<mask id="gap">
<rect width="100" height="100" fill="#fff"/>
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
</mask>
<mask id="dots">
<rect width="100" height="100" fill="#fff"/>
<circle cx="58" cy="62" r="3.6" fill="#000"/>
<circle cx="68" cy="62" r="3.6" fill="#000"/>
<circle cx="78" cy="62" r="3.6" fill="#000"/>
</mask>
</defs>
<g id="Ebene_1-2" data-name="Ebene 1">
<path class="cls-1" d="M91.64,53.93c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
<path class="cls-1" d="M93.21,98.04c-1.58.67-3.48,1.14-5.16,1.36-7.86,1.04-15.77.76-23.61.52-3.06-.04-7.8-.42-10.92-.56-2.12-.13-4.23-.56-6.23-1.28-4.01-1.34-7.48-4.13-10.26-7.27-3.66-3.85-11.08-11.56-14.75-15.4-2.49-2.54-4.81-5.23-7.56-7.52-4.08-3.48-7.59-7.49-11.21-11.42-3.78-3.58-4.71-9.08-1.77-13.49.6-.98,1.66-1.88,2.47-2.69,6.89-6.67,19.79-19.17,26.81-25.96,0,0,7.66-7.42,7.66-7.42,1.47-1.45,3.08-2.88,4.89-3.91C46.83,1.04,50.61,0,54.4,0c.89-.03,5.73.13,6.73.12,0,0,10.66.21,10.66.21,7.02.15,25.23.49,32.04.65,8.24.22,15.88,5.97,18.3,13.83,1.26,3.64.92,7.57.85,11.31,0,0-.85,42.64-.85,42.64-.31,13.92,1.41,31-17.86,30.33-.78,0-1.57-.04-2.33-.23-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26,3.35-.48,6.76-.1,9.95-1.53,6.81-2.78,8.04-10.78,7.88-17.39-.14-6.81-.88-45.63-1.05-53.3-.12-3.34.17-6.68-1.03-9.81-1.03-2.88-3-5.41-5.51-7.15-2.48-1.76-5.49-2.67-8.48-2.67,0,0-10.6.19-10.6.19-9.89.2-27.61.55-37.32.74-3.9-.11-7.81,1.33-10.51,4.18-.22.33-7.8,8.1-8.26,8.65-4.86,5.11-17.37,18.21-22.08,23.15-.73.81-4.11,4.27-4.82,5.05,0,0,0,.01,0,.01-.01,0,.01.02,0,.01.02.02.08.07.09.03.02-.02,0-.01.02-.03,0,0,.81.83.81.83,3.71,3.84,7.52,7.57,10.75,11.84,3.79,4.86,10.17,11.13,14.34,15.78,0,0,7.27,7.8,7.27,7.8,1.85,2.11,4.03,4.24,6.79,5.09,2.44.99,6.67.88,9.28,1.08,3.39.17,7.13.31,10.53.37,5.86.03,11.79.22,17.4-1.24,0,0,.9-.31.9-.31.35-.2.82-.44,1.2-.58,6.01-2.4,9.65,6.26,3.7,8.87h0Z"/>
</g>
<path mask="url(#gap)" fill="#1d1d1b"
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
<use href="#frontBubble" fill="#1d1d1b" mask="url(#dots)"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+26 -21
View File
@@ -1,28 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_2" data-name="Ebene 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 123.07 100.13">
<!--
Resenhacord mark: two speech bubbles in conversation, the front one talking.
Square viewBox so every raster target (tray, favicon, PWA) scales without
letterboxing. The gap between the bubbles is cut with a mask rather than
painted, so the mark stays legible on any background and the macOS template
(alpha-only) variant keeps the same silhouette.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<style>
.cls-1 {
fill: url(#Unbenannter_Verlauf_515);
}
.cls-2 {
fill: url(#Unbenannter_Verlauf_547);
}
</style>
<linearGradient id="Unbenannter_Verlauf_515" data-name="Unbenannter Verlauf 515" x1="52.91" y1="56.35" x2="108.94" y2="56.35" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".95" stop-color="#9a86b8"/>
<linearGradient id="back" x1="10" y1="12" x2="74" y2="74" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#c4b5fd"/>
<stop offset="1" stop-color="#7c6cf6"/>
</linearGradient>
<linearGradient id="Unbenannter_Verlauf_547" data-name="Unbenannter Verlauf 547" x1="0" y1="50.06" x2="126.19" y2="50.06" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fff"/>
<stop offset=".95" stop-color="#9a86b8"/>
<linearGradient id="front" x1="42" y1="42" x2="94" y2="94" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#fda4af"/>
<stop offset="1" stop-color="#f472b6"/>
</linearGradient>
<path id="frontBubble" d="M56 42 H80 A14 14 0 0 1 94 56 V68 A14 14 0 0 1 80 82 H76 L88 94 L68 82 H56 A14 14 0 0 1 42 68 V56 A14 14 0 0 1 56 42 Z"/>
<mask id="gap">
<rect width="100" height="100" fill="#fff"/>
<use href="#frontBubble" fill="#000" stroke="#000" stroke-width="7" stroke-linejoin="round"/>
</mask>
</defs>
<g id="Ebene_1-2" data-name="Ebene 1">
<g>
<path class="cls-1" d="M91.64,53.93c9.67-3.15,14.27-9.44,14.27-20.12,0-13.48-9.33-21.23-25.52-21.23h-16.13c-7.98,0-11.35,3.48-11.35,12.14v53.82h0c5.77,0,10.46-4.68,10.46-10.46v-7.44c-.06-.33-.1-.68-.09-1.05,0-3.35.07-6.69.12-10.04h-.03v-24.05c0-2.24,1.12-3.6,3.25-3.6h12.99c10.23,0,15.51,4.5,15.51,13.26s-5.62,14.38-15.29,14.38h-6.04c-.04,3.08-.1,6.14-.11,9.22h6.5c11.46,0,17.64,5.51,17.64,16.08s-8.01,16.22-19.36,16.22l-4.36.38h0l-10.57.41c-6.01,0-6.3,8.01-.3,8.25.34.01.68.02,1.03.02h17.37c17.32,0,27.31-8.99,27.31-24.28,0-12.14-5.84-19.56-17.3-21.92Z"/>
<path class="cls-2" d="M93.21,98.04c-.64.25-1.29.48-1.93.7l-1.67.39c-.57.14-1.04.17-1.57.26-.51.09-1.02.16-1.51.21-.98.11-1.95.2-2.9.25-.96.07-1.88.12-2.82.16-3.72.14-7.36.14-10.98.07-1.81-.03-3.61-.1-5.41-.16l-2.67-.11c-.83-.03-1.76-.08-2.69-.13l-5.55-.32-.63-.04-.81-.09c-.54-.08-1.08-.16-1.62-.27-1.07-.22-2.13-.5-3.17-.88-2.07-.74-4.05-1.78-5.83-3.11-.9-.66-1.74-1.39-2.53-2.18l-1.91-1.98-7.37-7.7-7.37-7.7c-2.49-2.54-4.81-5.23-7.56-7.52-2.75-2.29-5.15-4.91-7.6-7.49-1.23-1.29-2.4-2.62-3.61-3.93l-.45-.49-.22-.25-.34-.38c-.59-.67-1.07-1.43-1.47-2.23-.77-1.61-1.13-3.41-.99-5.18.11-1.76.72-3.49,1.7-4.96.26-.35.51-.72.81-1.04l.45-.48.25-.25.96-.93,3.83-3.71,7.66-7.42,15.32-14.84,7.66-7.42.99-.95c.4-.38.8-.74,1.23-1.09.85-.69,1.73-1.33,2.67-1.87,1.87-1.11,3.91-1.92,6.02-2.42,1.06-.24,2.13-.4,3.2-.49.54-.04,1.08-.06,1.62-.06h.73s.67.02.67.02l5.33.11,10.66.21,21.32.42,10.72.23c4.1.16,8.16,1.58,11.38,4.07.83.6,1.56,1.31,2.29,2.01.67.76,1.36,1.51,1.93,2.35,1.16,1.65,2.12,3.46,2.7,5.4.59,1.92.97,3.93.94,5.93,0,1.87-.06,3.58-.09,5.38l-.85,42.64-.22,10.68c-.12,3.6-.47,7.31-1.88,10.77-.69,1.72-1.64,3.4-2.98,4.74-1.32,1.36-2.95,2.38-4.68,3.05-1.75.64-3.58.92-5.39,1.06-.9.02-1.82.04-2.71.02-.44-.01-.89,0-1.33-.06-.22-.02-.44-.06-.67-.1l-.33-.07c-.11-.03-.22.01-.33-.26.11-.27.22-.22.33-.26l.33-.07c.22-.04.44-.08.67-.1.44-.05.89-.04,1.33-.06.89-.02,1.75-.07,2.62-.12,1.72-.2,3.42-.54,5-1.18,1.55-.67,2.95-1.64,4.06-2.88,1.13-1.22,1.92-2.74,2.48-4.33,1.16-3.19,1.37-6.71,1.34-10.18l-.2-10.64-.85-42.65c-.04-1.76-.05-3.61-.12-5.28-.04-1.56-.4-3.08-.9-4.52-.5-1.46-1.29-2.79-2.21-4.01-.45-.62-1-1.16-1.52-1.71-.58-.5-1.14-1.02-1.78-1.43-2.48-1.76-5.49-2.67-8.48-2.67l-10.6.19-21.32.42-10.66.21-5.33.11h-.67c-.23.02-.45.02-.6.03-.35,0-.7.04-1.04.08-.69.09-1.38.22-2.05.4-1.34.38-2.61.95-3.77,1.7-.58.37-1.12.8-1.64,1.25-.26.23-.5.47-.74.71l-.9.94-7.36,7.72-14.72,15.43-7.36,7.72-3.68,3.86-.92.96-.22.23s0,.01,0,.01c-.01,0,.01.02,0,.01.02.02.02.02.05.04,0,.01.05,0,.04,0,.02-.02,0-.01.02-.03l.81.83c1.24,1.28,2.5,2.53,3.71,3.82,2.43,2.59,4.91,5.14,7.04,8.02,2.13,2.88,4.69,5.35,7.08,7.98l7.27,7.8,7.27,7.8,1.76,1.89c.46.48.95.92,1.48,1.32,1.06.8,2.27,1.43,3.54,1.88.64.23,1.3.4,1.97.54.33.07.67.12,1.01.17l.51.06c.33.03.46.03.7.04l5.09.27c.84.05,1.69.1,2.63.12l2.65.1c1.76.06,3.51.12,5.25.15,3.48.06,6.96.07,10.34-.17,1.68-.1,3.36-.28,4.9-.57.4-.06.77-.14,1.12-.23.35-.09.76-.16,1.04-.27l.9-.31c.18-.11.36-.22.54-.3l.65-.29c2.43-1.07,5.26.03,6.33,2.46,1.07,2.43-.03,5.26-2.46,6.33-.05.02-.12.05-.17.07Z"/>
</g>
<path mask="url(#gap)" fill="url(#back)"
d="M26 12 H58 A16 16 0 0 1 74 28 V44 A16 16 0 0 1 58 60 H34 L16 74 L20 60 H26 A16 16 0 0 1 10 44 V28 A16 16 0 0 1 26 12 Z"/>
<use href="#frontBubble" fill="url(#front)"/>
<g fill="#fff">
<circle cx="58" cy="62" r="3.6"/>
<circle cx="68" cy="62" r="3.6"/>
<circle cx="78" cy="62" r="3.6"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

+10
View File
@@ -32,6 +32,16 @@ PI_PATH="~/backspace"
BETA_HOST="orbit.ddns.net"
BETA_PATH="~/backspace"
# ── Local target override (gitignored) ──────────────────────
# The values above are public placeholders. A maintainer can point this script
# at real infrastructure by creating ./.deploy.local (git-ignored) that reassigns
# PI_USER / PI_LOCAL / PI_REMOTE / PI_PATH / BETA_HOST / BETA_PATH. This keeps
# real hostnames, IPs, and usernames out of the public repo. Never commit it.
if [[ -f ./.deploy.local ]]; then
# shellcheck source=/dev/null
source ./.deploy.local
fi
# ── Rsync excludes ──────────────────────────────────────────
EXCLUDES=(
+36
View File
@@ -0,0 +1,36 @@
# ============================================================
# Backspace — Reverse-proxy / Tunnel override
# ============================================================
# Overlay for Mode 2 (behind your own reverse proxy) and Mode 3 (tunnel, e.g.
# Cloudflare Tunnel / Tailscale). Layer it on top of the base compose file:
#
# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
#
# What it changes versus the all-in-one base:
# 1. Publishes the app on 127.0.0.1:${APP_PORT} (loopback only) so your own
# reverse proxy or tunnel daemon on the same host can reach it. Nothing is
# exposed on a public interface by this stack — TLS/termination is the
# proxy's job.
# 2. Moves the bundled Caddy into a profile that is never activated here, so it
# does NOT start (your proxy owns 80/443). The base file leaves Caddy in the
# default profile, so Mode 1 (`-f docker-compose.yml` alone) is unchanged.
#
# ./install.sh selects the right `-f` combination automatically per mode; this
# file is also usable by hand for a fully manual setup.
# ============================================================
services:
backspace:
# Bind to loopback only. The reverse proxy / tunnel connects over 127.0.0.1;
# the app is never reachable directly from the network. Container listens on
# PORT (default 3000); APP_PORT is the host-side port your proxy forwards to.
ports:
- "127.0.0.1:${APP_PORT:-8080}:${PORT:-3000}"
caddy:
# Park Caddy in a profile that install.sh / the documented commands never
# enable, so the merged config drops it in proxy/tunnel mode. (Compose
# replaces the `profiles` list on merge; the base service has none, so this
# is the effective value only when this override is layered on.)
profiles:
- _proxy_mode_no_caddy
+14
View File
@@ -10,6 +10,14 @@
services:
# ── Backspace application server ──────────────────────────
backspace:
# Prebuilt multi-arch image on GHCR (published by .github/workflows/
# docker-publish.yml). `docker compose pull` / install.sh's default path
# fetches this so weak/ARM hosts skip the heavy local build. Both `image:`
# and `build:` are declared: if the image isn't present locally and can't be
# pulled, `docker compose up --build` (install.sh's fallback, and deploy.sh)
# builds from source instead and tags the result under this same ref.
# Override the tag with BACKSPACE_IMAGE_TAG in .env (defaults to `latest`).
image: ${BACKSPACE_IMAGE:-ghcr.io/thezwiss/backspace}:${BACKSPACE_IMAGE_TAG:-latest}
build:
context: .
args:
@@ -27,6 +35,12 @@ services:
- NODE_ENV=production
- DB_PATH=/app/data/backspace.db
- UPLOAD_DIR=/app/data/uploads
# Fail fast with a clear message if the secret is missing/empty, instead of
# letting the container boot-crash on every `restart: unless-stopped` cycle
# (a silent loop that only shows up in `docker compose logs backspace`).
# install.sh generates this before bringing the stack up; this guard only
# bites the manual `cp .env.example .env && docker compose up` path.
- "JWT_SECRET=${JWT_SECRET:?Set JWT_SECRET in .env - generate one with: openssl rand -hex 32}"
networks:
- internal
healthcheck:
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# Runs as root: make the (bind-mounted, host-owned) data dir writable by the
# non-root `node` user, then drop privileges via gosu and exec the CMD. This
# lets the container run as uid 1000 while still owning ./data on hosts where
# the bind mount was created by a different uid.
#
# - Idempotent AND cheap: only chown entries not already node-owned, so after
# the first boot this is near-instant. A plain `chown -R` over a large
# uploads/ tree on slow Pi/SD storage would delay startup on EVERY restart.
# - Non-fatal: on a bind mount that rejects chown (some CIFS/NFS backings),
# warn and continue rather than crash-looping under `restart: unless-stopped`
# (the old root container booted fine on such mounts).
set -e
mkdir -p /app/data/uploads
chown node:node /app/data /app/data/uploads 2>/dev/null || true
find /app/data ! -user node -exec chown node:node {} + 2>/dev/null || \
echo "docker-entrypoint: warning: could not chown /app/data; continuing (ensure it is writable by uid 1000)"
exec gosu node "$@"
+56
View File
@@ -0,0 +1,56 @@
# Gerar o instalador do app desktop
O workflow **`.github/workflows/release.yml`** (herdado do upstream) já compila
para macOS, Windows e Linux e publica os instaladores como release. Ele dispara
ao empurrar uma tag `v*`.
Não foi preciso escrever workflow novo — foi preciso **adaptá-lo ao fork**.
## Por que GitHub e não Gitea
O Gitea desta instância não tem Actions habilitado, e mesmo habilitado ele
**não oferece máquinas hospedadas**: seria preciso registrar um PC Windows
como runner e mantê-lo ligado. O GitHub fornece runners Windows prontos, que é
exatamente o que falta — o módulo nativo do compartilhamento de áudio usa
WASAPI e só compila no Windows, com o compilador da Microsoft.
O Gitea continua sendo o repositório principal. O GitHub entra apenas como
espelho para compilar.
## Passos (uma vez)
1. Criar um repositório no GitHub — o `electron-builder.yml` está apontado para
`syncwrld/resenhacord`. **Se o seu for outro nome, ajuste lá.**
2. Adicionar o espelho e empurrar:
```
cd /opt/backspace
git remote add github git@github.com:syncwrld/resenhacord.git
git push github main
```
3. Marcar uma versão e empurrar a tag — é ela que dispara a compilação:
```
git tag v1.0.1 && git push github v1.0.1
```
4. Os instaladores aparecem na aba Releases do GitHub em ~15 minutos.
## O que isso resolve de quebra
O `electron-updater` já estava instalado no projeto mas **sem feed** — o app
não se atualizava sozinho. Como o `publish` agora aponta para as releases do
fork, o app passa a encontrar versões novas por conta própria. Ninguém do grupo
precisa reinstalar na mão de novo.
## Adaptações feitas para o fork
| O quê | Por quê |
|---|---|
| `publish.owner` → `syncwrld` | Apontava para o repositório do upstream |
| `postinstall` reconstrói também `electron-native-screenshare` | Rodava `electron-rebuild` só no `uiohook-napi`. O módulo novo seria compilado para a ABI do Node em vez da do Electron e falharia ao carregar — e como o carregamento degrada em silêncio, o sintoma seria "compartilha sem som", sem erro visível |
| Nota sobre `asarUnpack` | O `uiohook-napi` tem `build/` excluído do asar porque distribui binários prontos. O módulo novo **não** distribui: `build/Release/*.node` é a única cópia e não pode ser excluída |
## Detalhe herdado que vale preservar
O runner do Windows está fixado em `windows-2022`, não `windows-latest`. O
comentário no workflow explica: a imagem `latest` traz o Visual Studio 18, que
o node-gyp embutido no `electron-rebuild` não detecta ("unknown version
undefined"). Não mude isso sem testar.
+82
View File
@@ -0,0 +1,82 @@
# Backspace compared to other chat platforms
This page is an honest look at how Backspace fits next to the tools people usually
weigh against it: Discord, Revolt, Spacebar, Matrix/Element, and Mumble. It
includes the places where those tools are the better choice. Feature sets change,
so verify current details on each project before you decide, and open an issue if
anything here is out of date.
Short version: Backspace is for people who want a Discord-style experience they
fully self-host, with serious voice and screen-share controls, and the option to
federate independently owned servers. If you need the largest ecosystem, a mature
open federation standard, or native mobile apps today, one of the others may suit
you better.
## Feature matrix
| | Backspace | Discord | Revolt | Spacebar | Matrix / Element | Mumble |
|---|---|---|---|---|---|---|
| Self-hostable | Yes | No | Yes | Yes | Yes | Yes |
| Open source | Yes (AGPL-3.0) | No | Yes | Yes | Yes | Yes |
| Discord-style UX | Yes | Yes | Yes | Yes (Discord client) | Different model | No |
| Text chat, roles, reactions | Yes | Yes | Yes | Yes | Yes | Limited |
| Voice channels | Yes | Yes | Yes | Partial | Yes | Yes (focus) |
| Video and screen share | Yes, up to 4K/120fps within admin bounds | Yes | Limited | Partial | Yes | No |
| Per-stream media controls (codec, bitrate, resolution) | Yes | No | No | No | Partial | Some audio |
| Federation between servers | Yes, between Backspace instances | No | No | No | Yes, open standard | No |
| Native mobile apps | Installable PWA | Yes | Yes | Desktop client only | Yes | Yes |
| Runs on a Raspberry Pi | Yes (prebuilt arm64 image) | No | Yes | Yes | Yes | Yes |
"Partial" and "Limited" mean the capability exists but is less complete or less
polished than the leaders in that row at the time of writing. Check the current
state of each project.
## Backspace vs Discord
Discord is the reference experience and has the ecosystem, the bots, and the user
base. It is also proprietary, you cannot host it, and you do not own the data or
the moderation policy. Backspace exists for the people who want the Discord shape
without giving up ownership. You trade the ecosystem and the network effect for
control of the server, the data, and the rules. If you want the biggest community
and the deepest bot ecosystem, use Discord. If you want to own your instance, use
Backspace.
## Backspace vs Revolt
Revolt is the closest peer: an open-source, self-hostable, Discord-style chat with
an active community and a Rust backend. The main differences are the media stack
and federation. Backspace is built around a full voice and video control surface
(per-stream codec, bitrate, and resolution, RNNoise, a live connection inspector,
screen share up to 4K/120fps within admin limits) and supports peering independent
instances. If high-quality voice and screen sharing or cross-instance federation
are central to you, Backspace is aimed squarely at that. If you want a larger
existing community and a longer track record, look at Revolt.
## Backspace vs Spacebar
Spacebar reimplements the Discord backend so the actual Discord client can talk to
a server you host. That is a clever path to instant client familiarity, and it is
the right pick if using the real Discord app against your own backend is the goal.
The trade-off is that it inherits Discord's client and its constraints, and its
voice stack is still maturing. Backspace ships its own client and its own voice
and video stack, and it is federation-first rather than Discord-protocol-first.
## Backspace vs Matrix and Element
Matrix is the mature, standardized answer to open federation, and Element is its
best-known client. If interoperable, standards-based federation across many
different server and client implementations is your priority, Matrix is the
stronger choice and Backspace does not try to replace it. Backspace federation is
newer, simpler, and currently peers Backspace instances with each other rather
than speaking an open cross-ecosystem protocol. Where Backspace differs is the
experience: a tightly integrated Discord-style client with a purpose-built media
control surface, rather than a protocol with many clients of varying polish.
## Backspace vs Mumble
Mumble is outstanding at exactly one thing: low-latency voice for groups, self
hosted, lightweight. It has no rich text platform, no video, and no federation.
If all you need is the best self-hosted push-to-talk voice, Mumble is a great,
proven choice. Backspace is a full communication platform (text, voice, video,
files, social, federation) rather than a dedicated voice server, so pick it when
you want more than voice.
+209
View File
@@ -0,0 +1,209 @@
# Roadmap — fork Resenha
Plano de features próprias desta instância. Arquivo exclusivo do fork
(nome com sufixo para não colidir com arquivos do upstream em merges).
Escrito em português por ser documento de planejamento do dono do fork; o
código e os commits seguem em inglês, como o resto do repositório.
## Entregue
| Feature | Commit | Nota |
|---|---|---|
| Ir para a call clicando no nome do canal | `c70b0095` | Exigiu o `voiceStore` passar a guardar o espaço da call — antes ele não sabia onde a call estava assim que o usuário navegava para outro servidor |
| Botão de GIF redesenhado | `20526e1b` | Contorno vazado com letras cheias, no lugar do bloco sólido |
| Explorador de GIF no banner | `20526e1b` | Sem upload: banner já aceita URL absoluta no cliente e no servidor |
| Teste de microfone com retorno | `bfe62d70` | `AudioManager.startMicTest/stopMicTest`; devolve o microfone ao parar, com duas travas independentes |
| Bloco de atividade no perfil | `d7da0ff2` | `ProfileActivity`; inclui correção de validação de assets no servidor |
| Favoritar GIFs + categorias (#6) | `fb662bfe` | Favoritos no servidor, por conta; guarda o resultado inteiro porque o provedor não busca por id |
| Registro de auditoria (#9) | `bbb190cb` | Tabela append-only genérica; 8 ganchos; sair ≠ ser expulso |
| Estatísticas do grupo | `18300517` | Tabela própria de sessões de voz; ganchos no ponto único de entrada/saída |
| Spotify por OAuth (atividade com capa e progresso) | `75316b08` | Token só no servidor; `state` assinado; atividades por fonte para não brigar com o detector do Electron |
| Menu do próprio nome | `f5451e1b` | Perfil, status e copiar ID. Sem Clips/trocar conta — não existem aqui |
| Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída |
| Correções do Spotify (sincronia, sumiço, barra) | `c899253e` | Pausa virou estado; horário do servidor no `ready` corrige relógio |
| Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar |
| Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música |
| Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada |
| Fixar mensagens no canal | `4ceb5cd6` | Estado na própria mensagem; painel no cabeçalho; limite de 50 |
| Busca: filtros embutidos e tradução | `50a8f12c` | Os filtros já existiam; faltava `de:fulano` e descoberta |
| Emojis e figurinhas do servidor | `7d003021` | `:nome:` no markdown sem plugin; figurinha como mensagem inteira |
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
| Rebrand: **Resenhacord** + logo nova | (ver git log) | `productName` mudou, `appId` **não** — assim o NSIS troca a instalação antiga no lugar. `userData` migra `Backspace` → `Resenhacord` no primeiro boot (senão todo mundo cairia da sessão). Logo: duas bolhas de conversa, roxo → rosa; fonte em `assets/brand/*.svg`, PNGs e ícones gerados por `pnpm gen-icons` |
| Aviso "X fixou uma mensagem" | (ver git log) | Tabela `messages` ganhou `type` (migração 0018); a rota de pin posta uma mensagem de sistema. Dois links: pular pra mensagem e abrir o painel de fixadas |
| Menção `@everyone` | (ver git log) | Reusa o token `<@id>`; permissão `MENTION_EVERYONE` nova, concedida ao `@everyone` por padrão e retroativa via backfill no boot. Servidor neutraliza o token de quem não tem a permissão, no envio **e** na edição |
| Renomear canal (nome e tópico) | (ver git log) | Rota `PATCH /api/channels/:id` já existia; faltava interface. Normalização em `shared/constants` para servidor e web usarem a mesma regra |
| Volume da tela em 50% por padrão | (ver git log) | `DEFAULT_STREAM_VOLUME`; o publicador reforça o áudio do sistema, então o padrão anterior chegava alto demais |
| Sons 50% mais baixos | (ver git log) | Só `SFX_BASE_VOLUME` (0.8 → 0.4); os `.ogg` não foram regerados |
| Atualização do desktop verificada com mais frequência | (ver git log) | Era só na abertura e de 4 em 4 horas; agora a cada 30 min e ao focar a janela, com trava de 5 min |
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
## Já existia no código (verificado, não construir de novo)
- **Busca com filtros** — servidor (`q`, `from`, `has`, `before`, `after`),
client de API e painel no `SearchPopover` já existiam completos. Só faltava
tradução, sintaxe embutida (`de:fulano`) e descoberta.
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
escalonados em 0/150/300ms.
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
stream started/ended, alguém entra/sai da tela, entra/sai da call, câmera,
mute, ringing. Os `.ogg` estão em `web/public/sounds/`.
- **Preview de perfil ancorado** — `uiStore.openUserProfile(user, anchor, placement)`
guarda `userProfilePopout`; popout posicionado no desktop, tela cheia no
mobile. Já era aberto por mensagens, menções, avatares, lista de membros,
DMs e painel de atividade. O que faltava era só a voz — agora ligado.
Continua faltando o bloco de atividade do print do Discord, que é a #8.
Se qualquer um dos dois não se manifestar em uso, o trabalho é **depuração**,
não implementação.
## Pendente — pedido original
Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
| # | Feature | Tamanho | Observação técnica |
|---|---|---|---|
## Identidade do perfil (entregue 2026-09-02, pedido: "completo e FODA")
Uma coluna JSON (`users.profile_style`, migração 0019) normalizada no servidor
por `shared/profile.ts`. Aba **Perfil** nas configurações com prévia ao vivo
(`ProfilePanel` renderiza o mesmo `ProfileCard` que o popout).
| Peça | Onde aparece | Detalhe |
|---|---|---|
| Estilo do nome — 10 fontes × 9 efeitos × 2 cores | popout, perfil completo | Fontes OFL em `public/fonts`; efeitos são CSS (`.name-fx-*`) |
| Decoração do avatar — 10 | popout, perfil, editor | SVG inline em `DecoratedAvatar`, sem imagens |
| Efeito do perfil — 9 | banner do popout e do perfil | Partículas CSS com posições semeadas (`ProfileEffectLayer`) |
| Nameplate — 10 + personalizado | lista de membros | `.nameplate-*` |
| Tema (2 cores) | fundo do cartão e banner sem imagem | |
| Pronomes, tag (emoji + 2–4 letras) | ao lado do nome; tag também na lista de membros | |
| Links (até 5) | popout e aba Sobre | Ícone detectado pelo host; só https |
| Quadro: jogo favorito, rotação (5), quero jogar (10), citação | aba Quadro do perfil; tira "Coleção" no popout | Busca na Steam via `/api/games/search` (proxy, cache 10 min); capas só de CDN da Steam |
| Emblemas derivados | popout e perfil | admin, dono, fundador (1º mês), veterano (1 ano), OG (6 meses), estiloso — nada é armazenado |
Fora do escopo desta entrega, candidatos a seguir: perfil por servidor
(apelido/avatar por servidor), emblemas concedidos pelo admin, molduras
animadas por upload, status com expiração.
## Pendente — ideias aprovadas
| Feature | Tamanho | Observação técnica |
|---|---|---|
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
## Regra de idioma (a partir de 2026-08-31)
Funcionalidade nova sai com interface em **pt-BR**, e a cada update um sistema
existente é traduzido. Os dois idiomas **coexistem**. Código, comentários e
commits seguem em inglês.
A fundação está pronta (`src/i18n/`). Para traduzir um sistema: adicione as
chaves em `locales/en.ts`, traduza em `locales/pt-BR.ts` e troque as strings
fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
### Sistemas já traduzidos
- Configurações → navegação e aba Idioma
- Configurações → Voz (dispositivo de entrada, volume, teste de microfone)
- Cartão de perfil (sobre mim, membro desde, enviar mensagem, atividade)
- Configurações → Privacidade
- Configurações → Conexões (nasceu bilíngue)
- Seletor de GIF
- Chat: composer e mensagens (placeholder, anexos, responder, reações)
- Barra lateral: servidores, canais e menus de contexto
- Lista de membros (grupos por cargo, carregamento)
- Busca (rótulos, filtros e sintaxe embutida)
- Registro de auditoria e Estatísticas (nasceram bilíngues)
- Configurações do canal (visão geral, privacidade, exclusão e rótulos de permissão)
- Amigos: abas, listas, pedidos, aba de adicionar e cartões de descoberta
- Modal de convite (incluindo os motivos de falha por convite)
- Lista de membros da DM em grupo (remover e transferir propriedade)
- Configurações → Área de trabalho (início automático, atualizações, instância)
- Configurações → Voz: dispositivo de saída (completa a aba, junto com a entrada)
- Configurações → Atalhos (incluindo os nomes das ações e o aviso de conflito)
- Configurações → Conta (perfil, senha, conta desligada da origem, zona de perigo)
- Explorar servidores (cartões, entrar, pedir para entrar)
- Configurações → Voz: vídeo (prévia, permissões, escolha de câmera) — fecha a aba
- Criar canal, categoria e servidor
- DMs em grupo (nova DM, adicionar amigos, configurações do grupo, sair)
- Entrar num servidor (descoberta, código de convite, conectar e login federado)
- Excluir conta, transferir propriedade e a tela de erro geral
- Configurações do servidor → Visão geral (identidade, ícone, banner, zona de perigo)
- Configurações do servidor → Membros e banimentos
- Configurações do servidor → Cargos (incluindo a tabela de permissões)
- Configurações da categoria (renomear, privacidade, exceções, excluir)
- Configurações da instância → Geral (nome, descoberta, busca de GIFs)
- Mensagens de sistema do chat e a prévia delas na barra lateral
- Login, registro e a página de convite
- Voz: controles, informações de conexão, folha de entrada no celular, aba Voz
- Telas do celular (`Mobile*.tsx`)
- Navegação das configurações (servidor, usuário e instância) + Descoberta e
pedidos de entrada
Os rótulos de permissão viraram um conjunto único (`perm.*`), usado pelo painel
de cargos e pelos editores de exceção de canal e de categoria — antes havia
duas cópias da mesma lista.
### Fila sugerida de tradução
Configurações → Instância: registro, federação, transmissão, armazenamento e
usuários (os cinco painéis grandes, só o dono vê) · `ConnectedInstances` ·
`UserProfileModal` · `MainContent` e `ChannelSidebar` · mensagens de erro
vindas de stores e hooks (`instanceStore`, `useLiveKit`, `transferStore`)
## Correções pendentes
| Problema | Causa provável | Correção |
|---|---|---|
## App desktop — já existia (verificado 2026-08-31)
As três pedidas já estão implementadas e ligadas de ponta a ponta. **Não
construir de novo.**
- **Bandeja** — `createTray()` em `desktop/src/main.ts`, chamada na inicialização;
fechar a janela esconde em vez de sair (`mainWindow.on('close')`).
- **Notificações nativas** — `showNotification()` no processo principal, canal
IPC `show-notification`, e o web já chama por `platform/notifications.ts`.
- **Áudio do sistema no compartilhamento** — `setDisplayMediaRequestHandler`
devolve `audio: 'loopback'`; existe caixa de seleção no `ScreenSharePicker`
ligada a `screenShareConfig.shareAudio`, que atravessa o IPC.
Se algum não se manifestar em uso, o trabalho é **depuração**, não construção.
## Aprovadas, a fazer depois (2026-08-31)
Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.**
| Feature | Tamanho | Observação técnica |
|---|---|---|
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
## Ideias novas — a avaliar
Ordenadas por relação valor/custo para um servidor de grupo fechado.
| Sistema | Tamanho | Por que faz sentido aqui |
|---|---|---|
| **Backup fora da VPS** | Pequena | Hoje app, banco, uploads, backups e as três cópias do repositório morrem no mesmo evento. Um envio periódico para fora resolve |
| **Aniversários e lembretes** | Pequena | Alto retorno afetivo, custo baixo: campo de data + verificação diária + mensagem no canal |
| **Perfis por servidor** | Média | Apelido e avatar diferentes por espaço, como no Discord. O modelo já tem membro por espaço |
| **Eventos agendados com presença** | Média | "Sexta 21h" com confirmação. Encaixa nas notificações e no PWA já instalado |
| **Notificações push de verdade** | Média | O `vite-plugin-pwa` e o service worker já estão lá; falta Web Push (VAPID) e o registro no servidor |
| **Níveis e conquistas** | Média | Gamificação por tempo em call e mensagens. **Mesmo registro de eventos da auditoria e das estatísticas** — três features, um mecanismo |
| **Clipes de call** | Grande | "Salvar os últimos 30 segundos" depois de alguém falar besteira. O LiveKit tem egress; exige buffer contínuo e armazenamento |
| **Fila de música compartilhada** | Grande | Um participante-robô publicando faixa de áudio na sala LiveKit. É o que mais muda o uso de um servidor de amigos, e o mais caro |
| **Autenticação em duas etapas** | Média | Só faz sentido depois de decidir o modelo de cadastro |
## Dependência que vale respeitar
**Auditoria (#9) e Estatísticas compartilham o mesmo mecanismo**: uma tabela de
eventos append-only no servidor. Construir a auditoria primeiro e as
estatísticas como leitura agregada dessa mesma tabela evita escrever dois
sistemas de registro paralelos que divergem com o tempo.
@@ -0,0 +1,645 @@
# Plan A — Scanning Pipeline & Supply-Chain Hardening (report-only) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stand up the full automated security-scanning pipeline (Dependabot, CodeQL SAST, secret scanning, dependency CVEs, IaC/license scanning, OpenSSF Scorecard) plus supply-chain hardening (SHA-pinned actions, harden-runner, least-privilege permissions) on GitHub Actions — all **report-only/advisory**, so the PR that adds it stays green and mergeable.
**Architecture:** Four new files under `.github/` (one Dependabot config + three workflows) each with a single scan responsibility, plus a hardening sweep across the four existing workflows. Every scanner uploads SARIF to the GitHub Security tab and is non-blocking in this plan; enforcement (fail-the-build) is flipped on in a later plan (Plan E) after the remediation pass. This is the foundation the rest of the initiative builds on and, on its own, answers the "no security scanning" objection with visibly-running scanners.
**Tech Stack:** GitHub Actions (YAML), GitHub Dependabot, CodeQL (`javascript-typescript`, build-mode `none`), gitleaks, OSV-Scanner (reads `pnpm-lock.yaml` v9), Aqua Trivy (config + license), OpenSSF Scorecard, StepSecurity harden-runner. Local validators: `actionlint`, `pinact` (SHA-pinning).
## Global Constraints
- **Report-only in this plan.** Every scanner must be non-blocking (`continue-on-error: true` at step level, or advisory SARIF upload). Enforcement is flipped on in Plan E — do NOT make any scanner fail the build here. Each non-blocking step carries a comment: `# report-only; enforcement flipped on in Plan E`.
- **SHA-pin every action.** All `uses:` refs across ALL workflows (new and existing) pin to a full 40-char commit SHA with a trailing `# vX.Y.Z` version comment. No `@v5`/`@main` tag refs may remain after Task 5.
- **harden-runner is Linux-only.** `step-security/harden-runner` runs only on Ubuntu runners. In any matrix that includes macOS/Windows (i.e. `release.yml`), guard it with `if: runner.os == 'Linux'`.
- **`egress-policy: audit`** for every harden-runner step (never `block` in this plan — multi-arch buildx/QEMU/gha-cache make many egress calls).
- **Commit identity:** the repo's local git config already uses `Jannis Braun <151788261+TheZwiss@users.noreply.github.com>` — use a plain `git commit`. NEVER override author/committer email with `-c user.email=...`, and never commit as `alxtrading94@gmail.com`.
- **No new runtime dependencies.** This plan touches only `.github/` and docs; it must not modify `package.json` dependency lists or any application/runtime code.
- **Node 20 / pnpm 10.34.3** are the project's pinned toolchain — any workflow that installs deps mirrors `ci.yml` (`pnpm/action-setup` @ 10.34.3, `actions/setup-node` node 20).
- **Branch:** all work lands on `security-scanning-hardening` (already checked out).
- **Action versions:** the YAML below uses each action's current major tag. If an action's latest major differs at implementation time, check its README and adjust the tag — then Task 5 pins whatever tag you used to its SHA. A wrong tag surfaces as an `actionlint` error or a red PR check; fix and re-run.
---
### Task 1: Dependabot config + local validators
**Files:**
- Create: `.github/dependabot.yml`
**Interfaces:**
- Consumes: nothing (first task).
- Produces: `.github/dependabot.yml` — the Dependabot v2 config later documented by Task 6. No code symbols.
- [ ] **Step 1: Install the local validators**
`actionlint` validates workflow YAML; `pinact` will SHA-pin actions in Task 5. On the macOS dev host:
Run:
```bash
brew install actionlint pinact
actionlint --version && pinact --version
```
Expected: both print a version. (Fallbacks if Homebrew lacks them: `go install github.com/rhysd/actionlint/cmd/actionlint@latest` and `go install github.com/suzuki-shunsuke/pinact/cmd/pinact@latest`, or run actionlint via Docker `docker run --rm -v "$(pwd):/repo" --workdir /repo rhysd/actionlint:latest -color`.)
- [ ] **Step 2: Write `.github/dependabot.yml`**
```yaml
# Dependabot keeps dependencies and CI actions patched. Three ecosystems:
# - npm → the pnpm workspace (Dependabot reads pnpm-lock.yaml v9)
# - github-actions → action version bumps (feeds the SHA-pin comments)
# - docker → the Dockerfile base image (FROM node:20-slim)
#
# NOTE (intentional): there is NO docker entry for docker-compose.yml. It sits
# at the same "/" directory (a second docker entry would collide on
# ecosystem+directory), and Dependabot's docker ecosystem parses Dockerfiles,
# not `image:` refs in compose. The pinned caddy / livekit-server compose images
# are updated MANUALLY — see the maintainer checklist in
# docs/systems/security-scanning.md.
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10
groups:
# One grouped PR for routine minor/patch bumps to cut PR noise.
npm-minor-patch:
update-types:
- minor
- patch
ignore:
# uiohook-napi is pinned by an exact-version pnpm patch
# (patches/uiohook-napi@1.5.5.patch). A bump makes the patch path stop
# matching, breaking `pnpm install --frozen-lockfile` in CI and both
# Docker stages until the patch is regenerated. Bump it by hand.
- dependency-name: uiohook-napi
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
```
- [ ] **Step 3: Validate YAML syntax**
Run:
```bash
python3 -c "import yaml,sys; yaml.safe_load(open('.github/dependabot.yml')); print('dependabot.yml: valid YAML')"
```
Expected: `dependabot.yml: valid YAML` (no traceback). (The full schema is validated by GitHub after push — Task 7 confirms it in the repo's Insights → Dependency graph → Dependabot.)
- [ ] **Step 4: Commit**
```bash
git add .github/dependabot.yml
git commit -m "ci(security): add Dependabot config (npm + actions + docker)"
```
---
### Task 2: `security.yml` — secret, dependency, IaC & license scanning (report-only)
**Files:**
- Create: `.github/workflows/security.yml`
**Interfaces:**
- Consumes: `pnpm-lock.yaml` (OSV lockfile scan), repo tree (gitleaks history, Trivy config/license).
- Produces: workflow `Security` with jobs `gitleaks`, `osv-scanner`, `trivy-config`, `trivy-license`; each uploads a SARIF category (`gitleaks`, `osv-scanner`, `trivy-config`, `trivy-license`). Task 6 documents these; Task 5 pins their actions.
- [ ] **Step 1: Write `.github/workflows/security.yml`**
```yaml
name: Security
# Report-only in this plan: every scanner is non-blocking and uploads SARIF to
# the Security tab. Enforcement (fail on fixable HIGH/CRITICAL, block on secrets)
# is flipped on in Plan E after the remediation pass.
on:
pull_request:
push:
branches: [main]
schedule:
- cron: '32 5 * * 1' # weekly Monday 05:32 UTC
permissions:
contents: read
concurrency:
group: security-${{ github.ref }}
cancel-in-progress: true
jobs:
gitleaks:
name: Secret scan (gitleaks)
runs-on: ubuntu-latest
steps:
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout (full history)
uses: actions/checkout@v5
with:
fetch-depth: 0 # gitleaks scans the whole git history, not just the diff
- name: Run gitleaks
uses: gitleaks/gitleaks-action@v2
continue-on-error: true # report-only; enforcement flipped on in Plan E
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
osv-scanner:
name: Dependency scan (OSV-Scanner)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # upload SARIF to code scanning
steps:
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v5
- name: Run OSV-Scanner
uses: google/osv-scanner-action@v2
continue-on-error: true # report-only; enforcement flipped on in Plan E
with:
scan-args: |-
--lockfile=./pnpm-lock.yaml
--format=sarif
--output=osv-results.sarif
- name: Upload OSV SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: osv-results.sarif
category: osv-scanner
trivy-config:
name: IaC/config scan (Trivy)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v5
- name: Trivy config scan (Dockerfile + docker-compose)
uses: aquasecurity/trivy-action@0.28.0
continue-on-error: true # report-only; enforcement flipped on in Plan E
with:
scan-type: config
scan-ref: .
format: sarif
output: trivy-config.sarif
- name: Upload Trivy config SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-config.sarif
category: trivy-config
trivy-license:
name: License compliance scan (Trivy)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v5
- name: Trivy license scan
uses: aquasecurity/trivy-action@0.28.0
continue-on-error: true # report-only; enforcement flipped on in Plan E
with:
scan-type: fs
scan-ref: .
scanners: license
format: sarif
output: trivy-license.sarif
- name: Upload Trivy license SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-license.sarif
category: trivy-license
```
- [ ] **Step 2: Validate with actionlint**
Run:
```bash
actionlint .github/workflows/security.yml
```
Expected: no output (exit 0). If actionlint flags an unknown input for an action, check that action's README and correct it. (Note: actionlint does not fetch action inputs, so most such errors are shellcheck/expression issues — fix those.)
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/security.yml
git commit -m "ci(security): add report-only security scan workflow (gitleaks, OSV, Trivy)"
```
---
### Task 3: `codeql.yml` — CodeQL SAST (report-only)
**Files:**
- Create: `.github/workflows/codeql.yml`
**Interfaces:**
- Consumes: repo TypeScript/JavaScript source (analyzed with `build-mode: none`).
- Produces: workflow `CodeQL` with job `analyze`, category `/language:javascript-typescript`. Findings land in the Security tab. Task 6 documents it; Task 5 pins its actions.
- [ ] **Step 1: Write `.github/workflows/codeql.yml`**
```yaml
name: CodeQL
# Static application security testing for all TS/JS. Uses build-mode: none — no
# compile needed, which sidesteps the monorepo/native-module build entirely.
# Default (code-scanning) query suite; security-extended is deferred (triage tax).
# CodeQL uploads alerts to the Security tab but does NOT fail the PR by itself —
# blocking is a repo setting (code-scanning merge protection), documented in the
# maintainer checklist in docs/systems/security-scanning.md.
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '27 3 * * 1' # weekly Monday 03:27 UTC
permissions:
contents: read
concurrency:
group: codeql-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: Analyze (javascript-typescript)
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # upload SARIF to code scanning
actions: read
steps:
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v5
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:javascript-typescript"
```
- [ ] **Step 2: Validate with actionlint**
Run:
```bash
actionlint .github/workflows/codeql.yml
```
Expected: no output (exit 0).
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/codeql.yml
git commit -m "ci(security): add CodeQL SAST workflow (javascript-typescript)"
```
---
### Task 4: `scorecard.yml` — OpenSSF Scorecard (report-only)
**Files:**
- Create: `.github/workflows/scorecard.yml`
**Interfaces:**
- Consumes: the whole repo + workflow metadata (Scorecard evaluates repo posture).
- Produces: workflow `OpenSSF Scorecard` with job `analysis`; publishes results (feeds the public badge added in Plan E) and uploads SARIF. Task 5 pins its actions.
- [ ] **Step 1: Write `.github/workflows/scorecard.yml`**
```yaml
name: OpenSSF Scorecard
# Scores the repo's security posture (branch protection, pinned deps, token
# permissions, etc.) and publishes to the OpenSSF public API so a badge can be
# shown (badge is added in Plan E). REQUIRES the canonical repo to be PUBLIC —
# see the maintainer checklist in docs/systems/security-scanning.md.
on:
branch_protection_rule:
schedule:
- cron: '18 4 * * 2' # weekly Tuesday 04:18 UTC
push:
branches: [main]
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
security-events: write # upload SARIF
id-token: write # publish_results OIDC attestation
steps:
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@v5
with:
persist-credentials: false
- name: Run Scorecard
uses: ossf/scorecard-action@v2
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: SARIF file
path: results.sarif
retention-days: 5
- name: Upload SARIF to code scanning
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
```
- [ ] **Step 2: Validate with actionlint**
Run:
```bash
actionlint .github/workflows/scorecard.yml
```
Expected: no output (exit 0).
- [ ] **Step 3: Commit**
```bash
git add .github/workflows/scorecard.yml
git commit -m "ci(security): add OpenSSF Scorecard workflow"
```
---
### Task 5: Harden existing workflows + SHA-pin every action
**Files:**
- Modify: `.github/workflows/ci.yml` (add harden-runner step)
- Modify: `.github/workflows/release.yml` (add Linux-guarded harden-runner step)
- Modify: `.github/workflows/security.yml`, `codeql.yml`, `scorecard.yml`, `ci.yml`, `release.yml`, `cla.yml`, `deploy-pages.yml`, `docker-publish.yml` (SHA-pin all `uses:`)
**Interfaces:**
- Consumes: all workflow files from Tasks 2-4 plus the four pre-existing ones.
- Produces: every `uses:` pinned to `@<40-char-sha> # vX.Y.Z`; harden-runner (audit) on the two build workflows. No code symbols.
- [ ] **Step 1: Add harden-runner to `ci.yml`**
In `.github/workflows/ci.yml`, insert as the FIRST step of the `build-and-test` job (before `Checkout`):
```yaml
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
```
- [ ] **Step 2: Add Linux-guarded harden-runner to `release.yml`**
In `.github/workflows/release.yml`, insert as the FIRST step of the `build` matrix job (before `Checkout`). It MUST be guarded — the matrix includes macOS and Windows, where harden-runner does not run:
```yaml
- name: Harden the runner
if: runner.os == 'Linux'
uses: step-security/harden-runner@v2
with:
egress-policy: audit
```
- [ ] **Step 3: SHA-pin every action across all workflows**
Run `pinact` from the repo root — it rewrites each `uses: owner/repo@vX` to `uses: owner/repo@<sha> # vX` in place:
```bash
pinact run
```
Manual fallback (if `pinact` is unavailable) — resolve each tag to its commit SHA with `gh` and edit by hand. `repos/{repo}/commits/{ref}` dereferences both lightweight and annotated tags to the commit:
```bash
# Example for one action; repeat for every distinct uses: ref.
gh api repos/actions/checkout/commits/v5 --jq '.sha'
# → paste as: uses: actions/checkout@<sha> # v5
```
- [ ] **Step 4: Verify no unpinned action refs remain**
Run (flags any `uses:` ref NOT pinned to a 40-hex-char SHA — catches both `@v5` and non-`v` semver tags like Trivy's `@0.28.0`):
```bash
grep -rnE 'uses: +[^ ]+@' .github/workflows/ | grep -vE '@[0-9a-f]{40}' && echo "UNPINNED REFS FOUND (fix above)" || echo "All actions pinned to SHA"
```
Expected: `All actions pinned to SHA` (the second grep exits non-zero when nothing is unpinned, so the `||` branch prints). A properly pinned line contains `@<40-hex> # vX.Y.Z` and is filtered out; any surviving line is an unpinned ref to fix.
- [ ] **Step 5: Re-validate all workflows**
Run:
```bash
actionlint
```
Expected: no output (exit 0) — actionlint scans every file in `.github/workflows/`.
- [ ] **Step 6: Commit**
```bash
git add .github/workflows/
git commit -m "ci(security): SHA-pin all actions and add harden-runner (audit)"
```
---
### Task 6: Document the pipeline
**Files:**
- Create: `docs/systems/security-scanning.md`
- Modify: `CLAUDE.md` (add a subsystem-table row)
**Interfaces:**
- Consumes: the workflows/config from Tasks 1-5 (documents them).
- Produces: the `security-scanning.md` spec + maintainer checklist referenced by every workflow comment; a CLAUDE.md table row. No code symbols.
- [ ] **Step 1: Write `docs/systems/security-scanning.md`**
```markdown
# Security Scanning & Supply-Chain Assurance
Automated, continuous scanning wired into GitHub Actions. This document is the
reference for what runs, where results go, and the one-time settings a maintainer
must enable. **Current state: report-only** — scanners surface findings in the
Security tab but do not block merges yet. Enforcement (blocking) is turned on in a
later change once the remediation pass has cleared the backlog.
## Workflows
| File | Purpose | Trigger | Result |
|------|---------|---------|--------|
| `.github/dependabot.yml` | Dependency + action + base-image update PRs | weekly | PRs |
| `.github/workflows/codeql.yml` | CodeQL SAST (`javascript-typescript`, build-mode none) | PR + push main + weekly | Security tab |
| `.github/workflows/security.yml` | gitleaks (secrets, full history), OSV-Scanner (deps), Trivy config (IaC), Trivy license | PR + push main + weekly | Security tab |
| `.github/workflows/scorecard.yml` | OpenSSF Scorecard (repo posture) | push main + weekly | Security tab + public badge |
## Tiered policy (target, enforced in a later change)
- **Always block:** gitleaks secret hit; OSV/Trivy fixable HIGH/CRITICAL; Trivy
disallowed license.
- **Advisory (SARIF → Security tab):** CodeQL alerts; OSV/Trivy unfixable or
medium/low; Scorecard.
Code-level gates (OSV, Trivy, gitleaks) block via workflow exit codes. CodeQL
merge-blocking, Dependabot alerts, and native secret-scanning are GitHub *settings*
— see the checklist below.
## Supply-chain hardening
- Every action is pinned to a full commit SHA (`# vX.Y.Z` comment) — resists
tag-move attacks and satisfies Scorecard's Pinned-Dependencies check.
- `step-security/harden-runner` (egress-policy `audit`) on Linux jobs.
- Least-privilege `permissions:` per workflow/job.
- SBOM + SLSA provenance are attached to the published container image (added with
the image-scan work).
## Maintainer checklist (one-time GitHub settings — NOT code)
- [ ] Repository must be **public** (required for the Scorecard badge/publish and
the CodeQL free tier).
- [ ] Settings → Code security: enable **Dependabot alerts** and **Dependabot
security updates**.
- [ ] Settings → Code security: enable **Secret scanning** + **Push protection**.
- [ ] Settings → Code security: enable **CodeQL / code-scanning merge protection**
so high-severity alerts block PRs (the code-level gates do the rest).
- [ ] Branch protection on `main`: require the CI + security status checks to pass.
- [ ] **Manual image bumps:** Dependabot does not track `docker-compose.yml`
`image:` pins — update `caddy` and `livekit/livekit-server` by hand when new
releases ship. (Renovate, which parses compose, is an optional future
alternative.)
```
- [ ] **Step 2: Add the CLAUDE.md subsystem-table row**
In `CLAUDE.md`, inside the "Subsystem Documentation" table (the block of `| File | Contents | Read when... |` rows), add:
```markdown
| [security-scanning.md](docs/systems/security-scanning.md) | CI security pipeline: Dependabot, CodeQL SAST, gitleaks, OSV-Scanner, Trivy (config/image/license), OpenSSF Scorecard, SHA-pinning, harden-runner, tiered enforcement policy, maintainer settings checklist | Any CI security work, adding/changing scanners, enabling enforcement, supply-chain hardening |
```
- [ ] **Step 3: Verify the doc links resolve**
Run:
```bash
test -f docs/systems/security-scanning.md && grep -q 'security-scanning.md' CLAUDE.md && echo "doc + CLAUDE.md row present"
```
Expected: `doc + CLAUDE.md row present`.
- [ ] **Step 4: Commit**
```bash
git add docs/systems/security-scanning.md CLAUDE.md
git commit -m "docs(security): document the scanning pipeline + maintainer checklist"
```
---
### Task 7: Open the PR and verify the pipeline runs
**Files:** none (verification only).
**Interfaces:**
- Consumes: everything from Tasks 1-6, pushed to GitHub.
- Produces: a PR with all scanners running green/advisory — the acceptance gate for Plan A.
- [ ] **Step 1: Push the branch**
Run:
```bash
git push -u origin security-scanning-hardening
```
Expected: branch pushed; GitHub prints a PR-create URL.
- [ ] **Step 2: Open a PR**
Run:
```bash
gh pr create --fill --base main --head security-scanning-hardening \
--title "Security scanning pipeline (report-only)" \
--body "Adds Dependabot, CodeQL, gitleaks, OSV-Scanner, Trivy (config/license), OpenSSF Scorecard, SHA-pinned actions, and harden-runner. All scanners are report-only; enforcement is flipped on in a later change. See docs/systems/security-scanning.md."
```
Expected: prints the PR URL.
- [ ] **Step 3: Watch the checks**
Run:
```bash
gh pr checks --watch
```
Expected: `CI / Build & test` passes; `CodeQL`, `Security` (gitleaks/osv/trivy jobs), and `OpenSSF Scorecard` all complete. Because every scanner is `continue-on-error`/advisory, **no scanner may report a failing (red) required check** — a scanner surfacing findings is fine, but the job itself should not fail the PR. If a job fails for a non-finding reason (bad action input, missing permission), fix the workflow and push.
- [ ] **Step 4: Confirm SARIF + Dependabot registration**
Verify in the GitHub UI (or note as maintainer follow-up if Actions/security features aren't enabled yet):
- Security → Code scanning: alerts appear under categories `codeql`, `osv-scanner`, `trivy-config`, `trivy-license`, and Scorecard.
- Insights → Dependency graph → Dependabot: the three ecosystems (npm, github-actions, docker) are listed as configured.
Run (CLI cross-check of code-scanning analyses, if the repo is public with Actions enabled):
```bash
gh api repos/:owner/:repo/code-scanning/analyses --jq '[.[].category] | unique' 2>/dev/null || echo "code-scanning API not available yet (enable in Settings)"
```
Expected: a list including the scanner categories, or the fallback message (then it's a maintainer-settings follow-up, not a plan defect).
- [ ] **Step 5: Record verification outcome**
No commit. Note in the PR description (or a comment) which checks passed and any settings follow-ups (from the Task 6 maintainer checklist) still pending. Plan A is complete when the PR is green with all four scanners running advisory.
---
## Self-Review Notes
- **Spec coverage (WS1 + supply-chain):** Dependabot (Task 1) ✓; CodeQL (Task 3) ✓; gitleaks + OSV + Trivy config + Trivy license (Task 2) ✓; Scorecard (Task 4) ✓; SHA-pinning + harden-runner (audit) + least-priv permissions across all workflows (Task 5) ✓; docs + maintainer checklist + CLAUDE.md row (Task 6) ✓; report-only sequencing honored throughout (Global Constraints + per-step comments) ✓.
- **Deferred by design (other plans, not gaps):** container image scan + SBOM/provenance + docker-publish restructure → Plan B; helmet/CSP/CORS + DAST → Plan C; Electron hardening → Plan D; remediation of findings + enforcement flip + README badges → Plan E. Task 5's SHA-pin sweep does include `docker-publish.yml` (harmless; Plan B re-pins as it restructures).
- **Enforcement stays OFF here** — every scanner is `continue-on-error`/advisory; no `fail-on`/severity gate is set in this plan. The maintainer-settings toggles (CodeQL merge protection, push protection, branch protection) are documented, not enabled in code.
@@ -0,0 +1,360 @@
# Plan B — Container Hardening & Real Image Scanning Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Harden the published container image — run it as a non-root user, slim its runtime attack surface, and scan the actual amd64 image for OS/library CVEs before publishing — without breaking existing self-hosters or the multi-arch (amd64+arm64) GHCR publish.
**Architecture:** Two edits to the runtime layer (`Dockerfile` + a new `docker-entrypoint.sh`) plus a restructure of `docker-publish.yml` so a single-arch amd64 image is built and Trivy-scanned before the multi-arch push. The image scan is **report-only** here (matching Plan A's sequencing); Plan E flips it to blocking. SBOM + SLSA provenance are attached at push time.
**Tech Stack:** Docker multi-stage build (`node:20-slim`), Docker Buildx + QEMU, GitHub Actions, Aqua Trivy (image + SARIF), `gosu` for privilege drop, better-sqlite3 (prebuilt binary), tsx (runtime TS loader).
## Global Constraints
- **This plan builds on Plan A's branch** (`security/scanning-pipeline`); the workflows here are already SHA-pinned. Work branch: `security/container-hardening`.
- **Do not break existing self-hosters.** The `./data:/app/data` bind mount (`docker-compose.yml:31`) is host-owned; the container must still read/write it after `docker pull` + restart. The non-root switch is handled by an entrypoint that chowns `/app/data` **as root** then drops to the `node` user via `gosu` — so there is **no static `USER` line** (a static `USER` would run the entrypoint unprivileged and make the chown impossible).
- **Keep `ffmpeg`** (real runtime dependency) and **keep `tsx`** (the `CMD` runs TS via `tsx/esm`). Only `python3 make g++` may leave the runtime stage.
- **better-sqlite3 must still load.** It is expected to install via its prebuilt binary on `node:20-slim` (glibc) for both amd64 and arm64. If a task's build shows it compiling (needs the toolchain), use the documented fallback (keep the toolchain, OR copy the built module from the builder stage) and report it — do not ship a broken image.
- **Image scan is report-only in this plan** (`exit-code: '0'` + `continue-on-error: true`, comment `# report-only; enforcement flipped on in Plan E`). Do NOT make it fail the publish here.
- **trivy-action pinned to `ed142fd0673e97e23eac54620cfb913e5ce36c25` (# v0.36.0)** — v0.28.0's nested `setup-trivy@v0.2.1` ref is broken (see memory `ci-security-action-gotchas`). SHA-pin any other new action with a `# vX.Y.Z` comment.
- **Both build paths must keep working:** the GHCR prebuilt-image pull (`docker-compose.yml` `image:`) AND the from-source `docker compose up --build` fallback.
- **Commit identity:** plain `git commit` (local config = `Jannis Braun <151788261+TheZwiss@users.noreply.github.com>`). NEVER `-c user.email`; never the alxtrading94 email.
- **Node 20 / pnpm 10.34.3** are the pinned toolchain.
- **Docker daemon must be running** for Tasks 1 and 2 verification (`docker build` / `buildx --load`). If it is not up, STOP and report — do not mark a Dockerfile task done without a real build+boot.
---
### Task 1: Harden the runtime image (non-root via gosu, drop build toolchain)
**Files:**
- Create: `docker-entrypoint.sh`
- Modify: `Dockerfile` (runtime stage, lines 37-97)
**Interfaces:**
- Consumes: the existing builder stage output (`/app/packages/web/dist`).
- Produces: an image that runs `node --import tsx/esm src/index.ts` as the non-root `node` user (uid 1000) with a writable `/app/data`. No code symbols.
- [ ] **Step 1: Write the entrypoint script**
Create `docker-entrypoint.sh` at the repo root:
```sh
#!/bin/sh
# Runs as root: make the (bind-mounted, host-owned) data dir writable by the
# non-root `node` user, then drop privileges via gosu and exec the CMD. This
# lets the container run as uid 1000 while still owning ./data on hosts where
# the bind mount was created by a different uid.
#
# - Idempotent AND cheap: only chown entries not already node-owned, so after
# the first boot this is near-instant. A plain `chown -R` over a large
# uploads/ tree on slow Pi/SD storage would delay startup on EVERY restart.
# - Non-fatal: on a bind mount that rejects chown (some CIFS/NFS backings),
# warn and continue rather than crash-looping under `restart: unless-stopped`
# (the old root container booted fine on such mounts).
set -e
mkdir -p /app/data/uploads
chown node:node /app/data /app/data/uploads 2>/dev/null || true
find /app/data ! -user node -exec chown node:node {} + 2>/dev/null || \
echo "docker-entrypoint: warning: could not chown /app/data; continuing (ensure it is writable by uid 1000)"
exec gosu node "$@"
```
- [ ] **Step 2: Modify the runtime stage's apt-get line**
In `Dockerfile`, replace the runtime-stage package install (currently line 42-44):
```dockerfile
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 make g++ ffmpeg && \
rm -rf /var/lib/apt/lists/*
```
with (drop the C toolchain; keep ffmpeg; add gosu for the privilege drop):
```dockerfile
RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg gosu && \
rm -rf /var/lib/apt/lists/*
```
- [ ] **Step 3: Wire the entrypoint + keep the CMD**
In `Dockerfile`, immediately AFTER the `RUN mkdir -p /app/data/uploads` line (currently line 73) add the entrypoint copy:
```dockerfile
# Non-root hardening: copy the privilege-dropping entrypoint. It chowns the
# data volume as root, then execs the CMD as the unprivileged `node` user.
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
```
Then, at the END of the file, replace the final two lines (currently line 96-97):
```dockerfile
WORKDIR /app/packages/server
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
```
with (add the ENTRYPOINT between WORKDIR and CMD; do NOT add a `USER` line):
```dockerfile
WORKDIR /app/packages/server
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
```
- [ ] **Step 4: Build the native (arm64) image + confirm neither arch needs the toolchain**
The runtime stage runs its own `pnpm install --prod` (Dockerfile:61), so better-sqlite3/sharp install **per arch**. This host is Apple Silicon (arm64), which is ALSO the Raspberry Pi's arch — the main self-host target — so build the real image **natively for arm64** (fast; a `--platform linux/amd64` build here would emulate the whole Vite build via Rosetta and likely time out). This build both produces the image for the Step 5 boot test AND exercises the arm64 runtime install:
```bash
docker buildx build --platform linux/arm64 --load -t backspace:hardening-test --build-arg BACKSPACE_COMMIT=test .
```
Watch the better-sqlite3 output: it must use a prebuilt binary (`prebuild-install`), NOT `node-gyp`/compilation. This build may take a few minutes (pnpm install + Vite) — give it an ample timeout or run it in the background so it isn't killed mid-build.
Then verify the OTHER arch (amd64) toolchain drop with a lightweight, emulated native-module check (no Vite build, so it's quick even under emulation) on `node:20-slim` amd64, which — like the hardened runtime stage — has no `python3/make/g++`:
```bash
BSQL=$(grep -A1 'better-sqlite3@' pnpm-lock.yaml | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); echo "testing better-sqlite3@$BSQL"
docker run --rm --platform linux/amd64 node:20-slim sh -c "
cd /tmp && npm init -y >/dev/null 2>&1 &&
npm install --no-audit --no-fund better-sqlite3@$BSQL sharp@0.33.5 2>&1 | grep -iE 'prebuild-install|prebuilt|node-gyp|gyp ERR|rebuild' | head -20;
node -e \"require('better-sqlite3')(':memory:').close(); require('sharp'); console.log('amd64 native modules OK (no toolchain)')\"
"
```
Expected: the native arm64 image builds (better-sqlite3 prebuilt), AND the amd64 test prints `amd64 native modules OK (no toolchain)`. If EITHER arch tries to compile, or the amd64 test errors, STOP — apply the fallback (re-add the toolchain to the runtime apt-get line, OR build the module in the builder stage and `COPY --from=builder`) and report which arch failed, which fallback you used, and why. Do not proceed on a one-arch pass.
- [ ] **Step 5: Boot the container and verify non-root + data volume + DB**
Run:
```bash
mkdir -p /tmp/bkspace-data
docker run -d --name bkspace-htest -e JWT_SECRET=testsecret_at_least_32_chars_long_xx -p 3999:3000 -v /tmp/bkspace-data:/app/data backspace:hardening-test
sleep 12
echo "--- health ---"; curl -fsS http://localhost:3999/api/health && echo " OK"
# IMPORTANT: check PID 1 (the actual server), NOT `docker exec ... id`. `docker exec`
# spawns a NEW process as the image's configured USER (root, since there is no USER
# line), so `exec ... id` prints uid=0 even when the gosu drop worked. /proc/1/status
# is the real server process's identity.
echo "--- server (PID 1) runs as node/uid 1000, not root ---"; docker exec bkspace-htest sh -c "grep '^Uid:' /proc/1/status"
echo "--- data dir written + owned by node ---"; docker exec bkspace-htest sh -c 'ls -ld /app/data /app/data/uploads'
echo "--- better-sqlite3 loaded (DB file exists) ---"; docker exec bkspace-htest sh -c 'ls -la /app/data/*.db 2>/dev/null || echo NO_DB'
echo "--- sharp (native, toolchain-sensitive) loads ---"; docker exec bkspace-htest node -e "require('sharp'); console.log('sharp OK')"
echo "--- boot logs clean (no EACCES / permission errors from running non-root) ---"; docker logs bkspace-htest 2>&1 | grep -iE 'EACCES|permission denied|EPERM' && echo "PERMISSION ERRORS FOUND" || echo "logs clean"
```
Expected: `/api/health` returns ok; `Uid:` line shows `1000 1000 1000 1000` (server runs non-root); `/app/data` + `/app/data/uploads` exist and are `node`-owned; a `.db` file was created (better-sqlite3 loaded and wrote); `sharp OK` prints (the OTHER native module survived the toolchain drop); logs show no permission errors. If any fails, fix before proceeding.
- [ ] **Step 6: Tear down the test container**
Run:
```bash
docker rm -f bkspace-htest; rm -rf /tmp/bkspace-data
docker rmi backspace:hardening-test 2>/dev/null || true
```
- [ ] **Step 7: Commit**
```bash
git add docker-entrypoint.sh Dockerfile
git commit -m "fix(docker): run container as non-root (gosu) and drop build toolchain from runtime"
```
---
### Task 2: Scan the image before publishing (restructure docker-publish.yml)
**Files:**
- Modify: `.github/workflows/docker-publish.yml`
**Interfaces:**
- Consumes: the hardened `Dockerfile` from Task 1.
- Produces: a publish workflow that builds amd64 → Trivy-scans it (report-only) → pushes multi-arch with SBOM + provenance. No code symbols.
- [ ] **Step 1: Restructure the build/scan/push steps**
In `.github/workflows/docker-publish.yml`, add `security-events: write` to the top-level `permissions` block (it currently has `contents: read` + `packages: write`):
```yaml
permissions:
contents: read
packages: write
security-events: write
```
Then replace the single `Build and push (linux/amd64, linux/arm64)` step (currently lines 79-92) with the build → scan → push sequence:
```yaml
# Build a single-arch amd64 image and LOAD it into the runner's docker
# daemon so Trivy can scan the exact artifact before anything is published.
# A multi-arch manifest cannot be --load'ed, so scanning must happen on a
# single-arch build first; the multi-arch push below reuses these layers
# from the buildx cache, so this is cheap.
- name: Build amd64 image for scanning
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
platforms: linux/amd64
load: true
push: false
tags: backspace:scan
build-args: |
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Trivy image scan (report-only)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
continue-on-error: true # report-only; enforcement flipped on in Plan E
with:
scan-type: image
image-ref: backspace:scan
ignore-unfixed: true
format: sarif
output: trivy-image.sarif
severity: HIGH,CRITICAL
- name: Upload Trivy image SARIF
if: always()
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
with:
sarif_file: trivy-image.sarif
category: trivy-image
# Publish the multi-arch image. Reuses the amd64 layers built above via the
# gha cache. Attaches an SBOM and SLSA provenance attestation to the image.
- name: Build and push (linux/amd64, linux/arm64)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
build-args: |
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
sbom: true
provenance: true
cache-from: type=gha
cache-to: type=gha,mode=max
```
- [ ] **Step 2: Validate the workflow**
Run:
```bash
actionlint .github/workflows/docker-publish.yml
```
Expected: exit 0, no output.
- [ ] **Step 3: Confirm all actions still SHA-pinned**
Run:
```bash
grep -rnE 'uses: +[^ ]+@' .github/workflows/docker-publish.yml | grep -vE '@[0-9a-f]{40}' && echo "UNPINNED FOUND" || echo "All actions pinned to SHA"
```
Expected: `All actions pinned to SHA`.
- [ ] **Step 4: Locally reproduce the build→load→scan path**
This proves the new build→load→scan logic works without publishing anything (requires Docker daemon + local Trivy: `brew install trivy` if absent). Build native (arm64) here to avoid emulation — the scan mechanism is arch-independent; CI scans the amd64 image natively on GitHub's runners:
```bash
docker buildx build --platform linux/arm64 --load -t backspace:scan --build-arg BACKSPACE_COMMIT=test .
trivy image --severity HIGH,CRITICAL --ignore-unfixed backspace:scan | tail -25
docker rmi backspace:scan
```
Expected: the image builds + loads, and Trivy scans it and prints a summary (findings are fine — the scan is report-only; we just need it to RUN). Note accurately in the report: only the **amd64** image is Trivy-scanned; the published **arm64** image ships unscanned (acceptable for this plan). The multi-arch push + SBOM/provenance path cannot be exercised without publishing — it is verified by review + a maintainer `workflow_dispatch` run; the amd64 layers are gha-cache reused on the push, but the **arm64 layers build cold** there (so the push is not "free").
- [ ] **Step 5: Commit**
```bash
git add .github/workflows/docker-publish.yml
git commit -m "ci(docker): scan the amd64 image before publish; attach SBOM + provenance"
```
---
### Task 3: Document the container hardening
**Files:**
- Modify: `docs/systems/deployment.md`
- Modify: `docs/systems/security-scanning.md`
**Interfaces:**
- Consumes: the changes from Tasks 1-2.
- Produces: an upgrade/migration note + an updated pipeline reference. No code symbols.
- [ ] **Step 1: Add a container-hardening + migration note to deployment.md AND correct now-false ownership statements**
Read `docs/systems/deployment.md` first to match its structure. Then:
(a) Add a subsection (place it near the Docker/image content) with this content:
```markdown
### Container hardening (non-root)
The runtime image runs as the unprivileged `node` user (uid 1000), not root. On
container start, `docker-entrypoint.sh` runs as root only long enough to `chown`
the `./data` bind mount to `node` (only entries not already node-owned, so it is
near-instant after the first boot), then drops privileges via `gosu` and execs the
server. The build toolchain (`python3`/`make`/`g++`) is not installed in the
runtime stage — `better-sqlite3` and `sharp` load from prebuilt binaries — which
shrinks the runtime attack surface. `ffmpeg` remains (a real runtime dependency).
The published image carries an SBOM and SLSA provenance attestation, and the
amd64 image is scanned by Trivy before publish (report-only). Note: only the
amd64 image is scanned; the arm64 image is published unscanned.
**Minimum Docker version:** the attestation-bearing multi-arch image requires a
reasonably modern Docker to `pull` cleanly (Docker Engine 24+ recommended).
Very old daemons (≤ 20.10) may mishandle the `unknown/unknown` attestation
manifests. New installs via `install.sh` (get.docker.com) are fine.
**Upgrade note for existing self-hosters:** on the first start of the hardened
image, the contents of your host `./data` directory are chowned to uid 1000. This
is expected and idempotent. If you previously accessed `./data` on the host as a
different user, adjust host-side access accordingly. `./restore.sh` continues to
work — it swaps files inside a throwaway root container, and root can rewrite the
now uid-1000-owned files.
```
(b) Correct the two statements that this change makes false (the data dir is no
longer root-owned):
- The seed-admin line (around `deployment.md:170`): change
`writes it to `data/seed-admin-rotated.txt` (mode `0600`, root-owned via the bind-mount)`
→ `... (mode `0600`, owned by the container's runtime user uid 1000 via the bind-mount)`.
- The Restore intro (around `deployment.md:252`): change
`Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (root)** via the bind-mount`
→ `... is **container-owned (uid 1000)** via the bind-mount`. (The throwaway
root `alpine` container still performs the swap — root can rewrite uid-1000
files — so the mechanism description after it stays correct.)
**Maintainer release-gate (record it, do not action it here):** before the first
`v*` tag that ships this image, do a real `docker compose pull && docker compose
up -d` on both an amd64 host and the arm64 Pi to confirm the attestation-bearing
image pulls on the actual deployment Docker versions.
- [ ] **Step 2: Reflect the image scan in security-scanning.md**
In `docs/systems/security-scanning.md`, update the supply-chain line about SBOM/provenance (currently "**will be** attached ... not yet live") to reflect that image scanning + SBOM + provenance now exist in `docker-publish.yml` (report-only image scan; SBOM + provenance attached at push). Add `docker-publish.yml` to the workflow table with trigger "tag push / manual" and result "image scan (report-only) + SBOM + provenance".
- [ ] **Step 3: Verify the docs reference reality**
Run:
```bash
grep -q 'non-root' docs/systems/deployment.md && grep -q 'provenance' docs/systems/security-scanning.md && echo "docs updated"
```
Expected: `docs updated`.
- [ ] **Step 4: Commit**
```bash
git add docs/systems/deployment.md docs/systems/security-scanning.md
git commit -m "docs(docker): document non-root runtime, data-volume migration, and image scan"
```
---
## Self-Review Notes
- **Spec coverage (WS2):** non-root USER via gosu (Task 1) ✓; slim runtime / drop toolchain with prebuilt-binary verification + fallback (Task 1) ✓; keep ffmpeg + tsx (Task 1 / constraints) ✓; bind-mount chown migration (Task 1 entrypoint + Task 3 doc) ✓; restructure to single-arch load → scan → multi-arch push (Task 2) ✓; SBOM + provenance (Task 2) ✓; image scan report-only, flips in Plan E (Task 2 + constraints) ✓.
- **Deferred by design:** flipping the image scan to blocking → Plan E. Desktop, web/CSP/CORS → Plans C/D.
- **Risk-managed:** the toolchain removal is verified by a real build that must show better-sqlite3 using a prebuilt binary; a fallback is defined if it compiles. The non-root switch is verified by asserting `uid=1000` at runtime and a successful `./data` write + DB creation. Multi-arch push + SBOM/provenance is review-plus-workflow_dispatch verified (cannot be exercised without publishing).
- **Enforcement stays OFF** — image scan is `continue-on-error` + `severity`-limited to HIGH/CRITICAL for the report; no build-failing gate added here.
- **Adversarial pre-execution review folded in:** the non-root verification now checks `/proc/1/status` (not `docker exec … id`, which spawns a new root process and would false-fail); BOTH arches are built to verify the toolchain drop (arm64 is the release-hard-fail path); the entrypoint chown is idempotent-cheap + non-fatal (Pi/CIFS safety); a `sharp` native-load smoke check + EACCES log scan were added; now-false `deployment.md` ownership statements are corrected; and an SBOM/provenance min-Docker floor + a maintainer pull-test release-gate are documented (attestation-bearing images can trip very old Docker on the `pull` path).
@@ -0,0 +1,164 @@
# Design: Split `routes/federation.ts` into cohesive modules
**Date:** 2026-07-10
**Status:** Approved (strategy) — pending implementation plan
**Author:** Lead Developer
## Problem
`packages/server/src/routes/federation.ts` is **7,626 lines** — the largest file in the
codebase by nearly 3×. It bundles at least five unrelated responsibilities:
1. HTTP route registration (`federationRoutes()` alone is ~2,800 lines / 30 endpoints)
2. Federated identity resolution (`resolveLocalUser`, `resolveOrCreateReplicatedUser`, …)
3. ~30 inbound relay event processors (`process*Event`)
4. DM channel / federated-id reconciliation helpers
5. Rate-limiting, nonce, and peer-approval-queue internals
A file this size is unreviewable (the originating complaint) and unsafe to change: any
diff touching it is hard to reason about, and the file is too large to hold in working
memory (human or model) at once.
This is a **structural** problem, not a behavioral one. The fix is to split the file
along its existing responsibility seams — **not** to change any behavior.
## Goals
- Every module has **one clear purpose**, is independently readable (~150–850 lines),
and communicates through explicit imports/exports.
- **Zero behavior change.** The ~21,700 lines of existing federation tests are the proof
and must pass unchanged.
- **Zero import-path churn** anywhere else in the codebase or tests.
## Non-Goals (explicitly deferred to a separate follow-up — "Phase C")
- Refactoring or de-duplicating logic *inside* the moved functions.
- Removing intra-function dead branches.
- Renaming symbols or changing signatures.
These are real but must not be mixed into the move: an edit-while-moving diff destroys
`git`'s move detection and re-creates the unreviewability problem. Cleanup, if warranted,
lands afterward as small edits scoped to the now-isolated modules.
## Key findings from analysis
- **No dead top-level code.** All 78 top-level symbols are reachable (self-referenced,
imported by another source module, or exercised by tests). A "prune dead functions"
phase has **no targets** — this is a pure move. (Verified by a per-symbol reference
count across all `packages/**/*.ts`.)
- **Small public surface.** Only **5 symbols** are imported by other *source* files:
`validateOrigin`, `extractDomain`, `backfillReplicatedProfileAssets`,
`sweepDeadIncarnationArtifacts`, `reconcileDriftedDmFederatedIds`, plus `federationRoutes`
(from `index.ts`). Tests import the 22 currently-`export`ed symbols. Everything else is
file-internal.
- **`federationRoutes()` has no function-local shared state.** Its body is a flat list of
`app.<verb>()` registrations; each handler closes only over module-level imports and the
5 module-level rate-limit maps + nonce store. Splitting it into independent registrar
functions requires no hoisting.
- **Build mechanics:** `moduleResolution: "bundler"`, `isolatedModules: true`,
`noUnusedLocals: false`. Importers use explicit `./routes/federation.js` specifiers.
## Strategy: barrel + submodule directory
`routes/federation.ts` **stays as a file** and becomes a thin **barrel**:
- It re-exports the 22 public symbols from their new homes, so every existing
`from '.../routes/federation.js'` import (source **and** test) resolves unchanged.
The one interface (`DmReconcileResult`) is re-exported via `export type { … }`
(required by `isolatedModules`).
- It defines `federationRoutes(app)` as a thin function that calls the six route
registrars in order.
Because importers use the explicit `.js` specifier, the barrel **must** remain a file at
`routes/federation.ts`; the extracted modules live beside it in a new `routes/federation/`
directory (a file and a same-named directory coexist fine on disk and under bundler
resolution).
## Target module layout
```
packages/server/src/routes/
federation.ts ← BARREL: re-exports public API + federationRoutes()
federation/
rateLimits.ts ← rate-limit consts+maps+fns, nonce store, _resetLookupRateBuckets
origin.ts ← validateOrigin, resolveLocalOrigin, sanitizePeer, SanitizedPeer
identity.ts ← extractDomain, getOurIdentityDomain, verifyAttribution,
resolveLocalUser, findFederatedUser,
resolveOrCreateReplicatedUser, backfillHomeUserId
dmChannels.ts ← buildDmChannelPayload, findOrCreateDmChannel,
buildDmMessagePayload, isUrlFromPeer, resolveLocalDmMessage
profile.ts ← hydrateReplicatedUserProfile, downloadProfileAsset,
processProfileUpdateEvent, backfillReplicatedProfileAssets
reconciliation.ts ← DmReconcileResult, reconcileDmChannelFederatedId,
reconcileDriftedDmFederatedIds, sweepDeadIncarnationArtifacts
events/
dmMessages.ts ← processCreate/Update/Delete/ReactionAdd/ReactionRemove Event
membership.ts ← processMemberAdd/MemberRemove/OwnershipTransfer/GroupMetadataUpdate Event
friends.ts ← processFriendRequestCreate/Update/Cancel, FriendAdd/Remove Event
calls.ts ← processDmCallStart/Accept/Reject/End, TypingStart/Stop,
fanOutCallEvent, emitHostFanoutUndeliverable
dmState.ts ← processDmClose/DmReopen/ReadStateUpdate/PresenceUpdate/FileRejected Event
dispatch.ts ← processRelayEvents (imports every processor above)
handlers/
peerHandshake.ts ← POST peer/initiate, peer/accept, peer/ensure, peer/rotate, peer/denied
peerAdmin.ts ← GET peers, reset-events(+ack), peers/:id GET/DELETE/permanent,
peers/:id reset/recheck/rotate
approvals.ts ← approval-requests(+approve/deny), peering-subscriptions,
peering-notifications + queueApprovalRequest,
handleInbound/OutboundApprove, handleInbound/OutboundDeny
relay.ts ← POST identity, relay, epoch, sync
lookup.ts ← POST users/lookup, users/by-home-id
files.ts ← POST verify-attach-proof
```
~19 modules, ~150–850 lines each (avg ~400). Route-registration order across registrars
is preserved by calling them in path order; Fastify does not depend on cross-path
registration order, so intra-group reordering (grouping interleaved endpoints) is safe.
### Dependency layering (acyclic)
```
L0 leaves: rateLimits · origin · identity · dmChannels
L1: profile · reconciliation · events/* (use L0)
L2: events/dispatch (uses all events/*)
L3: handlers/* (use L0–L2)
L4 barrel: federation.ts (re-exports + calls handlers/*)
```
No leaf imports upward, so no import cycles.
## Correctness / verification strategy
- **Test net:** the full server suite (`pnpm --filter @backspace/server test`, ~30
federation test files / ~21.7k lines) runs after **each** module group is extracted.
Green throughout = behavior preserved.
- **Typecheck + build** (`pnpm -w typecheck && pnpm --filter @backspace/server build`)
after each group catches import/type regressions immediately.
- **Move discipline:** functions are moved **verbatim**. The only permitted edits are
(a) adding `import`/`export` statements, and (b) the barrel re-exports. No logic edits.
- **`git diff -M`** on the final result should render as moves + a small barrel — the
reviewability property we are buying.
## Rollout (phased so each commit is independently verifiable)
- **Phase A — Prune.** *Empty by analysis* (no dead top-level code). Skipped; documented
here so the absence is deliberate, not overlooked.
- **Phase B — Extract (this design).** One commit per module group, tests green at each:
1. Leaf helpers: `rateLimits`, `origin`, `identity`, `dmChannels`
2. `profile`, `reconciliation`
3. `events/*` + `events/dispatch`
4. `handlers/*` + convert `federation.ts` to the barrel
5. Update `docs/systems/federation.md` source-file map
- **Phase C — Cleanup (separate, later, optional).** Intra-module logic simplification,
now reviewable because each concern is isolated in a small file.
## Risks & mitigations
| Risk | Mitigation |
|------|-----------|
| Import cycle between extracted modules | Enforced L0–L4 layering; typecheck catches any cycle immediately |
| A test imports a symbol the barrel forgot to re-export | Barrel re-exports the exact set of 22 currently-`export`ed symbols; verified against the export grep |
| `isolatedModules` breaks type re-export | `DmReconcileResult` re-exported via `export type { … }` |
| Route path resolution changes | Barrel stays a file at `routes/federation.ts`; no importer specifier changes |
| Hidden shared local state in `federationRoutes` | Verified none exists (flat `app.<verb>()` body) |
```
@@ -0,0 +1,428 @@
# Security Scanning & Hardening Initiative — Design
**Date:** 2026-07-10
**Status:** Approved (design); pending implementation plan
**Author:** Lead Developer (Backspace)
---
## 1. Motivation
A prospective self-hoster declined to run Backspace with the objection:
> "Security testing: You've made a web app. I am not installing a new webapp that
> is expected to touch the internet without some level of security scanning."
The objection is valid. Investigation of the current state shows Backspace has
solid security **engineering** but no security **assurance infrastructure**:
**Already present (good):**
- `SECURITY.md` with a private vulnerability-disclosure policy.
- Real defensive code: SSRF protection (`packages/server/src/utils/ssrf.ts` — DNS
resolution, private-IP blocking, per-redirect-hop re-validation), HMAC-signed
federation with replay-nonce prevention, sliding-window rate limiters, JWT +
bcrypt, input validation.
- Good secrets hygiene: `.deploy.local` untracked; thorough `.gitignore`
(`.env*`, `*.pem`, `*.key`, `data/`, `*.db`).
- CI (`ci.yml`) running typecheck + build + full test suite.
**Absent (the gap):**
- No SAST, no dependency/CVE scanning, no secret scanning, no container image
scanning, no supply-chain hardening, no Dependabot.
- No visible, verifiable evidence a stranger can audit before trusting the app.
- **No browser-facing hardening** the objection actually cares about: no security
response headers (`@fastify/helmet` absent; bare `Caddyfile`), CORS reflects any
origin with credentials, unsigned desktop autoupdate, no Electron fuses/asar
integrity, no license compliance gate for a dual-licensed (AGPL + commercial)
project.
**Root cause (per the No-Band-Aids principle):** the fix is not a one-off scan. It
is a permanent, automated, and *visible* scanning pipeline wired into CI/CD, plus
remediation of the browser/desktop hardening gaps that continuous scanning would
be embarrassing to leave open.
---
## 2. Goals & Non-Goals
### Goals
1. Continuous, automated scanning on every change: SAST, dependency CVEs, secrets,
container image, license compliance, supply-chain posture.
2. Tiered enforcement: high-confidence, fixable issues **block merge**; the rest are
advisory in the GitHub Security tab. Never wall off merges on unfixable upstream
CVEs.
3. Close the browser-facing and desktop-facing hardening gaps (headers, CORS,
Electron integrity).
4. Publish verifiable evidence: badges, an OpenSSF Scorecard, SBOM/provenance, and
documentation a stranger can read without repo access.
5. Land the whole thing without leaving CI spuriously red: scanners report-only →
remediate → flip enforcement.
### Non-Goals (explicitly out of scope for this initiative)
- **npm provenance / package signing** — every workspace is `"private": true`;
nothing is published to npm. N/A.
- **Purchasing desktop code-signing certificates** — a procurement action (Apple
Developer ~$99/yr, a Windows code-signing cert) that cannot be done in code. We
implement the *code-level* Electron hardening and *document* the signing steps
and certs to buy; we do not fake signing.
- **Full fuzzing harness for federation input** — valuable but multi-week; deferred.
A handful of targeted negative/property tests on `validateExternalUrl` and S2S
JSON parsing is in scope; a standing fuzz harness is not.
- **TLS/cipher configuration** — Caddy already auto-provisions HTTPS with modern
defaults; we add security *headers*, not a TLS overhaul.
---
## 3. Tool Selection & Rationale
Where a "GitHub-native" option and a "committed-workflow" option overlap, we prefer
**committed workflow files** — a self-hoster auditing the repo can read a `.yml`
file; they cannot read repo settings. Settings-only toggles are documented as
required manual steps, never claimed as code.
| Scan class | Choice | Rationale |
|---|---|---|
| SAST | **CodeQL** (committed advanced workflow, default `security` suite to start) | Free for public repos, best TS/JS coverage, `none` build mode sidesteps monorepo/native-module build complexity. `security-extended` deferred to avoid a day-one triage tax. |
| Dependency CVEs | **OSV-Scanner** (blocking CI gate) **+ Dependabot** (auto-upgrade PRs) | OSV-Scanner parses `pnpm-lock.yaml` v9 directly and can fail the build; Dependabot alerts are advisory-only. Two distinct roles, no overlap. **Trivy is NOT used for dependency CVEs** (avoids double-noise). |
| Secrets | **gitleaks** (committed, full history + PR diff) **+** documented native push-protection | gitleaks is the verifiable, blocking, history-aware gate; native push-protection is the complementary pre-commit net for the future. |
| Container image | **Trivy** (image scan, blocking) | SARIF output, `ignore-unfixed: true` for tiered policy, scans the exact GHCR image users pull. |
| IaC/config | **Trivy config** (Dockerfile, docker-compose) | Note: Trivy does **not** lint the `Caddyfile`; the reverse-proxy hardening is done by hand (§6.3). |
| License compliance | **Trivy `--scanners license`** with an allowlist | Dual-licensed AGPL + commercial → a copyleft-incompatible transitive dep is a legal defect. Reuses the Trivy we already run. |
| Supply chain | **SHA-pinned actions + harden-runner (audit) + SBOM + SLSA provenance + OpenSSF Scorecard** | Answers "can I trust the build?" and produces a public Scorecard badge. |
| Dynamic (DAST) | **ZAP baseline** against an ephemeral `docker compose up` (advisory) | Catches missing headers + CORS reflection continuously; the one dynamic check for a "webapp exposed to the internet." |
---
## 4. Architecture — Component Layout
Each workflow file has one clear purpose (mirrors the codebase's module-boundary
principle).
```
.github/
dependabot.yml NEW — pnpm(npm) + github-actions + docker(Dockerfile only)
workflows/
codeql.yml NEW — CodeQL SAST (PR + push main + weekly)
security.yml NEW — gitleaks + OSV-Scanner + Trivy config + Trivy license
scorecard.yml NEW — OpenSSF Scorecard (push main + weekly) → Security tab + badge
dast.yml NEW — ZAP baseline vs ephemeral compose stack (advisory)
docker-publish.yml EDIT — restructure for real image scanning + SBOM + provenance
ci.yml EDIT — harden-runner (audit), tighten permissions
release.yml EDIT — harden-runner (audit), tighten permissions
cla.yml EDIT
deploy-pages.yml EDIT
```
**SHA-pinning applies to EVERY workflow** — the four edited above, `docker-publish.yml`,
and all four new ones (`codeql`/`security`/`scorecard`/`dast`). Pin every `uses:` to a
full commit SHA with a trailing `# vX.Y.Z` comment. (OpenSSF Scorecard's
Pinned-Dependencies check and tag-move attack resistance both require this repo-wide.)
```
Dockerfile EDIT — non-root USER, slim runtime, copy pruned node_modules
Caddyfile EDIT — security response headers
packages/server/src/index.ts EDIT — @fastify/helmet + CSP; tighten CORS
packages/server/package.json EDIT — add @fastify/helmet
packages/web/index.html EDIT — CSP meta (defense in depth for static shell)
packages/desktop/src/main.ts EDIT — will-navigate deny handler
packages/desktop/electron-builder.yml EDIT — @electron/fuses / asar integrity
packages/desktop/package.json EDIT — add @electron/fuses
README.md EDIT — badges + "Security & supply chain" section
SECURITY.md EDIT — "Security testing & assurance" section
docs/systems/security-scanning.md NEW — full pipeline spec
docs/systems/desktop-security.md NEW — Electron hardening + signing procurement
CLAUDE.md EDIT — add subsystem-table rows
```
---
## 5. Policy Engine (tiered enforcement)
| Finding | Action |
|---|---|
| gitleaks secret hit | **Block** (always) |
| OSV-Scanner — fixable HIGH/CRITICAL | **Block** |
| Trivy image — fixable HIGH/CRITICAL (`ignore-unfixed: true`) | **Block** |
| Trivy license — disallowed license | **Block** |
| CodeQL — any alert | Advisory (SARIF → Security tab) |
| OSV/Trivy — unfixable, or medium/low | Advisory (SARIF → Security tab) |
| ZAP baseline (DAST) | Advisory (report artifact) |
| Scorecard | Advisory (score badge + Security tab) |
**Enforcement honesty — two mechanisms, kept separate:**
- **Code-enforced (auditable in the `.yml`):** OSV-Scanner, Trivy, and gitleaks
block via workflow exit codes.
- **Settings-enforced (documented one-time toggles, NOT claimed as code):** CodeQL
merge-blocking (code-scanning merge protection), Dependabot alerts, native
secret-scanning + push protection, and branch protection "require status checks."
These live in `docs/systems/security-scanning.md` as a maintainer checklist.
---
## 6. Workstreams (bounded, independently reviewable)
Sequencing rule: **WS1 scanners land report-only → WS5 remediation → flip WS1/WS2
enforcement to blocking.** WS3/WS4 are otherwise independent and can land in
parallel. Two cross-workstream dependencies to respect: **(a)** WS3 (CSP/CORS
validation) and WS6's DAST job share the same **two-instance + LiveKit ephemeral
test rig** — build it once, reuse it; **(b)** WS6's badges + maintainer checklist
document state produced by WS1/WS2/WS5, so its final copy is written *last* (the
workflow files can be scaffolded earlier).
### WS1 — Scanning & supply-chain pipeline (report-only first)
- `.github/dependabot.yml`:
- `package-ecosystem: npm` at `/` (Dependabot handles pnpm workspaces), weekly,
grouped minor/patch.
- **`ignore` `uiohook-napi`** — it is pinned by an exact-version patch
(`patches/uiohook-napi@1.5.5.patch`); an unmatched bump breaks
`pnpm install --frozen-lockfile` in CI and both Docker stages. Also treat
`onlyBuiltDependencies` (`better-sqlite3`, `esbuild`, `electron`, `sharp`)
bumps with care (grouped, expect native-rebuild churn).
- `package-ecosystem: github-actions` at `/`.
- `package-ecosystem: docker` at `/` — tracks the **Dockerfile `FROM`** only.
**No compose entry:** `docker-compose.yml` sits at the same `/` directory (a
second docker entry there would collide on ecosystem+directory), and Dependabot's
docker ecosystem parses Dockerfiles, **not** `image:` refs in compose. The pinned
`caddy:2.11.1-alpine` / `livekit/livekit-server:v1.9.11` compose images are
therefore updated **manually** — added as a line item to the maintainer checklist
in `docs/systems/security-scanning.md`. (Renovate, which does parse compose, is
noted there as an optional future alternative.)
- `codeql.yml`: languages `javascript-typescript`, default `security` queries,
triggers PR + push `main` + weekly cron. SARIF uploaded.
- `security.yml`:
- **gitleaks** — full history + PR diff, SARIF, **block** on hit.
- **OSV-Scanner** — reads `pnpm-lock.yaml`; report-only initially, then block on
fixable HIGH/CRITICAL after WS5.
- **Trivy config** — Dockerfile + docker-compose misconfig, SARIF, advisory.
- **Trivy license** — `--scanners license` against the dependency tree with an
allowlist (permissive + AGPL-compatible); block on disallowed.
- `scorecard.yml`: `ossf/scorecard-action`, push `main` + weekly, publish results +
badge.
- Harden **all** workflows (new and existing, incl. `docker-publish.yml`): pin every
`uses:` to a full commit SHA (retain a `# vX.Y.Z` comment); add
`step-security/harden-runner` in **`egress-policy: audit`** (not block — multi-arch
buildx + QEMU + gha cache make many egress calls); tighten job-level `permissions`
to least privilege.
### WS2 — Container hardening & real image scanning
- **Restructure `docker-publish.yml`** (the current single multi-arch `build-push`
cannot be scanned before publish):
1. Build **single-arch `linux/amd64`** with `load: true`.
2. **Trivy image scan** (`ignore-unfixed: true`, block on fixable HIGH/CRITICAL),
SARIF uploaded.
3. On pass, the multi-arch (`amd64,arm64`) `build-push` with `push: true`,
`sbom: true`, `provenance: true`. (Buildx cache makes the second build cheap.)
- Trivy authenticates to GHCR with the same `GITHUB_TOKEN` used for login (the
package may be private until manually flipped public).
- **Dockerfile hardening:**
- **Prune mechanics (precise):** the builder runs a *full* `pnpm install
--frozen-lockfile` (Dockerfile:25) whose `node_modules` is a symlinked `.pnpm`
virtual store — a plain `COPY --from=builder node_modules` is **not**
self-contained. Use `pnpm --filter @backspace/server deploy --prod
/app/deploy` in the builder to produce a dereferenced/hoisted prod tree, then
`COPY --from=builder /app/deploy` into the runtime stage. This replaces the
runtime stage's own `pnpm install --prod`, letting `python3 make g++` be dropped
from runtime. **Keep `ffmpeg`** (real runtime dep) and **keep `tsx`** as a prod
dependency (the CMD runs TS via `tsx/esm`). Verify `better-sqlite3`'s prebuilt
binary and `tsx` are present in the copied tree for **both** target arches.
- **Non-root + bind-mount chown (reconciled — the two are mutually exclusive if
done naively):** `docker-compose.yml:31` bind-mounts host-owned `./data:/app/data`.
Chowning it requires **root**, so we do **not** hard-set a `USER` line (that would
run the entrypoint as non-root and make the chown impossible). Instead: install
`gosu` (or `su-exec`), add an `ENTRYPOINT` that (a) idempotently `chown`s
`/app/data` to a fixed non-root UID, then (b) `exec gosu <uid> "$@"` to drop
privileges — so the process runs non-root while the volume stays writable. The
`ENTRYPOINT` must `exec "$@"` to preserve the existing `WORKDIR
/app/packages/server` + `CMD ["node","--import","tsx/esm","src/index.ts"]`
(Dockerfile:96-97). Ship a documented upgrade note; must not break existing
self-hosters on `docker pull` + restart.
### WS3 — Web/server hardening
**Reality check (from review):** this app renders *arbitrary user-supplied content*
and is *federated*, so a restrictive `img-src`/`media-src`/`connect-src` is
infeasible. A CSP here realistically constrains `script-src` / `object-src` /
`base-uri` / `frame-ancestors` / `form-action` (the XSS/clickjacking-relevant
directives) and stays permissive on content origins. Concretely:
- **`img-src` / `media-src` must be broad** (`https: data: blob:`): link-embed OG
images (`VideoEmbed.tsx`, `RichEmbed.tsx`) come from *any* linked site, and GIF
previews load directly from Klipy's CDN (`routes/gif.ts` returns `file.url`
unproxied — the CDN host differs from `api.klipy.com`).
- **`connect-src` must include the LiveKit `wss://` origin, which is operator
config** (`routes/livekit.ts` returns `config.livekit.url` = `LIVEKIT_URL`
verbatim) — so the **CSP must be generated at runtime from config**, not a static
string. Federation (`getApiForOrigin` in `exploreStore`/`socialStore`/`spaceStore`)
fetches/opens WS to peers discovered at runtime → `connect-src` must also allow
`https: wss:` (peers aren't enumerable at build time).
- **`frame-src` needs an explicit provider allowlist** — YouTube, Vimeo, Spotify
embed origins — or the embed iframes break (default `frame-src 'self'` blocks them).
Steps:
- Add `@fastify/helmet`. Build the CSP **dynamically** from `config.livekit.url` +
the embed-provider list; ship it **report-only first**, validate against real flows
(chat, **cross-instance federation**, embed render, upload, and a **real voice
join**) with zero violations, then flip to enforcing.
- `packages/web/index.html`: CSP `<meta>` (defense-in-depth) — script/object/base
directives only; do not duplicate the dynamic connect/img rules there.
- `Caddyfile`: `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`,
`Referrer-Policy`, and clickjacking protection via CSP `frame-ancestors` (prefer
over `X-Frame-Options`). **Ownership split (documented to avoid conflicts):** Caddy
owns HSTS + nosniff + Referrer-Policy; the app (helmet) owns the CSP. Don't set CSP
in two places.
- **CORS (`packages/server/src/index.ts:46-48`)** — replace `origin: true` with a
**dynamic `origin` callback backed by the live federation-peer registry**, NOT a
static `DOMAIN`-derived list. Two breakages a static list would cause, both must be
handled:
- **Federated browser uploads:** browsers make cross-origin tus POST/HEAD/PATCH/
DELETE to peer `/api/files/*` (see the existing CORS-block comment at
`index.ts:50-64`); peers are DB-backed and added after boot → the callback must
consult the live registry, not a boot-time snapshot.
- **Desktop instance picker:** `packages/desktop/resources/instance-picker.html`
does a renderer `fetch('<url>/api/instance/info')` from a `file://` document
(Origin `null`). Keep `/api/instance/info` **CORS-open** (or move that probe to a
main-process fetch) so the picker doesn't report instances as unreachable.
- **Federation note:** S2S endpoints authenticate by HMAC and receive no browser
`Origin`; verify they are unaffected by the two-instance federation integration
suite.
- **Rollout (phased, like the scanners):** CSP report-only → observe → enforce; CORS
gets a **"log-and-allow" observation phase** (log rejected origins without blocking)
before switching to reject. **Test-rig dependency:** validating CSP + CORS here
needs the **two-instance + LiveKit** harness (shared with the DAST env, §WS6/G2),
which is heavier than a single-instance boot — call this out when scheduling.
### WS4 — Desktop/Electron hardening
- **Fuses without breaking the existing hook:** `electron-builder.yml:20` already
declares `afterPack: ./scripts/afterPack.js` (it strips host-compiled
`uiohook-napi` artifacts + cross-platform prebuilds), and electron-builder allows
**only one** `afterPack`. So do **not** add a second hook. Prefer electron-builder's
top-level **`electronFuses:`** config key (cleanest, no collision); if a fuse isn't
expressible there, call `@electron/fuses` `flipFuses()` **inside** the existing
`scripts/afterPack.js`. Fuses: disable `RunAsNode` + `EnableNodeCliInspectArguments`,
enable `OnlyLoadAppFromAsar`. **Asar-integrity caveat:** it interacts with the
existing `asarUnpack: **/*.node` (lines 17-18) and the afterPack that mutates
`app.asar.unpacked` — integrity hashes must be computed *after* those mutations, and
because builds are unsigned (`release.yml:93`) macOS integrity **enforcement** is
limited; document this in `desktop-security.md` rather than over-claiming.
- **`will-navigate` deny handler** in `main.ts`: block foreign top-level navigations
while allowing the initial `https://` instance load and the `file://` picker.
Clarification (mechanism): the app is client-routed (history API →
`did-navigate-in-page`), cross-instance switching uses main-process `loadURL`, and
`/join/*` deep-links are handled by `setWindowOpenHandler` (`main.ts:454`) — none of
these are `will-navigate`, so the deny handler is safe and `setWindowOpenHandler`
stays untouched.
- `docs/systems/desktop-security.md`: document the current webPreferences posture
(contextIsolation on, nodeIntegration off, sandbox on — `main.ts:356-360`), the
fuses/asar posture and its unsigned-macOS limits, and — because `release.yml:93`
sets `CSC_IDENTITY_AUTO_DISCOVERY: false` (unsigned) — the exact signing +
notarization steps and certificates to procure. Flag unsigned autoupdate as a known
gap until signing is wired up.
### WS5 — Remediation (after WS1 lands report-only)
- Run OSV-Scanner + Trivy + CodeQL; triage. Fix real HIGH/CRITICAL: direct upgrades,
`pnpm.overrides` for transitive pins where no direct upgrade exists, code fixes for
true-positive SAST findings. Dismiss false positives **with written justification**
(`.trivyignore` / inline).
- **SSRF hardening (fix, then test — not just test):** the string-prefix
`isPrivateIp` (`utils/ssrf.ts:3-16`) is genuinely bypassable — `::ffff:127.0.0.1`
matches no branch and returns `false` (SSRF to loopback via an attacker AAAA
record), and there is no `100.64.0.0/10` (CGNAT) or `::` handling. **Harden
`isPrivateIp`**: normalize IPv4-mapped IPv6, reject CGNAT and `::`/unspecified, and
normalize decimal/octal/hex hostname encodings — *then* add the negative/property
tests for `validateExternalUrl` covering those vectors. The residual DNS-rebind
TOCTOU is already documented (`ssrf.ts:58-61`) and stays out of scope (noted, not
fixed).
- **Then flip WS1/WS2 enforcement to blocking.**
### WS6 — Visible evidence, DAST & docs
- **`dast.yml` (ZAP baseline):** stands up an ephemeral instance and runs ZAP
baseline (advisory). **CI env override required** — the production compose won't
come up unmodified: Caddy uses `{$DOMAIN}` + ACME auto-HTTPS (hangs in CI without
public DNS), `backspace` requires `JWT_SECRET`, livekit is profile-gated. Use a CI
compose override that sets a test `JWT_SECRET`/`DOMAIN` and **points ZAP directly at
the `backspace` container `:3000`, bypassing Caddy** (or Caddy `internal`/local
TLS). This is the same two-instance-capable rig WS3 needs for CSP/CORS validation.
- README: CodeQL, OpenSSF Scorecard, and security-policy badges; a "Security &
supply chain" section describing what runs on every change and where results are
published.
- SECURITY.md: add a "Security testing & assurance" section enumerating the pipeline.
- `docs/systems/security-scanning.md`: full spec of every workflow, the tiered
policy, and the maintainer settings checklist (§5) — including the **repo-must-be-
public precondition** (Scorecard `publish_results` + badge and CodeQL free tier both
require a public canonical repo) and the manual `caddy`/`livekit` compose-image
update reminder (from WS1/F1).
- CLAUDE.md: add subsystem-table rows for `security-scanning.md` and
`desktop-security.md` (required by the Documentation Rule — this is structural CI
and architecture).
- **Finalize WS6 last:** badges + the maintainer checklist document state that only
exists once WS1/WS2/WS5 land, so write the final copy after those are green (the
workflow *files* can be scaffolded earlier).
---
## 7. Testing Strategy
- **`actionlint`** on every new/edited workflow.
- **Real PR-branch run** watching each check go green (or advisory) as intended.
- **Canary proof of blocking:** on a throwaway branch, introduce a fake secret and a
known-vulnerable dependency; confirm gitleaks and OSV-Scanner actually **fail** the
build; revert.
- **WS2:** `docker build` locally for amd64 + container boots + `/api/health`
responds, before and after the Dockerfile changes; confirm the process runs
**non-root** (via gosu step-down) yet still writes the host-owned `./data` bind
mount; confirm `tsx` + `better-sqlite3` prebuilt are present in the pruned tree and
the arm64 image still builds; confirm existing self-hosters survive `pull` + restart.
- **WS3 (needs the two-instance + LiveKit rig):** security headers present
(curl/DevTools); **zero CSP violations** across chat, **cross-instance federation**,
embed render (YouTube/Vimeo/Spotify + generic OG image), GIF, upload, and a **real
voice join**; CORS callback permits the app origin **and dynamically-registered
peers** (federated upload), keeps `/api/instance/info` open to the `file://` picker
(Origin `null`), and rejects an unknown origin; two-instance federation S2S suite
still green.
- **WS4:** desktop app boots with fuses/asar-integrity applied and the existing
`afterPack` native-module cleanup intact; `will-navigate` blocks a foreign top-level
URL while the initial instance load, the `file://` picker, and `/join/*` deep-links
(via `setWindowOpenHandler`) still work.
- **DAST:** ZAP baseline runs against the CI compose override (bypassing Caddy) and
produces a report artifact.
- **Full suite** (`pnpm -r test`) green throughout; existing federation/voice suites
unaffected.
---
## 8. Risks & Mitigations
| Risk | Mitigation |
|---|---|
| Enforcement day-one paints CI permanently red (May-2024 lockfile has fixable highs) | Report-only → remediate (WS5) → flip blocking. |
| Multi-arch image "scan" is theater / arm64 unscanned | WS2 restructure: single-arch load+scan → then multi-arch push. |
| Non-root USER breaks `./data` bind-mount for existing self-hosters | Run entrypoint as root → chown → `exec gosu <uid>` step-down (no static `USER`); documented upgrade note; tested before/after. |
| Dependabot breaks CI via `uiohook-napi` patch / native rebuilds | `ignore` the patched dep; group `onlyBuiltDependencies`. |
| CSP too strict for a federated, arbitrary-content app | CSP built **dynamically** from `config.livekit.url` + peer registry; `img/media/connect` permissive; constrain only script/object/base/frame-ancestors; report-only → enforce. |
| CORS allowlist breaks federated uploads + desktop `file://` picker | Dynamic `origin` callback backed by the **live peer registry**; keep `/api/instance/info` CORS-open; "log-and-allow" phase before rejecting. |
| Electron fuses overwrite the existing `afterPack` (native-module cleanup) | Use top-level `electronFuses:` key or call `flipFuses()` inside the existing `scripts/afterPack.js`; compute asar-integrity hashes after afterPack mutations. |
| pnpm symlinked `.pnpm` store makes a plain `node_modules` copy non-self-contained | Use `pnpm --filter @backspace/server deploy --prod`; verify `tsx` + `better-sqlite3` prebuilt land per-arch; keep `ffmpeg`; boot test. |
| DAST/compose won't come up in CI (ACME/DOMAIN/JWT_SECRET) | CI compose override with test env; point ZAP at `backspace:3000`, bypass Caddy. |
| harden-runner block mode false-positives the Docker build | Start in `audit`; graduate to block only on lightweight jobs. |
| Scorecard badge / CodeQL free tier assume a public repo | Documented as an explicit precondition in the maintainer checklist. |
---
## 9. Definition of Done
- All new workflows present, `actionlint`-clean, and green on a real PR.
- Blocking gates proven by canary (secret + vuln), then reverted.
- Security tab populated (CodeQL, Scorecard, advisory Trivy/OSV) with no open
fixable HIGH/CRITICAL after WS5.
- helmet + CSP + Caddyfile headers live with no CSP violations in normal use; CORS
allowlisted; federation suite green.
- Electron fuses + `will-navigate` live; desktop boots and deep-links work; existing
`afterPack` native-module cleanup intact.
- `isPrivateIp` hardened (IPv4-mapped IPv6 / CGNAT / `::` / alt-encodings) with
passing negative tests.
- Container image scanned before publish; SBOM + provenance attached; Dockerfile
runs non-root (gosu step-down) with a working data volume.
- README badges + Security section; SECURITY.md expanded;
`docs/systems/security-scanning.md` + `docs/systems/desktop-security.md` written;
CLAUDE.md subsystem table updated.
- Maintainer settings checklist documented (CodeQL merge protection, Dependabot
alerts, push protection, branch protection).
+89 -13
View File
@@ -4,9 +4,11 @@ Operator- and contributor-facing reference for hosting Backspace: the Docker bui
Source files:
- `Dockerfile` -- multi-stage build (builder → runtime)
- `docker-compose.yml` -- `backspace` + `caddy` (+ optional `livekit`) services, healthcheck
- `Caddyfile` -- reverse proxy / auto-HTTPS config
- `install.sh` -- interactive first-time setup
- `docker-compose.yml` -- base stack: `backspace` + `caddy` (+ optional `livekit`) services, healthcheck
- `docker-compose.proxy.yml` -- proxy/tunnel overlay: publishes the app on `127.0.0.1:APP_PORT` and drops Caddy
- `.github/workflows/docker-publish.yml` -- multi-arch (amd64+arm64) GHCR image publish
- `Caddyfile` -- reverse proxy / auto-HTTPS config (All-in-One mode only)
- `install.sh` -- interactive first-time setup, mode-aware (allinone / proxy / tunnel)
- `deploy.sh` -- rsync + rebuild to Heidi's two boxes
- `backup.sh` / `restore.sh` -- manual snapshot + restore tooling (host side)
- `packages/server/src/config.ts` -- `config.backup.*` env parsing
@@ -25,14 +27,42 @@ Source files:
## 1. Pipeline Overview
Backspace ships as a single application container fronted by Caddy. Everything is built and run via Docker Compose; there is no separate CI artifact — **the image is built on each target host** from source.
Backspace ships as a single application container. In the default **All-in-One** deployment it is fronted by the bundled Caddy (automatic HTTPS); behind an operator's own reverse proxy or a tunnel, Caddy is dropped and the container is published on a host loopback port instead (see [Deployment modes](#deployment-modes) below). The application image is a **prebuilt multi-architecture image published to GHCR** — `docker compose pull` (install.sh's default path) fetches `ghcr.io/thezwiss/backspace` for `linux/amd64` or `linux/arm64`, so weak/ARM hosts skip the heavy local build; a from-source build is the fallback when the image can't be pulled.
### Prebuilt image (GHCR)
`.github/workflows/docker-publish.yml` builds and pushes the application image to `ghcr.io/thezwiss/backspace` on every `v*` tag (and on manual `workflow_dispatch`). It is deliberately **separate from** the desktop-installer workflow (`release.yml`): the two share the `v*` tag trigger but build entirely different artifacts and must not be entangled.
- **Multi-arch.** `docker/setup-qemu-action` + `buildx` build `linux/amd64,linux/arm64` in one push, so a Raspberry Pi pulls a native image instead of cross-building (the Vite build OOMs small ARM boxes).
- **Tags.** `docker/metadata-action` derives `{version}`, `{major}.{minor}`, `latest` (on `v*` tags), and `sha-<short>`. A `workflow_dispatch` with an extra `tag` input publishes that tag too (e.g. `latest` without cutting a release).
- **AGPL § 13 commit stamping is preserved.** The workflow resolves `git rev-parse --short HEAD` and passes it as `--build-arg BACKSPACE_COMMIT=…`, exactly like `install.sh`/`deploy.sh`, plus OCI labels (`source`, `licenses=AGPL-3.0-only`, `revision`). The pulled image therefore advertises its exact source version via `GET /api/instance/info`.
- **Auth.** The push authenticates with the built-in `GITHUB_TOKEN` (`permissions: packages: write`). The GHCR package must be set **public** once for unauthenticated `docker pull` to work.
- **Compose wiring.** `docker-compose.yml` declares **both** `image: ${BACKSPACE_IMAGE:-ghcr.io/thezwiss/backspace}:${BACKSPACE_IMAGE_TAG:-latest}` **and** `build: .`. `pull`/`up` uses the image; `up --build` (deploy.sh, or install.sh's fallback) builds from source and tags the result under the same ref. Operators pin a version or point at a fork's registry via `BACKSPACE_IMAGE` / `BACKSPACE_IMAGE_TAG`.
### Deployment modes
One installer, three modes, recorded as `DEPLOY_MODE` in `.env`. `install.sh` auto-detects (and, when ambiguous, prompts); a non-interactive run honors an explicit `DEPLOY_MODE`.
| Mode | Ports 80/443 | Topology | TLS | Voice |
|------|--------------|----------|-----|-------|
| `allinone` (default) | must be free | base `docker-compose.yml`: `backspace` + `caddy` (+ `livekit`) | bundled Caddy (Let's Encrypt) | ✅ with UDP media ports open |
| `proxy` | already taken | base **+** `docker-compose.proxy.yml`: `backspace` on `127.0.0.1:APP_PORT`, no Caddy | operator's reverse proxy | ✅ if operator proxies `/livekit` and opens media ports |
| `tunnel` | already taken | same overlay as `proxy` | tunnel provider (Cloudflare, Tailscale…) | ❌ WebRTC/UDP can't traverse a tunnel |
**The overlay (`docker-compose.proxy.yml`).** Layered on top of the base file it (1) publishes `backspace` on `127.0.0.1:${APP_PORT:-8080}:${PORT:-3000}` — loopback only, so nothing is exposed on a public interface — and (2) parks `caddy` in an inert profile (`_proxy_mode_no_caddy`) that is never activated, so Caddy does not start. The base file is unchanged, so All-in-One (`docker-compose.yml` alone) behaves exactly as before.
**`COMPOSE_FILE` wiring.** In proxy/tunnel mode install.sh writes `COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml` into `.env`. Docker Compose reads `COMPOSE_FILE` from `.env`, so **every** later `docker compose …` command in the directory transparently uses both files — the operator (and the update commands) never need `-f` flags. All-in-One leaves `COMPOSE_FILE` unset (defaults to `docker-compose.yml`).
**Server proxy-awareness.** The server sets Fastify `trustProxy: true`, so it trusts `X-Forwarded-*` from the fronting proxy/tunnel. `getOurOrigin()` returns `https://${DOMAIN}` (federation/public identity) whenever `DOMAIN` is set and `PUBLIC_ORIGIN` is unset — correct in all three modes, since the public URL is `https://DOMAIN` regardless of which layer terminates TLS. No `PUBLIC_ORIGIN` is needed for a normal proxy/tunnel deployment.
**Voice per mode.** LiveKit media is WebRTC over UDP and never flows through the HTTP proxy/tunnel — the media ports (`3478/udp` TURN, `7881/tcp` fallback, `50000-60000/udp` media) must be reachable from clients directly. All-in-One proxies LiveKit *signaling* through Caddy (`/livekit` → `host.docker.internal:7880`); a reverse-proxy operator must replicate that route (`/livekit` → `127.0.0.1:7880`, prefix stripped) **and** open the media ports. Over a tunnel, voice is unavailable and install.sh force-disables it. See `docs/systems/voice.md` for LiveKit tuning.
### Build: multi-stage Dockerfile
`Dockerfile` has two stages:
1. **`builder`** (`node:20-slim`) — enables pnpm via corepack, installs the full workspace with `pnpm install --frozen-lockfile`, copies `shared`/`server`/`web` source, and runs `pnpm --filter @backspace/web build` to produce the static frontend (`packages/web/dist`).
2. **`runtime`** (`node:20-slim`) — installs the native toolchain for `better-sqlite3` plus `ffmpeg` (`python3 make g++ ffmpeg`), installs production-only deps with `pnpm install --prod --frozen-lockfile` (`tsx` is a server runtime dependency), copies `shared` + `server` source and the prebuilt `web/dist`, creates `/app/data/uploads`, and starts the server with `node --import tsx/esm src/index.ts` from `/app/packages/server`.
2. **`runtime`** (`node:20-slim`) — installs `ffmpeg` (media) + `gosu` (privilege drop) only — **no C toolchain**, since `better-sqlite3`/`sharp` load prebuilt binaries — installs production-only deps with `pnpm install --prod --frozen-lockfile` (`tsx` is a server runtime dependency), copies `shared` + `server` source and the prebuilt `web/dist`, creates `/app/data/uploads`, and runs the server **as the non-root `node` user** via `docker-entrypoint.sh` (which chowns `/app/data` as root, then `exec gosu node`) with `node --import tsx/esm src/index.ts` from `/app/packages/server`.
The server is run through `tsx` (no separate transpile step); TypeScript is executed directly at runtime.
@@ -43,6 +73,42 @@ The server is run through `tsx` (no separate transpile step); TypeScript is exec
Empty/unset → `config.commit` is `null` (local dev, tarball install, or git unavailable). The source URL itself is `config.sourceCodeUrl` (env `BACKSPACE_SOURCE_URL`, default upstream) — operators running a modified build MUST set it to their fork.
### Container hardening (non-root)
The runtime image runs as the unprivileged `node` user (uid 1000), not root. On
container start, `docker-entrypoint.sh` runs as root only long enough to `chown`
the `./data` bind mount to `node` (only entries not already node-owned, so it is
near-instant after the first boot), then drops privileges via `gosu` and execs the
server. The build toolchain (`python3`/`make`/`g++`) is not installed in the
runtime stage — `better-sqlite3` and `sharp` load from prebuilt binaries — which
shrinks the runtime attack surface. `ffmpeg` remains (a real runtime dependency).
The published image carries an SBOM and SLSA provenance attestation, and the
amd64 image is scanned by Trivy before publish (report-only). Note: only the
amd64 image is scanned; the arm64 image is published unscanned.
**Minimum Docker version:** the attestation-bearing multi-arch image requires a
reasonably modern Docker to `pull` cleanly (Docker Engine 24+ recommended).
Very old daemons (≤ 20.10) may mishandle the `unknown/unknown` attestation
manifests. New installs via `install.sh` (get.docker.com) are fine.
**Upgrade note for existing self-hosters:** on the first start of the hardened
image, the contents of your host `./data` directory are chowned to uid 1000. This
is expected and idempotent. On an instance with a large `uploads/` tree on slow
storage (e.g. a Pi on SD), the **first** restart after upgrade may take noticeably
longer as this one-time chown runs before the server starts; subsequent boots only
touch not-yet-node-owned entries and are near-instant. If you previously accessed
`./data` on the host as a different user, adjust host-side access accordingly. `./restore.sh` continues to
work — it swaps files inside a throwaway root container, and root can rewrite the
now uid-1000-owned files.
**Release-gate (maintainer):** before the first `v*` tag that ships this image,
do a real `docker compose pull && docker compose up -d` on both an amd64 host and
the arm64 Pi to confirm the attestation-bearing image pulls cleanly on the actual
deployment Docker versions, and that the container boots non-root with a writable
`./data` on real Linux (the macOS Docker Desktop bind-mount ownership display is
not representative of Linux behaviour).
### Run: `docker compose up -d --build`
`docker-compose.yml` defines:
@@ -65,7 +131,13 @@ Caddy provisions and renews TLS certificates automatically for `DOMAIN`; the per
### First-time setup: `install.sh`
`./install.sh` is the interactive installer for a fresh Linux host. It prompts for the domain, whether to enable voice, and an instance name (each skippable via the `DOMAIN` / `ENABLE_VOICE` / `INSTANCE_NAME` env vars for a non-interactive run), generates a `JWT_SECRET`, writes `.env`, optionally configures LiveKit (`livekit.yaml` + `COMPOSE_PROFILES=voice`), and brings the stack up (`docker compose build` then `up -d`).
`./install.sh` is the interactive installer for a fresh Linux host. It prompts for the domain, whether to enable voice, and an instance name (each skippable via the `DOMAIN` / `ENABLE_VOICE` / `INSTANCE_NAME` env vars for a non-interactive run), generates a `JWT_SECRET`, writes `.env`, optionally configures LiveKit (`livekit.yaml` + `COMPOSE_PROFILES=voice`), and brings the stack up.
**Mode selection.** Before configuring, it determines the deployment mode (precedence: explicit `DEPLOY_MODE` env → existing `.env` → auto-detect + prompt). Auto-detection checks whether ports 80/443 are free — and does so **Docker-aware**: it consults both `ss` *and* `docker ps` published ports, because a host running Docker with the userland proxy disabled DNATs 80/443 via iptables with **no listening socket for `ss` to see** (a box whose Caddy already owns those ports would otherwise be misread as "ports free"). When 80/443 are free it offers All-in-One (default); when taken it never dead-ends — it explains what holds them and steers to `proxy`/`tunnel`. In proxy/tunnel mode it auto-picks a free loopback `APP_PORT` (scanning past commonly-taken 3000/8080), force-lowers `MAX_UPLOAD_SIZE` to 90 MB for `tunnel` (Cloudflare's 100 MB body cap), and force-disables voice for `tunnel`.
**Image acquisition.** By default it pulls the prebuilt image (`docker compose pull backspace`). If the pull fails but a usable image is already present on the host (a prior run, an air-gapped `docker load`, or a previous from-source build tagged under the ref) it uses that copy rather than forcing a needless rebuild; only if neither pull nor a local image is available does it fall back to `docker compose build` (or when `BACKSPACE_BUILD=true` forces a source build, e.g. a fork). The commit is captured from the checkout and passed as `--build-arg BACKSPACE_COMMIT=<sha>` on the build path.
**Reverse-proxy / tunnel output.** In proxy/tunnel mode the post-deploy check verifies the app answers on `127.0.0.1:APP_PORT` (TLS is the operator's edge's job, not ours to test), and the summary prints paste-ready nginx / Caddy / Traefik snippets (proxy) or a `cloudflared` ingress rule (tunnel), each with WebSocket upgrade, `X-Forwarded-*`, and a body-size cap matching `MAX_UPLOAD_SIZE` already correct — plus the `/livekit` route and media ports when voice is on.
**Post-install HTTPS reachability check.** The container healthcheck only proves the app is up *inside* Docker — not that `https://DOMAIN` actually works, which additionally requires Caddy to have obtained a publicly-trusted certificate (DNS pointing here **and** ports 80/443 reachable from the internet). After the stack is healthy, the installer verifies this and reports it honestly instead of always printing success:
@@ -131,7 +203,7 @@ Behavior (`packages/server/src/scripts/remediate-seed-admin.ts`):
- **Targets only the local seed admin** — `username = 'admin'` with `home_instance IS NULL` and `is_admin = 1`. Replicated/federated users are never touched.
- **Rotates only `admin123`.** It verifies the current hash against `admin123`; if the password has already been changed, it is a **no-op** ("nothing to do"). It is fully idempotent — safe to run repeatedly.
- **Never deletes** the account (the default-space ownership constraint above).
- On rotation it generates a 24-character random password, updates the hash, prints the new password to stdout, **and** writes it to `data/seed-admin-rotated.txt` (mode `0600`, root-owned via the bind-mount). **Store the password somewhere safe, then delete `data/seed-admin-rotated.txt`.**
- On rotation it generates a 24-character random password, updates the hash, prints the new password to stdout, **and** writes it to `data/seed-admin-rotated.txt` (mode `0600`, **root-owned** — the script runs via `docker exec`, which bypasses the entrypoint's gosu drop and runs as root, so this file is uid 0 until the next container restart re-chowns it). **Store the password somewhere safe, then delete `data/seed-admin-rotated.txt`** (a non-root host user may need `sudo`).
> **Note — sessions are not invalidated.** Rotation changes the stored password hash only; it does **not** revoke existing JWTs. An already-logged-in admin session survives until the token expires (`JWT_EXPIRES_IN`, default 30 days). Rotation closes off *future* logins with the old password; it does not eject a currently active session. If you must terminate live sessions immediately, rotate `JWT_SECRET` (which invalidates **all** tokens instance-wide) and restart.
@@ -214,7 +286,7 @@ They do **not** protect against **hardware loss** (disk failure, the box being d
## 4. Restore
Restores are driven by `./restore.sh` from the host. Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (root)** via the bind-mount, the host user cannot rewrite those files directly — so the actual swap runs inside a throwaway root `alpine` container that mounts `data/`.
Restores are driven by `./restore.sh` from the host. Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (uid 1000)** via the bind-mount, the host user cannot rewrite those files directly — so the actual swap runs inside a throwaway root `alpine` container that mounts `data/`.
### List snapshots
@@ -247,14 +319,16 @@ The pre-restore copy means a mistaken restore is itself undoable: the previous D
## 5. Image Pinning & Upgrades
The two pulled images are **pinned to explicit tags** in `docker-compose.yml`, never `latest`:
The third-party images are **pinned to explicit tags** in `docker-compose.yml`, never `latest`:
| Service | Pinned image |
|---------|--------------|
| `caddy` | `caddy:2.11.1-alpine` |
| `livekit` | `livekit/livekit-server:v1.9.11` |
Pinning makes deploys reproducible — a rebuild pulls the exact same proxy/SFU version every time, so an upstream release can't silently change behavior under you. (The `backspace` image is built from source via the `Dockerfile`, which itself pins the `node:20-slim` base.)
Pinning makes deploys reproducible — a rebuild pulls the exact same proxy/SFU version every time, so an upstream release can't silently change behavior under you.
The `backspace` image itself defaults to `ghcr.io/thezwiss/backspace:latest` (`BACKSPACE_IMAGE` / `BACKSPACE_IMAGE_TAG`). `latest` is chosen for a frictionless first install, but it is a **moving** tag: operators who want reproducible upgrades should pin `BACKSPACE_IMAGE_TAG` to a released version (e.g. `1.0.0`) in `.env` and bump it deliberately. On the source-build paths (`deploy.sh`, `install.sh`'s fallback) the image is built from the `Dockerfile`, which pins the `node:20-slim` base.
**Upgrade procedure:** bump the tag in `docker-compose.yml` → test the new version (locally or on one box) → redeploy. Concretely:
@@ -270,7 +344,7 @@ Never pin to a floating tag like `latest` or a bare major — it defeats reprodu
These are accepted constraints of the current deploy model, documented so operators aren't surprised:
- **The image is built on each target host, including the ARM Raspberry Pi.** There is no cross-built/registry-pushed artifact. The Pi build is slower and consumes build resources on the box (`deploy.sh` caps the build cache and prunes old images to compensate). A native-module or toolchain regression can surface on ARM but not x86, or vice-versa.
- **`deploy.sh` still builds on each target host.** The public `install.sh` path now defaults to the prebuilt GHCR image (multi-arch, so a Pi pulls a native image), but `deploy.sh` — Heidi's rsync-then-`up -d --build` helper for `nova`/`orbit` — deliberately builds from the rsynced working tree on the box (it caps the build cache and prunes old images to compensate). A native-module or toolchain regression can still surface on ARM but not x86, or vice-versa, on that path; the CI multi-arch build catches most such regressions before release.
- **A deploy causes brief downtime + WebSocket reconnect.** `docker compose up -d --build` rebuilds and recreates the `backspace` container; while it restarts, the server is briefly unavailable and every connected client's WebSocket drops and must reconnect. There is no rolling/zero-downtime deploy. Clients reconnect automatically, but in-flight requests during the swap can fail.
- **`deploy.sh all` can mask one host failing.** The `all` target runs both deploys in parallel (`deploy … & deploy … & wait`). The visible "Deployment complete." is printed regardless of whether one host's build failed mid-stream; the failure scrolls by in the interleaved output. After an `all` deploy, **confirm `/api/health` on both boxes** rather than trusting the final line. For a high-stakes change, deploy to one box at a time.
@@ -280,10 +354,12 @@ These are accepted constraints of the current deploy model, documented so operat
| Task | Command |
|------|---------|
| First-time install | `./install.sh` (or `DOMAIN=chat.example.com ./install.sh`) |
| First-time install | `./install.sh` (or `DOMAIN=chat.example.com DEPLOY_MODE=proxy ./install.sh`) |
| Update (prebuilt image — default) | `git pull && docker compose pull && docker compose up -d` |
| Update (from source / fork) | `git pull && docker compose up -d --build` |
| Redeploy both boxes | `./deploy.sh all` |
| Redeploy one box | `./deploy.sh pi` / `./deploy.sh vm` |
| Bring stack up manually | `docker compose up -d --build` |
| Bring stack up manually | `docker compose up -d` (proxy/tunnel: `.env`'s `COMPOSE_FILE` auto-adds the overlay) |
| Check health | `curl -fsS https://<domain>/api/health` |
| Take a manual snapshot | `./backup.sh` |
| List snapshots | `./restore.sh` |
+33
View File
@@ -199,6 +199,39 @@ interface AvatarStackProps {
**Hooks-in-loop safety:** each rendered slot is its own `<AvatarTile>` component so `useCanonicalUserView` is called exactly once per slot, never inside a variable-length `.map()`.
### Avatar vs ProfileAvatar
Two components, one deliberate split:
| Component | Role |
|---|---|
| `Avatar` (`ui/Avatar.tsx`) | Purely presentational. Takes `user` for the gradient, avatar colour, `homeUserId` and status dot. Clicking it does nothing unless the caller passes `onClick`. |
| `ProfileAvatar` (`ui/ProfileAvatar.tsx`) | `Avatar` plus the profile card. Opens `UserProfilePopout` anchored to its own box, stops propagation so it wins over an enclosing row handler, and stays inert while `user` is undefined. |
**Rule:** an avatar is only a profile trigger when it is a `ProfileAvatar`. Never re-add an implicit "open the profile if a `user` prop is present" branch to `Avatar` — passing `user` is how *every* avatar gets its colour, so that branch silently turns the picture inside the profile card, the settings preview, the avatar-upload button and every row in a modal into a trigger. It also made the card re-anchor to its own picture and walk across the screen on repeated clicks (issue #37).
Use `ProfileAvatar` when the avatar is the primary way to reach that person's profile and nothing else owns the click. Use `Avatar` when an enclosing row, button or list item already handles clicks, or when the avatar depicts the surface it already sits on.
**Escalation chain.** Clicking a face always moves one step deeper, never sideways and never nowhere:
| Surface | Picture click |
|---|---|
| Member tile / row / message author | Opens the preview card (`UserProfilePopout`) |
| Preview card | Opens the full profile modal (`UserProfileModal`) and closes the card |
| Full profile modal | Nothing — this is the terminus |
The middle step matters: an inert picture on the preview card is a dead end that forces the user down to the *View Full Profile* link. What it must never do is reopen the card itself — that is the drift bug from issue #37.
### Floating placement
Every floating surface places itself with `computeFloatingPosition` (`hooks/useFloatingPosition.ts`): preferred side → flip when it would overflow → clamp into the viewport, with an 8px viewport padding.
- Components with a live anchor element use the `useFloatingPosition` hook (tooltips, mention/search popovers, voice popovers).
- Components opened from a store keep the anchor's **rect** instead of an element — `uiStore.openUserProfile(user, anchor, placement)` stores `AnchorRect` + `Placement`, and `UserProfilePopout` measures itself and places off that. `pointAnchor(x, y)` builds a zero-size rect for the rare caller with no anchor element.
- `align: 'start'` lines the surface's leading edge up with the anchor; the default centres it on the anchor.
**Callers never compute coordinates.** A surface that is handed a finished `{ top, left }` cannot account for its own measured size, and any caller-side constant (an assumed card height, a hardcoded sidebar width) drifts the moment the content or the layout changes.
**Tile geometry contract.** Each `AvatarTile` renders at `size × size` with a 2px border (`box-sizing: border-box` from Tailwind preflight), so its content area is `(size − 4) × (size − 4)`. The inner `Avatar` is sized to that content area (`size − 2 · TILE_BORDER_WIDTH`) and centered geometrically on the tile via `flex items-center justify-center`, **not** by inline-flow placement. Both corrections are required: sizing the Avatar to the outer dimensions overflows the padding box and gets clipped off-center (visible disc remains centered, but the avatar's contents — image crop, initials gradient + letter — anchor at the padding-edge top-left and visibly drift toward the lower-right of the visible disc); relying on `Avatar`'s `inline-flex` placement makes the Avatar drift vertically by whatever the inherited `line-height` adds, independent of border. `TILE_BORDER_WIDTH` is exported from `AvatarStack.tsx` as the single source of truth for the `border-2` width and must be updated in lockstep with any future change to that class.
**Border tiers:** the surface tier the stack sits on determines the tile border color (so the tiles cleanly separate from the panel they overlap). `channel` → `border-surface-channel` (sidebar); `chat` → `border-surface-chat` (chat area / welcome header / chat header); `modal` → `border-surface-elevated` (modal hero, mobile info-screen hero — there is no `surface-modal` token in `tailwind.config.js`).
+10 -1
View File
@@ -3,7 +3,16 @@
> **Companion spec:** This document covers **S2S (server-to-server)** federation — the relay protocol, HMAC auth, identity resolution, and background workers. For the **client-side** multi-instance architecture (how the web/desktop app connects to multiple instances, federated account creation, origin-aware routing), see [`client-federation.md`](client-federation.md). Both systems work together.
Source files:
- `packages/server/src/routes/federation.ts` -- API endpoints (peer handshake, relay, sync) + all inbound event processors + identity resolution functions
- `packages/server/src/routes/federation.ts` -- **Barrel** for the federation route subsystem. Re-exports the public API (identity resolution, event processors, reconciliation, `validateOrigin`) so `from '.../routes/federation.js'` imports resolve unchanged, and composes the HTTP registrars into `federationRoutes()`. The implementation lives in `routes/federation/` (split out of the former single 7.6k-line file; see `docs/superpowers/specs/2026-07-10-federation-ts-split-design.md`):
- `routes/federation/rateLimits.ts` -- In-memory sliding-window rate limiters (accept/relay/lookup/ensure) + replay-nonce store + eviction timers
- `routes/federation/origin.ts` -- `validateOrigin`, `resolveLocalOrigin`, `sanitizePeer` (+ `SanitizedPeer` shape)
- `routes/federation/identity.ts` -- Federated identity resolution: `extractDomain`, `getOurIdentityDomain`, `verifyAttribution`, `resolveLocalUser`, `findFederatedUser`, `resolveOrCreateReplicatedUser`, `backfillHomeUserId`
- `routes/federation/dmChannels.ts` -- DM channel/message payload builders, `findOrCreateDmChannel`, `resolveLocalDmMessage`, `isUrlFromPeer`
- `routes/federation/profile.ts` -- Replicated-profile hydration + asset download, `processProfileUpdateEvent`, `backfillReplicatedProfileAssets`
- `routes/federation/reconciliation.ts` -- DM federated-id reconciliation + dead-incarnation artifact sweeps (worker-facing maintenance)
- `routes/federation/events/*.ts` -- Inbound relay event processors, grouped by domain: `dmMessages`, `membership`, `friends`, `calls`, `dmState` (presence/read-state/close/reopen/file-rejected), and `dispatch` (`processRelayEvents`, the fan-out entry point shared by the HTTP relay handler and the initial-sync worker)
- `routes/federation/handlers/*.ts` -- Fastify route registrars, grouped by endpoint concern: `peerHandshake` (initiate/accept/ensure/rotate/denied), `peerAdmin` (peer list/CRUD/reset/recheck/rotate), `approvals` (approval queue + peering subscriptions/notifications + approve/deny helpers), `relay` (identity delete, relay, epoch, sync), `lookup` (user lookups), `attach` (verify-attach-proof, `/api/users/@me/reattach`)
- `routes/federation/handlers/s2sAuth.ts` -- `authenticateS2SPeer(request, reply, opts?)`: the shared inbound S2S-HMAC auth preamble (parse headers → resolve active peer → optional per-peer rate limit **before** signature → verify HMAC signature → nonce replay). Adopted by the six endpoints whose preamble is byte-identical: `DELETE /identity`, `POST /relay`, `POST /sync` (`relay.ts`), `POST /users/lookup`, `POST /users/by-home-id` (`lookup.ts`), and `POST /verify-attach-proof` (`attach.ts`). Returns `{ ok: true, peer, nonce }` or, having already sent the rejection reply, `{ ok: false }` (caller must `return`). **Intentional non-adopters** (each keeps a load-bearing gate the helper would flatten, documented in its own docstring/comment): `POST /epoch` (revoked-only gate for peer recovery, 400 on missing headers, no nonce check), `POST /peer/rotate` (active-only, no nonce check), `POST /peer/denied` (`awaiting_approval` gate, synthetic no-grace secret verify).
- `packages/server/src/utils/federationAuth.ts` -- HMAC signing, verification, header parsing, `getOurOrigin()`
- `packages/server/src/utils/federationOutbox.ts` -- Event queuing, coalescing, relay payload construction, mutation log, participant/target resolution
- `packages/server/src/utils/federationLookup.ts` -- HMAC-signed remote-user lookups: `lookupRemoteUser` (by username) and `lookupRemoteUserByHomeId` (reverse lookup, used by stub backfill)
+9 -1
View File
@@ -45,6 +45,15 @@ Storage: Bigint decimal strings in SQLite TEXT columns (bigint not JSON-safe).
### Step 1: Owner/Admin Check
- Space owner OR instance admin (`isAdmin === 1`) → return ALL_PERMISSIONS
### Step 1b: Membership Gate
- If the user is **not** a member of the space (`getMember` returns nothing) → return `0n`
- A non-member has no permissions in a space they have not joined. Without this,
the @everyone role in Step 2 would leak default member rights (VIEW_CHANNEL,
READ_MESSAGE_HISTORY, CREATE_INVITE, …) to any authenticated non-member —
allowing them to read channels and mint invite codes for spaces they never
joined. Owner and instance admin are already resolved in Step 1, so they are
unaffected.
### Step 2: Compute Base (space-level)
- Start with @everyone role permissions (role where `id === spaceId`)
- OR together all permissions from user's assigned roles
@@ -89,7 +98,6 @@ if channelOverride: base = (base & ~deny) | allow
| `permissionsToString(perms)` | Bigint → decimal string for JSON |
| `stringToPermissions(str)` | Decimal string → bigint (supports legacy JSON array format) |
| `computePermissions(userId, spaceId, channelId?)` | Full resolution algorithm |
| `computeCategoryPermissions(userId, spaceId, categoryId)` | Stops at category level (no channel overrides) |
| `hasPermission(userId, spaceId, permission, channelId?)` | Boolean wrapper |
| `getMember/isMember/isSpaceOwner` | Membership checks |
| `isDmMember/isBanned` | DM/ban checks |
+58
View File
@@ -0,0 +1,58 @@
# Security Scanning & Supply-Chain Assurance
Automated, continuous scanning wired into GitHub Actions. This document is the
reference for what runs, where results go, and the one-time settings a maintainer
must enable. **Current state: report-only** — scanners surface findings in the
Security tab but do not block merges yet. Enforcement (blocking) is turned on in a
later change once the remediation pass has cleared the backlog.
## Workflows
| File | Purpose | Trigger | Result |
|------|---------|---------|--------|
| `.github/dependabot.yml` | Dependency + action + base-image update PRs | weekly | PRs |
| `.github/workflows/codeql.yml` | CodeQL SAST (`javascript-typescript`, build-mode none) | PR + push main + weekly | Security tab |
| `.github/workflows/security.yml` | gitleaks (secrets, full history), OSV-Scanner (deps), Trivy config (IaC), Trivy license | PR + push main + weekly | Security tab |
| `.github/workflows/scorecard.yml` | OpenSSF Scorecard (repo posture) | push main + weekly + on branch-protection change | Security tab + public badge |
| `.github/workflows/docker-publish.yml` | Image scan (Trivy) + SBOM + provenance for the published container | tag push / manual | image scan (report-only) + SBOM + provenance |
> **gitleaks findings** surface in the workflow's job log and PR summary — the
> `gitleaks` job does not upload SARIF, so secret hits do **not** appear under
> Security → Code scanning (unlike the OSV / Trivy / CodeQL / Scorecard jobs).
## Tiered policy (target, enforced in a later change)
- **Always block:** gitleaks secret hit; OSV/Trivy fixable HIGH/CRITICAL; Trivy
disallowed license.
- **Advisory (SARIF → Security tab):** CodeQL alerts; OSV/Trivy unfixable or
medium/low; Scorecard.
Code-level gates (OSV, Trivy, gitleaks) block via workflow exit codes. CodeQL
merge-blocking, Dependabot alerts, and native secret-scanning are GitHub *settings*
— see the checklist below.
## Supply-chain hardening
- Every action is pinned to a full commit SHA (`# vX.Y.Z` comment) — resists
tag-move attacks and satisfies Scorecard's Pinned-Dependencies check.
- `step-security/harden-runner` (egress-policy `audit`) on Linux jobs.
- Least-privilege `permissions:` per workflow/job.
- SBOM + SLSA provenance are attached to the published container image at push
(`.github/workflows/docker-publish.yml`), alongside a report-only Trivy scan
of the amd64 image (the arm64 image ships unscanned; enforcement is turned
on in a later plan).
## Maintainer checklist (one-time GitHub settings — NOT code)
- [ ] Repository must be **public** (required for the Scorecard badge/publish and
the CodeQL free tier).
- [ ] Settings → Code security: enable **Dependabot alerts** and **Dependabot
security updates**.
- [ ] Settings → Code security: enable **Secret scanning** + **Push protection**.
- [ ] Settings → Code security: enable **CodeQL / code-scanning merge protection**
so high-severity alerts block PRs (the code-level gates do the rest).
- [ ] Branch protection on `main`: require the CI + security status checks to pass.
- [ ] **Manual image bumps:** Dependabot does not track `docker-compose.yml`
`image:` pins — update `caddy` and `livekit/livekit-server` by hand when new
releases ship. (Renovate, which parses compose, is an optional future
alternative.)
+5 -1
View File
@@ -113,6 +113,8 @@ Cross-references: [database.md](database.md) (table schemas), [permissions.md](p
**Behavior:** Returns existing `inviteCode` if one exists. Only generates a new one (`crypto.randomBytes(4).toString('hex')`) if the space has no invite code. Invite codes are permanent (no expiration).
**Visibility gate:** returns `403` for `request`-visibility spaces — they are approval-gated and have no usable invite link (the join endpoints reject invite-code joins for them), so the endpoint refuses to hand one out. The client (`InviteModal`) shows an "invite by join request" notice instead of the invite UI for such spaces, and `POST /api/dm/space-invite` likewise rejects a `request`-visibility **local** space with `403 space_requires_approval` (remote request spaces are enforced by their home instance at join time).
**Response:** `{ inviteCode: string }`
### Invite URL Format
@@ -168,7 +170,9 @@ Two endpoints serve the same purpose:
| `POST /api/spaces/:id/join` | Join when spaceId is known (body: `{ inviteCode }`) |
| `POST /api/spaces/join` | Join by code only, spaceId looked up from `inviteCode` |
**Validations:** invite code match, not banned, not already a member.
**Validations:** invite code match, not banned, not already a member, and **space visibility is not `request`**.
**Visibility gate:** invite-code joins are rejected (`403`) for `request`-visibility spaces — entry to a request-only space must go through `POST /api/spaces/:id/request-join` + manager approval, never a bearer invite code. `private` spaces remain invite-joinable (an invite is their only entry path); `public` spaces are joinable by code or via `POST /api/spaces/:id/public-join`. Combined with the permission membership gate (a non-member cannot obtain `CREATE_INVITE`, see [permissions.md](permissions.md)), this closes the invite-bypass path where a non-member could mint a code for a request-only space and self-join without approval.
**Side effects:**
1. Insert `space_members` row
+634 -138
View File
@@ -2,13 +2,31 @@
# ============================================================
# Backspace — Production Installer
# ============================================================
# Sets up Backspace with Caddy (auto-HTTPS) and optional
# LiveKit (voice/video) on a Linux server.
# Sets up Backspace on a Linux server in one of three deployment modes,
# auto-detecting which one fits your environment:
#
# allinone The bundled Caddy owns ports 80/443 and does automatic HTTPS
# for your domain. The simplest setup — pick this if 80/443 are free.
# proxy You already run a reverse proxy (nginx, Traefik, Caddy, Nginx Proxy
# Manager, SWAG…). Backspace is published on 127.0.0.1:APP_PORT and
# the installer prints ready-to-paste proxy config. No bundled Caddy.
# tunnel You expose the box through a tunnel (Cloudflare Tunnel, Tailscale…).
# Same as proxy, plus tunnel-specific guidance. (Voice does not work
# over a tunnel — WebRTC/UDP can't traverse it.)
#
# Usage:
# ./install.sh Interactive setup
# ./install.sh Interactive setup (auto-detects the mode)
#
# Non-interactive — set any/all of these to skip the matching prompt:
# DOMAIN=chat.example.com ENABLE_VOICE=true INSTANCE_NAME="My Chat" ./install.sh
# DOMAIN=chat.example.com \
# DEPLOY_MODE=allinone|proxy|tunnel \
# APP_PORT=8080 \ # proxy/tunnel only; auto-picked if unset
# ENABLE_VOICE=true \
# INSTANCE_NAME="My Chat" \
# ./install.sh
#
# BACKSPACE_BUILD=true ./install.sh Force a local from-source build instead of
# pulling the prebuilt image (fork operators).
# ============================================================
set -euo pipefail
@@ -97,56 +115,84 @@ if ! $COMPOSE version &>/dev/null 2>&1; then
fi
success "Docker Compose $($COMPOSE version --short 2>/dev/null || echo 'installed')"
# Check if ports 80/443 are available
check_port() {
local port=$1
if ss -tlnp 2>/dev/null | grep -qE ":${port}\b"; then
local proc
proc=$(ss -tlnp 2>/dev/null | grep -E ":${port}\b" | grep -oP 'users:\(\("\K[^"]+' | head -1 || echo "unknown")
error "Port $port is already in use by: $proc"
error "Free port $port before running this script."
return 1
fi
return 0
# ── Port helpers ────────────────────────────────────────────
# A host port can be held in two ways this installer must detect:
# 1. A normal listening socket (host process) → visible via `ss -tln`.
# 2. A Docker-published port. With the userland proxy *disabled*
# (`userland-proxy: false`, common on tuned hosts), Docker DNATs the port
# with iptables and there is NO listening socket for `ss` to see. So we must
# also consult `docker ps` — otherwise a box whose Caddy already owns 80/443
# via iptables would be misreported as "ports free".
# Does any running container publish this host port? (matches "…:<port>->" in the
# Ports column; the ":" before the port and "->" after pin it to the HOST port,
# so :80 doesn't match :8080 and never matches the container-side port.)
docker_publishes_port() {
$DOCKER ps --format '{{.Names}} {{.Ports}}' 2>/dev/null | grep -qE ":${1}->"
}
# Only check ports if we're NOT already running (upgrade scenario)
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^caddy$'; then
port_ok=true
check_port 80 || port_ok=false
check_port 443 || port_ok=false
if [[ "$port_ok" == false ]]; then
exit 1
# Same, but ignore Backspace's own container (used when re-running on a host that
# already runs this instance — its published port must not count as a conflict).
docker_publishes_port_other() {
$DOCKER ps --format '{{.Names}} {{.Ports}}' 2>/dev/null \
| grep -vE '^backspace ' | grep -qE ":${1}->"
}
# Is the port in use by anything (host socket OR any container)?
port_in_use() {
local port=$1
if ss -tln 2>/dev/null | grep -qE ":${port}[[:space:]]"; then
return 0
fi
success "Ports 80 and 443 are available"
else
success "Caddy is already running (upgrade mode)"
docker_publishes_port "$port"
}
# In use by something OTHER than our own Backspace container?
port_in_use_by_other() {
local port=$1
if ss -tln 2>/dev/null | grep -qE ":${port}[[:space:]]"; then
return 0
fi
docker_publishes_port_other "$port"
}
# Best-effort human description of what holds a port (for helpful warnings).
port_holder() {
local port=$1 holder=""
holder=$($DOCKER ps --format '{{.Names}} {{.Ports}}' 2>/dev/null \
| grep -E ":${port}->" | awk '{print $1}' | head -1 || true)
if [[ -n "$holder" ]]; then
echo "docker container '$holder'"
return
fi
holder=$(ss -tlnp 2>/dev/null | grep -E ":${port}[[:space:]]" \
| grep -oP 'users:\(\("\K[^"]+' | head -1 || true)
echo "${holder:-another process}"
}
# ── openssl (needed for secret generation) ──────────────────
if ! command -v openssl &>/dev/null; then
error "openssl is required for generating secrets."
error "Install: sudo apt-get install openssl"
exit 1
fi
# Disk space check
available_kb=$(df -k . 2>/dev/null | tail -1 | awk '{print $4}' || true)
if [[ -n "$available_kb" ]] && (( available_kb < 3000000 )); then
warn "Low disk space: $((available_kb / 1024))MB available (recommend 3GB+)"
warn "The prebuilt image (~1.6GB pulled) needs less than a from-source build."
else
success "Disk space OK"
fi
# Check for openssl (needed for secret generation)
if ! command -v openssl &>/dev/null; then
error "openssl is required for generating secrets."
error "Install: sudo apt-get install openssl"
exit 1
fi
# ── Phase 2: Configuration ─────────────────────────────────
step "Configuration"
# Detect existing installation
EXISTING_ENV=false
if [[ -f .env ]]; then
EXISTING_ENV=true
info "Existing .env detected — secrets will be preserved."
info "Existing .env detected — secrets and settings will be preserved."
fi
# Helper: read existing .env value (|| true prevents set -e from killing
@@ -157,17 +203,131 @@ env_val() {
fi
}
# ── Deployment mode ─────────────────────────────────────────
# Precedence: explicit DEPLOY_MODE env → existing .env → auto-detect + prompt.
# Auto-detect never dead-ends: if 80/443 are taken, All-in-One is simply off the
# menu and we steer the operator to proxy/tunnel instead.
existing_mode=$(env_val DEPLOY_MODE)
CADDY_RUNNING=false
if $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^caddy$'; then
CADDY_RUNNING=true
fi
port80_free=true; port443_free=true
port_in_use 80 && port80_free=false
port_in_use 443 && port443_free=false
choose_mode_interactively() {
# Writes the chosen mode to the global DEPLOY_MODE.
local choice=""
if [[ "$port80_free" == true && "$port443_free" == true ]]; then
echo " Ports 80 and 443 are free — All-in-One (bundled Caddy + auto-HTTPS) is available."
echo ""
echo " How do you want to expose Backspace?"
echo -e " ${BOLD}1)${NC} All-in-One — bundled Caddy handles HTTPS for you ${GREEN}(recommended)${NC}"
echo -e " ${BOLD}2)${NC} Behind my own reverse proxy (nginx, Traefik, Caddy, Nginx Proxy Manager, SWAG…)"
echo -e " ${BOLD}3)${NC} Behind a tunnel (Cloudflare Tunnel, Tailscale…)"
echo ""
read -rp " Choice [1]: " choice || choice=""
case "${choice:-1}" in
1) DEPLOY_MODE=allinone ;;
2) DEPLOY_MODE=proxy ;;
3) DEPLOY_MODE=tunnel ;;
*) DEPLOY_MODE=allinone ;;
esac
else
warn "Ports 80/443 are already in use on this host:"
[[ "$port80_free" == false ]] && echo " 80 → held by $(port_holder 80)"
[[ "$port443_free" == false ]] && echo " 443 → held by $(port_holder 443)"
echo ""
info "All-in-One needs 80 and 443 free, so it's unavailable here."
info "That's fine — run Backspace behind what already owns those ports:"
echo ""
echo -e " ${BOLD}1)${NC} Behind my own reverse proxy (nginx, Traefik, Caddy, Nginx Proxy Manager, SWAG…) ${GREEN}(recommended)${NC}"
echo -e " ${BOLD}2)${NC} Behind a tunnel (Cloudflare Tunnel, Tailscale…)"
echo -e " ${BOLD}3)${NC} Nothing yet — I'll free 80/443 and use All-in-One (exit so I can do that)"
echo ""
read -rp " Choice [1]: " choice || choice=""
case "${choice:-1}" in
1) DEPLOY_MODE=proxy ;;
2) DEPLOY_MODE=tunnel ;;
3)
error "Free ports 80 and 443, then re-run ./install.sh for All-in-One mode."
exit 1
;;
*) DEPLOY_MODE=proxy ;;
esac
fi
}
if [[ -n "${DEPLOY_MODE:-}" ]]; then
info "Deployment mode: ${DEPLOY_MODE} (from environment)"
elif [[ -n "$existing_mode" ]]; then
DEPLOY_MODE="$existing_mode"
info "Deployment mode: ${DEPLOY_MODE} (from existing .env)"
info "To switch modes, re-run with DEPLOY_MODE=allinone|proxy|tunnel."
elif [[ -t 0 ]]; then
echo ""
choose_mode_interactively
else
# Non-interactive with no DEPLOY_MODE: pick a safe, never-dead-end default.
if [[ "$port80_free" == true && "$port443_free" == true ]]; then
DEPLOY_MODE=allinone
else
DEPLOY_MODE=proxy
warn "Ports 80/443 are in use and no DEPLOY_MODE was given — defaulting to 'proxy'."
fi
info "Deployment mode: ${DEPLOY_MODE} (auto-detected)"
fi
case "$DEPLOY_MODE" in
allinone|proxy|tunnel) ;;
*)
error "Invalid DEPLOY_MODE='${DEPLOY_MODE}'. Use allinone, proxy, or tunnel."
exit 1
;;
esac
# All-in-One requires 80/443 — but a re-run over an existing All-in-One instance
# legitimately finds *its own* Caddy holding them (an upgrade), which is fine.
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
if [[ "$CADDY_RUNNING" == true ]]; then
success "Caddy is already running (All-in-One upgrade)"
elif [[ "$port80_free" == false || "$port443_free" == false ]]; then
error "All-in-One mode needs ports 80 and 443 free, but:"
[[ "$port80_free" == false ]] && error " 80 is held by $(port_holder 80)"
[[ "$port443_free" == false ]] && error " 443 is held by $(port_holder 443)"
error "Free them, or re-run with DEPLOY_MODE=proxy (or =tunnel) to run behind them."
exit 1
else
success "Ports 80 and 443 are available"
fi
fi
# In proxy/tunnel mode, layer the proxy override so Caddy is dropped and the app
# is published on a loopback port. Compose reads COMPOSE_FILE (from the shell and
# from .env) to pick up both files for every command — the operator never needs
# to remember `-f` flags afterwards.
if [[ "$DEPLOY_MODE" == "proxy" || "$DEPLOY_MODE" == "tunnel" ]]; then
export COMPOSE_FILE="docker-compose.yml:docker-compose.proxy.yml"
else
unset COMPOSE_FILE 2>/dev/null || true
fi
# ── Domain ──────────────────────────────────────────────────
# Required in every mode: it's the public hostname clients use and it drives the
# federation identity + LiveKit URL. In proxy/tunnel mode TLS is terminated at
# your edge, but the app still advertises https://DOMAIN.
existing_domain=$(env_val DOMAIN)
if [[ -n "${DOMAIN:-}" ]]; then
# Non-interactive: DOMAIN set via environment
:
elif [[ -n "$existing_domain" ]]; then
read -rp "Domain [$existing_domain]: " DOMAIN
read -rp "Domain [$existing_domain]: " DOMAIN || DOMAIN=""
DOMAIN="${DOMAIN:-$existing_domain}"
else
read -rp "Domain (e.g., chat.example.com): " DOMAIN
read -rp "Domain (e.g., chat.example.com): " DOMAIN || DOMAIN=""
fi
if [[ -z "${DOMAIN:-}" ]]; then
@@ -175,42 +335,93 @@ if [[ -z "${DOMAIN:-}" ]]; then
exit 1
fi
# DNS verification
info "Verifying DNS for ${DOMAIN}..."
# DNS verification is meaningful for All-in-One (Caddy must reach this host to
# issue a certificate). In proxy/tunnel mode the DNS record points at your proxy
# or tunnel edge — often NOT this host's IP (that's the whole point) — so we only
# note what it resolves to, without warning about a mismatch.
info "Checking DNS for ${DOMAIN}..."
resolved_ip=""
if command -v dig &>/dev/null; then
resolved_ip=$(dig +short "$DOMAIN" A 2>/dev/null | tail -1 || true)
elif command -v getent &>/dev/null; then
# A non-resolving domain makes `getent hosts` exit 2, and with `set -o
# pipefail` that would abort the installer here — before the graceful
# "Could not resolve" warning below. Swallow it: an empty result is the
# intended "not resolved yet" signal (installing before DNS is set up is
# explicitly supported).
resolved_ip=$(getent hosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | head -1 || true)
fi
my_ip=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null || curl -s4 --connect-timeout 5 icanhazip.com 2>/dev/null || echo "")
if [[ -z "$resolved_ip" ]]; then
warn "Could not resolve ${DOMAIN}. Ensure DNS is configured before Caddy can issue certificates."
elif [[ -n "$my_ip" && "$resolved_ip" != "$my_ip" ]]; then
warn "${DOMAIN} resolves to ${resolved_ip}, but this server appears to be ${my_ip}"
warn "Let's Encrypt certificate issuance may fail if DNS doesn't point here."
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
my_ip=$(curl -s4 --connect-timeout 5 ifconfig.me 2>/dev/null || curl -s4 --connect-timeout 5 icanhazip.com 2>/dev/null || echo "")
if [[ -z "$resolved_ip" ]]; then
warn "Could not resolve ${DOMAIN}. Ensure DNS is configured before Caddy can issue certificates."
elif [[ -n "$my_ip" && "$resolved_ip" != "$my_ip" ]]; then
warn "${DOMAIN} resolves to ${resolved_ip}, but this server appears to be ${my_ip}"
warn "Let's Encrypt certificate issuance may fail if DNS doesn't point here."
else
success "${DOMAIN} resolves to ${resolved_ip:-verified}"
fi
else
success "${DOMAIN} resolves to ${resolved_ip:-verified}"
if [[ -n "$resolved_ip" ]]; then
info "${DOMAIN} currently resolves to ${resolved_ip} (should point at your proxy/tunnel edge)."
else
info "${DOMAIN} does not resolve yet — point it at your proxy/tunnel edge when ready."
fi
fi
# ── App port (proxy / tunnel only) ──────────────────────────
# The loopback host port your proxy/tunnel forwards to. Auto-picked to avoid
# common clashes (3000/8080 are frequently taken) unless APP_PORT is set.
pick_free_port() {
if [[ -n "${APP_PORT:-}" ]]; then
if port_in_use_by_other "$APP_PORT"; then
error "Requested APP_PORT=$APP_PORT is already in use by $(port_holder "$APP_PORT")."
exit 1
fi
echo "$APP_PORT"; return
fi
local existing; existing=$(env_val APP_PORT)
if [[ -n "$existing" ]]; then
# Re-run: keep the existing port so the proxy/tunnel config stays valid, even
# though our own container is currently publishing it.
echo "$existing"; return
fi
local p
for p in 8080 8081 8082 8090 8095 3001 3002 18080 28080; do
if ! port_in_use "$p"; then echo "$p"; return; fi
done
for p in $(seq 8100 8200); do
if ! port_in_use "$p"; then echo "$p"; return; fi
done
error "Could not find a free host port for the app. Set APP_PORT to a free port and re-run."
exit 1
}
APP_PORT_FINAL=""
if [[ "$DEPLOY_MODE" == "proxy" || "$DEPLOY_MODE" == "tunnel" ]]; then
APP_PORT_FINAL="$(pick_free_port)"
success "App will be published on 127.0.0.1:${APP_PORT_FINAL}"
fi
# ── Voice/Video ─────────────────────────────────────────────
# Voice needs open UDP media ports and (in proxy mode) a /livekit route. Over a
# tunnel, WebRTC/UDP cannot traverse the edge at all — so voice is force-disabled
# in tunnel mode rather than silently configured and then failing at call time.
existing_profiles=$(env_val COMPOSE_PROFILES)
if [[ -n "${ENABLE_VOICE:-}" ]]; then
# Non-interactive
if [[ "$DEPLOY_MODE" == "tunnel" ]]; then
if [[ "${ENABLE_VOICE:-}" == "true" ]]; then
warn "Voice cannot work over a tunnel (WebRTC/UDP can't traverse it) — disabling it."
fi
ENABLE_VOICE=false
info "Voice/video is disabled in tunnel mode (a known, unavoidable limitation)."
elif [[ -n "${ENABLE_VOICE:-}" ]]; then
:
elif [[ "$existing_profiles" == *"voice"* ]]; then
read -rp "Voice/video is currently enabled. Keep it? [Y/n] " yn
read -rp "Voice/video is currently enabled. Keep it? [Y/n] " yn || yn=""
ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true)
else
read -rp "Enable voice/video? (requires open UDP ports) [Y/n] " yn
if [[ "$DEPLOY_MODE" == "proxy" ]]; then
info "Voice needs open UDP media ports AND a /livekit route in your proxy (snippet printed at the end)."
fi
read -rp "Enable voice/video? (requires open UDP ports) [Y/n] " yn || yn=""
ENABLE_VOICE=$([[ "${yn,,}" == "n" ]] && echo false || echo true)
fi
ENABLE_VOICE="${ENABLE_VOICE:-true}"
@@ -219,10 +430,26 @@ ENABLE_VOICE="${ENABLE_VOICE:-true}"
existing_name=$(env_val INSTANCE_NAME)
if [[ -z "${INSTANCE_NAME:-}" ]]; then
read -rp "Instance name [${existing_name:-Backspace}]: " INSTANCE_NAME
read -rp "Instance name [${existing_name:-Backspace}]: " INSTANCE_NAME || INSTANCE_NAME=""
INSTANCE_NAME="${INSTANCE_NAME:-${existing_name:-Backspace}}"
fi
# ── Max upload size ─────────────────────────────────────────
# Cloudflare (free/pro) hard-caps request bodies at 100MB, so a 100MB app limit
# lets uploads fail at the edge instead of in-app. In tunnel mode we default the
# cap below that (90MB) with headroom for multipart overhead. An explicit
# MAX_UPLOAD_SIZE (env or existing .env) always wins.
existing_max=$(env_val MAX_UPLOAD_SIZE)
if [[ -n "${MAX_UPLOAD_SIZE:-}" ]]; then
:
elif [[ -n "$existing_max" ]]; then
MAX_UPLOAD_SIZE="$existing_max"
elif [[ "$DEPLOY_MODE" == "tunnel" ]]; then
MAX_UPLOAD_SIZE=94371840 # 90 MB — under Cloudflare's 100MB body cap
else
MAX_UPLOAD_SIZE=104857600 # 100 MB
fi
# ── Phase 3: Generate Secrets ───────────────────────────────
step "Generating configuration"
@@ -259,12 +486,31 @@ step "Writing configuration files"
# ── .env ────────────────────────────────────────────────────
cat > .env << EOF
{
cat << EOF
# Backspace Configuration
# Generated by install.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
DOMAIN=${DOMAIN}
# Deployment mode: allinone | proxy | tunnel (see ./install.sh --help / README)
DEPLOY_MODE=${DEPLOY_MODE}
EOF
if [[ "$DEPLOY_MODE" == "proxy" || "$DEPLOY_MODE" == "tunnel" ]]; then
cat << EOF
# Reverse-proxy / tunnel mode: layer the proxy override so the bundled Caddy is
# dropped and the app is published on 127.0.0.1:APP_PORT for your edge to reach.
# COMPOSE_FILE makes every 'docker compose' command in this directory use both
# files automatically — no -f flags needed.
COMPOSE_FILE=docker-compose.yml:docker-compose.proxy.yml
APP_PORT=${APP_PORT_FINAL}
EOF
fi
cat << EOF
# Server
PORT=3000
HOST=0.0.0.0
@@ -275,12 +521,12 @@ JWT_SECRET=${JWT_SECRET}
# Registration
REGISTRATION_OPEN=true
# Max upload size in bytes (100MB)
MAX_UPLOAD_SIZE=104857600
# Max upload size in bytes
MAX_UPLOAD_SIZE=${MAX_UPLOAD_SIZE}
EOF
if [[ "$ENABLE_VOICE" == true ]]; then
cat >> .env << EOF
if [[ "$ENABLE_VOICE" == true ]]; then
cat << EOF
# LiveKit Voice/Video
LIVEKIT_URL=wss://${DOMAIN}/livekit
@@ -290,8 +536,8 @@ LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
# Activate the LiveKit service in Docker Compose
COMPOSE_PROFILES=voice
EOF
else
cat >> .env << EOF
else
cat << EOF
# LiveKit Voice/Video (disabled)
# To enable: fill in credentials and add COMPOSE_PROFILES=voice
@@ -299,7 +545,8 @@ else
# LIVEKIT_API_KEY=
# LIVEKIT_API_SECRET=
EOF
fi
fi
} > .env
success ".env"
@@ -382,14 +629,43 @@ step "Deploying Backspace"
# so GET /api/instance/info advertises the exact source version. Passed straight
# to the build as --build-arg (survives the sudo/non-sudo $COMPOSE split, unlike
# an exported env var). Empty when this isn't a git checkout (e.g. tarball
# install) or git is unavailable → the server treats the commit as null.
# install) or git is unavailable → the server treats the commit as null. A pulled
# prebuilt image already carries the commit baked at CI build time.
BUILD_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo '')"
if [[ -n "$BUILD_COMMIT" ]]; then
info "Building Backspace image from commit ${BUILD_COMMIT} (this may take a few minutes on first run)..."
build_from_source() {
if [[ -n "$BUILD_COMMIT" ]]; then
info "Building Backspace image from commit ${BUILD_COMMIT} (first build takes a few minutes)..."
else
info "Building Backspace image (first build takes a few minutes)..."
fi
$COMPOSE build --build-arg BACKSPACE_COMMIT="$BUILD_COMMIT"
}
# Default path: pull the prebuilt multi-arch image (fast, and it spares weak/ARM
# hosts the ~1.6GB local build that OOMs small boxes). Fall back to a from-source
# build if the image can't be pulled (not published yet, private, or offline), or
# if the operator forces a build (BACKSPACE_BUILD=true — e.g. running a fork).
if [[ "${BACKSPACE_BUILD:-false}" == "true" ]]; then
info "BACKSPACE_BUILD=true — building from source (skipping the prebuilt image)."
build_from_source
else
info "Building Backspace image (this may take a few minutes on first run)..."
image_ref="${BACKSPACE_IMAGE:-ghcr.io/thezwiss/backspace}:${BACKSPACE_IMAGE_TAG:-latest}"
info "Fetching prebuilt image ${image_ref} ..."
if $COMPOSE pull backspace; then
success "Pulled prebuilt image"
elif $DOCKER image inspect "$image_ref" >/dev/null 2>&1; then
# Pull failed (offline / registry hiccup / private) but a usable copy is
# already on this host (a prior run, an air-gapped `docker load`, or a
# previous from-source build tagged under this ref) — use it instead of
# forcing a needless multi-hundred-MB rebuild.
warn "Could not pull ${image_ref} — using the copy already present on this host."
else
warn "Prebuilt image unavailable (not published yet, private, or offline)."
warn "Falling back to a from-source build — slower, and heavy on low-RAM/ARM hosts."
build_from_source
fi
fi
$COMPOSE build --quiet --build-arg BACKSPACE_COMMIT="$BUILD_COMMIT"
info "Starting services..."
$COMPOSE up -d
@@ -427,92 +703,312 @@ if [[ "$healthy" == true && -n "$INSTANCE_NAME" && "$INSTANCE_NAME" != "Backspac
' 2>/dev/null && success "Instance name set to: ${INSTANCE_NAME}" || warn "Could not set instance name (set it manually in admin settings)"
fi
# ── Phase 7.5: Verify HTTPS reachability ───────────────────
# The internal healthcheck only proves the app is up *inside* Docker. What the
# operator actually cares about is whether https://DOMAIN works — which needs
# Caddy to have obtained a publicly-trusted TLS certificate, and that only
# happens once DNS points here AND ports 80/443 are reachable from the internet.
#
# We test this hairpin-safely with `curl --resolve DOMAIN:443:127.0.0.1`: it
# connects to the LOCAL Caddy but presents the real SNI/Host and performs full
# certificate verification. Success means Caddy is serving a valid, publicly-
# trusted certificate for DOMAIN *and* the app answers over it — which is only
# possible once issuance has succeeded. Crucially this avoids a false negative
# on self-hosted boxes that can't reach their own public address (router NAT
# hairpin), where a plain external request would time out even though the site
# is perfectly reachable for everyone else.
# ── Phase 7.5: Post-deploy reachability check ──────────────
# What's verifiable differs by mode:
# allinone → prove https://DOMAIN works end-to-end (Caddy has a valid,
# publicly-trusted certificate AND the app answers over it).
# proxy/tunnel → prove the app answers on its loopback port (your edge then
# fronts it); TLS is your proxy/tunnel's job, not ours to test.
https_status="skipped"
if [[ "$healthy" == true ]]; then
step "Verifying HTTPS"
https_status="pending"
app_reachable="unknown"
info "Checking for a valid TLS certificate on ${DOMAIN} (Caddy issues it on first start)..."
for i in $(seq 1 15); do
if curl -fsS --max-time 6 --resolve "${DOMAIN}:443:127.0.0.1" "https://${DOMAIN}/api/health" >/dev/null 2>&1; then
https_status="live"
break
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
if [[ "$healthy" == true ]]; then
step "Verifying HTTPS"
https_status="pending"
# `curl --resolve DOMAIN:443:127.0.0.1` connects to the LOCAL Caddy but
# presents the real SNI/Host and does full certificate verification. A pass
# proves a valid public cert is installed AND the app answers over it — and
# it's hairpin-safe (many self-hosted boxes can't reach their own public IP).
info "Checking for a valid TLS certificate on ${DOMAIN} (Caddy issues it on first start)..."
for i in $(seq 1 15); do
if curl -fsS --max-time 6 --resolve "${DOMAIN}:443:127.0.0.1" "https://${DOMAIN}/api/health" >/dev/null 2>&1; then
https_status="live"
break
fi
sleep 2
done
if [[ "$https_status" == "live" ]]; then
success "HTTPS is live — a valid TLS certificate is installed and Backspace is serving over it."
else
warn "HTTPS is not live yet — Caddy hasn't obtained a publicly-trusted certificate."
fi
fi
else
if [[ "$healthy" == true ]]; then
step "Verifying the app"
app_reachable="no"
for i in $(seq 1 10); do
if curl -fsS --max-time 6 "http://127.0.0.1:${APP_PORT_FINAL}/api/health" >/dev/null 2>&1; then
app_reachable="yes"
break
fi
sleep 1
done
if [[ "$app_reachable" == "yes" ]]; then
success "Backspace is answering on http://127.0.0.1:${APP_PORT_FINAL} — point your $( [[ "$DEPLOY_MODE" == tunnel ]] && echo tunnel || echo 'reverse proxy') at it."
else
warn "Could not reach http://127.0.0.1:${APP_PORT_FINAL}/api/health yet — check: docker compose logs backspace"
fi
sleep 2
done
if [[ "$https_status" == "live" ]]; then
success "HTTPS is live — a valid TLS certificate is installed and Backspace is serving over it."
else
warn "HTTPS is not live yet — Caddy hasn't obtained a publicly-trusted certificate."
fi
fi
# ── Reverse-proxy / tunnel snippet generators ──────────────
# Paste-ready configs with WebSocket upgrade, X-Forwarded-*, and a body-size cap
# already correct. Printed for proxy/tunnel modes so the operator's edge routes
# to 127.0.0.1:APP_PORT (and, if voice is on, /livekit → the host LiveKit).
hr() { echo -e "${CYAN}────────────────────────────────────────────────────────────${NC}"; }
snip() { echo -e "${BOLD}$*${NC}"; }
# Body-size cap in the proxy should match the app's MAX_UPLOAD_SIZE. Express it in
# MB for the human-facing proxy directives (round up so the proxy never rejects a
# body the app would accept).
max_mb=$(( (MAX_UPLOAD_SIZE + 1048575) / 1048576 ))
print_nginx_snippet() {
snip "nginx — add the map once inside http { }, then a server block:"
hr
cat << EOF
# --- inside http { } (once) --------------------------------
map \$http_upgrade \$connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
server_name ${DOMAIN};
# Your TLS certs (certbot, your proxy manager, etc.):
# ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
client_max_body_size ${max_mb}m; # match MAX_UPLOAD_SIZE (${MAX_UPLOAD_SIZE} bytes)
EOF
if [[ "$ENABLE_VOICE" == true ]]; then
cat << EOF
# Voice signaling → host-networked LiveKit (strips the /livekit prefix):
location /livekit/ {
proxy_pass http://127.0.0.1:7880/;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$connection_upgrade;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
}
EOF
fi
cat << EOF
location / {
proxy_pass http://127.0.0.1:${APP_PORT_FINAL};
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;
# WebSocket upgrade (chat, live events, voice signaling):
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
EOF
hr
}
print_caddy_snippet() {
snip "Caddy — if you run your OWN Caddy (it auto-handles WebSocket + HTTPS):"
hr
cat << EOF
${DOMAIN} {
EOF
if [[ "$ENABLE_VOICE" == true ]]; then
cat << EOF
handle_path /livekit/* {
reverse_proxy 127.0.0.1:7880
}
handle {
reverse_proxy 127.0.0.1:${APP_PORT_FINAL}
}
EOF
else
cat << EOF
reverse_proxy 127.0.0.1:${APP_PORT_FINAL}
EOF
fi
cat << EOF
request_body {
max_size ${max_mb}MB
}
}
EOF
hr
}
print_traefik_snippet() {
snip "Traefik — dynamic (file-provider) config; Traefik handles WebSocket itself:"
hr
cat << EOF
http:
routers:
backspace:
rule: "Host(\`${DOMAIN}\`)"
entryPoints: [websecure]
service: backspace
tls:
certResolver: letsencrypt
EOF
if [[ "$ENABLE_VOICE" == true ]]; then
cat << EOF
backspace-livekit:
rule: "Host(\`${DOMAIN}\`) && PathPrefix(\`/livekit\`)"
entryPoints: [websecure]
service: backspace-livekit
priority: 100
middlewares: [strip-livekit]
tls:
certResolver: letsencrypt
middlewares:
strip-livekit:
stripPrefix:
prefixes: ["/livekit"]
EOF
fi
cat << EOF
services:
backspace:
loadBalancer:
servers:
- url: "http://127.0.0.1:${APP_PORT_FINAL}"
EOF
if [[ "$ENABLE_VOICE" == true ]]; then
cat << EOF
backspace-livekit:
loadBalancer:
servers:
- url: "http://127.0.0.1:7880"
EOF
fi
hr
}
print_tunnel_snippet() {
snip "Cloudflare Tunnel — ingress rule (cloudflared config.yml):"
hr
cat << EOF
tunnel: <YOUR-TUNNEL-ID>
credentials-file: /root/.cloudflared/<YOUR-TUNNEL-ID>.json
ingress:
- hostname: ${DOMAIN}
service: http://127.0.0.1:${APP_PORT_FINAL}
- service: http_status:404
EOF
hr
echo " Then map the hostname to the tunnel (once):"
echo -e " ${BOLD}cloudflared tunnel route dns <YOUR-TUNNEL-ID> ${DOMAIN}${NC}"
}
# ── Phase 8: Summary ───────────────────────────────────────
step "Backspace is running"
echo -e " ${BOLD}URL:${NC} https://${DOMAIN}"
echo -e " ${BOLD}Instance:${NC} ${INSTANCE_NAME}"
echo -e " ${BOLD}Mode:${NC} ${DEPLOY_MODE}"
echo -e " ${BOLD}Voice:${NC} $(if [[ "$ENABLE_VOICE" == true ]]; then echo 'Enabled'; else echo 'Disabled'; fi)"
case "$https_status" in
live) echo -e " ${BOLD}HTTPS:${NC} ${GREEN}Live${NC}" ;;
pending) echo -e " ${BOLD}HTTPS:${NC} ${YELLOW}Not live yet${NC}" ;;
esac
echo ""
if [[ "$https_status" == "pending" ]]; then
echo -e " ${YELLOW}The app is up, but HTTPS isn't live yet — Caddy is still trying to get a certificate.${NC}"
echo -e " ${YELLOW}This is normal right after install; it comes up automatically once BOTH are true:${NC}"
echo " 1. ${DOMAIN} resolves to THIS host's public IP"
echo " 2. Ports 80 and 443 are open and forwarded to this host from the internet"
echo -e " Watch progress: ${BOLD}docker compose logs -f caddy${NC}"
if [[ "$DEPLOY_MODE" == "allinone" ]]; then
case "$https_status" in
live) echo -e " ${BOLD}HTTPS:${NC} ${GREEN}Live${NC}" ;;
pending) echo -e " ${BOLD}HTTPS:${NC} ${YELLOW}Not live yet${NC}" ;;
esac
echo ""
echo -e " ${YELLOW}Then open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
else
echo -e " ${YELLOW}Open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
if [[ "$https_status" == "pending" ]]; then
echo -e " ${YELLOW}The app is up, but HTTPS isn't live yet — Caddy is still trying to get a certificate.${NC}"
echo -e " ${YELLOW}This is normal right after install; it comes up automatically once BOTH are true:${NC}"
echo " 1. ${DOMAIN} resolves to THIS host's public IP"
echo " 2. Ports 80 and 443 are open and forwarded to this host from the internet"
echo -e " Watch progress: ${BOLD}docker compose logs -f caddy${NC}"
echo ""
echo -e " ${YELLOW}Then open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
else
echo -e " ${YELLOW}Open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
fi
echo ""
# Best-effort primary LAN IP (the address a router would port-forward to).
LAN_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[0-9.]+' | head -1 || true)
echo -e " ${BOLD}Ports to open${NC} — on this host's firewall (ufw / firewalld / cloud"
echo -e " security group)${BOLD} and,${NC} if the host is behind a router, also"
echo -e " port-forward them to this host${LAN_IP:+ (${LAN_IP})}:"
echo ""
echo " 80/TCP HTTP — cert challenge + HTTP→HTTPS redirect (required)"
echo " 443/TCP HTTPS — web app, API, WebSocket, LiveKit signal (required)"
if [[ "$ENABLE_VOICE" == true ]]; then
echo " 3478/UDP TURN — WebRTC NAT traversal (voice)"
echo " 7881/TCP WebRTC TCP fallback (voice)"
echo " 50000-60000/UDP WebRTC media — voice / video / screen-share (voice)"
fi
echo ""
echo -e " ${YELLOW}80 and 443 must be reachable from the internet before HTTPS can come up.${NC}"
if [[ "$ENABLE_VOICE" == true ]]; then
echo -e " ${YELLOW}Voice/video won't connect until the voice ports above are reachable too.${NC}"
fi
echo -e " LiveKit's own signaling port (7880) stays internal — do ${BOLD}not${NC} forward it."
elif [[ "$DEPLOY_MODE" == "proxy" ]]; then
echo ""
echo -e " Backspace listens on ${BOLD}127.0.0.1:${APP_PORT_FINAL}${NC} (loopback only — never expose it directly)."
echo -e " Point your reverse proxy at it, then open ${BOLD}https://${DOMAIN}${NC} and register the first account (it becomes admin)."
echo ""
echo -e " ${BOLD}Paste one of these into your reverse proxy${NC} (WebSocket, X-Forwarded-*, and body cap already set):"
echo ""
print_nginx_snippet
echo ""
print_caddy_snippet
echo ""
print_traefik_snippet
echo ""
echo -e " ${BOLD}Nginx Proxy Manager / other GUI proxies:${NC} see the field-by-field guide"
echo -e " in the README → 'Deployment modes' (forward to 127.0.0.1:${APP_PORT_FINAL}, enable"
echo -e " 'Websockets Support', and set client_max_body_size ${max_mb}m in the Advanced tab)."
if [[ "$ENABLE_VOICE" == true ]]; then
echo ""
echo -e " ${YELLOW}Voice is enabled — in addition to the /livekit route above, open these UDP/TCP${NC}"
echo -e " ${YELLOW}media ports on the firewall and forward them to this host:${NC}"
echo " 3478/UDP TURN — WebRTC NAT traversal"
echo " 7881/TCP WebRTC TCP fallback"
echo " 50000-60000/UDP WebRTC media — voice / video / screen-share"
echo -e " ${YELLOW}Media flows host→client directly, NOT through your reverse proxy.${NC}"
fi
elif [[ "$DEPLOY_MODE" == "tunnel" ]]; then
echo ""
echo -e " Backspace listens on ${BOLD}127.0.0.1:${APP_PORT_FINAL}${NC} (loopback only). Your tunnel fronts it."
echo -e " Once the ingress rule is live, open ${BOLD}https://${DOMAIN}${NC} and register the first account (it becomes admin)."
echo ""
print_tunnel_snippet
echo ""
echo -e " ${YELLOW}Upload cap:${NC} MAX_UPLOAD_SIZE is set to ${max_mb}MB to stay under Cloudflare's 100MB"
echo -e " request-body limit. Raising it above ~100MB will make large uploads fail at the edge."
echo ""
echo -e " ${YELLOW}Voice/video is not available over a tunnel${NC} — WebRTC/UDP media can't traverse it."
echo -e " If you need voice, run Backspace behind a reverse proxy (proxy mode) with the"
echo -e " media ports opened, or use All-in-One with ports 80/443."
fi
echo ""
echo -e " ${BOLD}Commands:${NC}"
echo -e " ${BOLD}Commands${NC} (run from this directory):"
echo " docker compose logs -f # Watch logs"
echo " docker compose restart # Restart all services"
echo " docker compose down # Stop everything"
echo " docker compose up -d --build # Rebuild after code changes"
echo ""
# Best-effort primary LAN IP (the address a router would port-forward to).
LAN_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[0-9.]+' | head -1 || true)
echo -e " ${BOLD}Ports to open${NC} — on this host's firewall (ufw / firewalld / cloud"
echo -e " security group)${BOLD} and,${NC} if the host is behind a router, also"
echo -e " port-forward them to this host${LAN_IP:+ (${LAN_IP})}:"
echo ""
echo " 80/TCP HTTP — cert challenge + HTTP→HTTPS redirect (required)"
echo " 443/TCP HTTPS — web app, API, WebSocket, LiveKit signal (required)"
if [[ "$ENABLE_VOICE" == true ]]; then
echo " 3478/UDP TURN — WebRTC NAT traversal (voice)"
echo " 7881/TCP WebRTC TCP fallback (voice)"
echo " 50000-60000/UDP WebRTC media — voice / video / screen-share (voice)"
fi
echo ""
echo -e " ${YELLOW}80 and 443 must be reachable from the internet before HTTPS can come up.${NC}"
if [[ "$ENABLE_VOICE" == true ]]; then
echo -e " ${YELLOW}Voice/video won't connect until the voice ports above are reachable too.${NC}"
fi
echo -e " LiveKit's own signaling port (7880) stays internal — do ${BOLD}not${NC} forward it."
echo " docker compose pull && docker compose up -d # Update to the latest prebuilt image"
echo ""
+9 -5
View File
@@ -1,8 +1,8 @@
{
"name": "backspace",
"version": "1.0.0",
"version": "2.1.0",
"private": true,
"description": "Open, self-hosted communication platform — text, voice, video, and federation",
"description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
"license": "AGPL-3.0-only",
"author": "Jannis Braun",
"homepage": "https://github.com/TheZwiss/backspace",
@@ -13,11 +13,13 @@
"scripts": {
"dev:server": "pnpm --filter @backspace/server dev",
"dev:web": "pnpm --filter @backspace/web dev",
"dev": "pnpm --filter @backspace/server dev & pnpm --filter @backspace/web dev",
"dev": "pnpm --parallel --filter @backspace/server --filter @backspace/web dev",
"build:shared": "pnpm --filter @backspace/shared build",
"build:server": "pnpm --filter @backspace/server build",
"build:web": "pnpm --filter @backspace/web build",
"build": "pnpm --filter @backspace/shared build && pnpm --filter @backspace/server build && pnpm --filter @backspace/web build",
"typecheck": "pnpm --filter @backspace/shared build && pnpm -r typecheck",
"test": "pnpm -r test",
"dev:desktop": "pnpm --filter @backspace/desktop dev",
"build:desktop": "pnpm --filter @backspace/desktop build",
"gen-icons": "node scripts/gen-icons.mjs"
@@ -30,16 +32,18 @@
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild",
"electron",
"electron-native-screenshare",
"esbuild",
"sharp"
],
"patchedDependencies": {
"uiohook-napi@1.5.5": "patches/uiohook-napi@1.5.5.patch"
}
},
"packageManager": "pnpm@10.34.3",
"engines": {
"node": ">=20.0.0",
"pnpm": ">=8.0.0"
"pnpm": ">=10.0.0"
}
}
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 955 B

After

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

+30 -7
View File
@@ -1,5 +1,5 @@
appId: com.backspace.desktop
productName: Backspace
productName: Resenhacord
artifactName: "${productName}-${version}-${arch}.${ext}"
directories:
output: dist-electron
@@ -14,16 +14,29 @@ files:
- "!**/node_modules/uiohook-napi/build/**"
- "!**/node_modules/uiohook-napi/build.bak/**"
- "!**/node_modules/uiohook-napi/bin/**"
# electron-native-screenshare has no prebuilds — build/Release/*.node is the
# only copy, so it must NOT be excluded the way uiohook-napi's is. asarUnpack
# below takes it out of the archive so the loader can find it.
asarUnpack:
- "**/*.node"
npmRebuild: false
afterPack: ./scripts/afterPack.js
publish:
- provider: github
owner: TheZwiss
repo: backspace
# Updates are served from this fork's own Gitea, not from GitHub.
#
# GitHub is only the build machine — it has the Windows runners the native
# audio module needs. Its repository is private, and electron-updater against
# a private GitHub repo would need a token shipped inside the app, which is a
# leaked token. Gitea serves release assets to anyone, so no credential ends
# up in the installer.
#
# The tag is fixed at `latest` on purpose: electron-updater fetches
# latest.yml before it knows which version exists, so the URL cannot contain
# a version. CI replaces that release's assets on every publish.
- provider: generic
url: https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/
protocols:
- name: Backspace
- name: Resenhacord
schemes:
- backspace
mac:
@@ -45,5 +58,15 @@ linux:
- deb
category: Network
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
# One-click, per-user install (%LOCALAPPDATA%), the way Discord ships.
#
# This is what makes an update silent: a per-user install needs no elevation,
# so electron-updater can run the installer with /S and no UAC prompt breaks
# the flow. The assisted installer we had before (oneClick: false) showed its
# wizard on every update and offered an all-users install, which would then
# demand elevation on each subsequent update.
#
# The trade is that a fresh install no longer asks where to put the app.
oneClick: true
perMachine: false
allowToChangeInstallationDirectory: false
+4 -3
View File
@@ -1,9 +1,9 @@
{
"name": "@backspace/desktop",
"version": "1.0.0",
"version": "2.1.0",
"private": true,
"license": "AGPL-3.0-only",
"description": "Backspace",
"description": "Resenhacord",
"author": {
"name": "Jannis Braun",
"email": "backspace@backspace.chat"
@@ -18,9 +18,10 @@
"clean": "rm -rf dist dist-electron",
"test": "vitest run",
"test:watch": "vitest",
"postinstall": "electron-rebuild -f -w uiohook-napi"
"postinstall": "electron-rebuild -f -w uiohook-napi,electron-native-screenshare || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\""
},
"dependencies": {
"electron-native-screenshare": "^1.2.0",
"electron-updater": "^6.3.0",
"uiohook-napi": "^1.5.5"
},
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backspace — Connect</title>
<title>Resenhacord — Conectar</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
@@ -270,7 +270,7 @@
<div class="container">
<div class="card">
<div class="header">
<h1>Welcome to Backspace</h1>
<h1>Bem-vindo ao Resenhacord</h1>
<p>Connect to your instance</p>
</div>
@@ -360,7 +360,7 @@
// Update header copy to reflect the "switch" intent rather than the
// first-run "welcome" framing.
document.querySelector('.header h1').textContent = 'Switch instance';
document.querySelector('.header p').textContent = 'Connect to a different Backspace instance, or cancel to stay.';
document.querySelector('.header p').textContent = 'Conecte-se a outra instância, ou cancele para ficar nesta.';
}
}
init();
+4 -4
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Backspace — Recovery</title>
<title>Resenhacord — Recuperação</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
* { box-sizing: border-box; }
@@ -128,7 +128,7 @@
<div class="content">
<div class="container">
<div class="card">
<h1>Backspace couldn't load.</h1>
<h1>O Resenhacord não conseguiu abrir.</h1>
<p class="hint" id="hint">Loading…</p>
<div class="actions">
@@ -147,7 +147,7 @@
<pre id="diagnostic">loading…</pre>
</details>
<button class="action subtle" id="btn-quit">Quit Backspace</button>
<button class="action subtle" id="btn-quit">Fechar o Resenhacord</button>
</div>
</div>
</div>
@@ -270,7 +270,7 @@
async function init() {
if (!window.backspace) {
$('hint').textContent = 'Recovery bridge unavailable. Please quit and reinstall Backspace.';
$('hint').textContent = 'A ponte de recuperação não respondeu. Feche e reinstale o Resenhacord.';
return;
}
try {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 983 B

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 703 B

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 836 B

+204 -34
View File
@@ -36,28 +36,36 @@ import {
buildTrayMenuTemplate,
buildAppMenuTemplate,
type RecoveryState,
RELEASES_URL,
} from './recovery';
import { migrateUserData } from './userDataMigration';
// Override Electron's package.json-derived app name so userData lives at
// "<appData>/Backspace" instead of leaking the monorepo's "@backspace/desktop"
// "<appData>/Resenhacord" instead of leaking the monorepo's "@backspace/desktop"
// package name. Must run before any app.getPath('userData') consumer.
app.setName('Backspace');
app.setName('Resenhacord');
// One-time migration from the historical scoped path. After the move the old
// folder is gone, so subsequent launches hit the old-missing no-op branch.
// One-time migrations, oldest first, so an install that skipped a release
// still walks the whole chain: "@backspace/desktop" → "Backspace" →
// "Resenhacord". Each step is a no-op once its source folder is gone, and a
// step whose target already has data leaves it alone (`new-populated`), so a
// user who already ran the renamed build is never overwritten by stale files.
// Without the second step the rename would have logged everyone out: the
// instance URL and session live under userData.
{
const appDataDir = app.getPath('appData');
const oldParent = path.join(appDataDir, '@backspace');
const result = migrateUserData({
oldDir: path.join(oldParent, 'desktop'),
newDir: path.join(appDataDir, 'Backspace'),
oldParent,
});
if (result.kind === 'migrated') {
console.log(`[userData] migrated ${result.from} → ${result.to}`);
} else if (result.kind === 'failed') {
console.error('[userData] migration failed:', result.error);
const legacyParent = path.join(appDataDir, '@backspace');
const steps: { oldDir: string; newDir: string; oldParent: string }[] = [
{ oldDir: path.join(legacyParent, 'desktop'), newDir: path.join(appDataDir, 'Backspace'), oldParent: legacyParent },
{ oldDir: path.join(appDataDir, 'Backspace'), newDir: path.join(appDataDir, 'Resenhacord'), oldParent: appDataDir },
];
for (const step of steps) {
const result = migrateUserData(step);
if (result.kind === 'migrated') {
console.log(`[userData] migrated ${result.from} → ${result.to}`);
} else if (result.kind === 'failed') {
console.error('[userData] migration failed:', result.error);
}
}
}
@@ -241,7 +249,7 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
enabled: openAtLogin,
path: process.execPath,
args: startMinimized ? ['--hidden'] : [],
name: 'Backspace',
name: 'Resenhacord',
});
} else {
// Linux: setLoginItemSettings creates ~/.config/autostart/<name>.desktop.
@@ -265,6 +273,102 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
}
}
// ─── Native system-audio capture ────────────────────────────────────────────
//
// Electron's own `audio: 'loopback'` captures the whole output mix, which
// includes this app playing everyone else's voices — so those voices went back
// out inside the screen share and every listener heard themselves. Not acoustic
// echo: it is a digital copy of the output, so headphones never helped.
//
// This module captures with process-level isolation instead: only the shared
// window (include mode), or everything except this app (exclude mode).
interface NativeAudioMeta {
sampleRate: number;
channels: number;
bitsPerSample: number;
isFloat: boolean;
}
interface NativeScreenShareAudio {
startCapture(processId?: number, isIncludeMode?: boolean, onData?: (data: Buffer, meta: NativeAudioMeta) => void): boolean;
stopCapture(): boolean;
getPidFromWindowHandle(windowHandle: number): number;
isAvailable(): boolean;
getLoadError(): string | null;
}
let nativeAudio: NativeScreenShareAudio | null = null;
try {
// Required lazily and defensively: a native module that fails to load must
// degrade to sharing without audio, never stop the app from starting.
nativeAudio = require('electron-native-screenshare') as NativeScreenShareAudio;
if (!nativeAudio.isAvailable()) {
console.warn('[Main:ScreenShare] Native audio unavailable:', nativeAudio.getLoadError());
nativeAudio = null;
}
} catch (err) {
console.warn('[Main:ScreenShare] Native audio module missing:', err);
nativeAudio = null;
}
let nativeAudioActive = false;
/**
* Windows and Linux hand desktopCapturer ids of the form `window:<handle>:<n>`.
* Recovering the handle lets us capture only that window's audio, which is
* better than excluding ourselves: a game's sound goes out, the rest of the
* desktop does not.
*/
function windowHandleFromSourceId(sourceId: string): number | null {
const match = /^window:(\d+)/.exec(sourceId);
if (!match) return null;
const handle = Number(match[1]);
return Number.isFinite(handle) && handle > 0 ? handle : null;
}
function startNativeAudioCapture(sourceId: string): boolean {
if (!nativeAudio || nativeAudioActive) return false;
let targetPid = process.pid;
let includeMode = false;
const handle = windowHandleFromSourceId(sourceId);
if (handle !== null) {
const pid = nativeAudio.getPidFromWindowHandle(handle);
// pid 0 means the handle did not resolve; fall back to excluding ourselves
// rather than capturing nothing.
if (pid > 0) {
targetPid = pid;
includeMode = true;
}
}
try {
const started = nativeAudio.startCapture(targetPid, includeMode, (data, meta) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('native-audio-data', data, meta);
});
nativeAudioActive = started;
console.log('[Main:ScreenShare] Native audio', started ? 'started' : 'failed',
includeMode ? `(only pid ${targetPid})` : '(excluding self)');
return started;
} catch (err) {
console.error('[Main:ScreenShare] Native audio start failed:', err);
nativeAudioActive = false;
return false;
}
}
function stopNativeAudioCapture(): void {
if (!nativeAudio || !nativeAudioActive) return;
try {
nativeAudio.stopCapture();
} catch (err) {
console.warn('[Main:ScreenShare] Native audio stop failed:', err);
}
nativeAudioActive = false;
}
// ─── Tray Icon ──────────────────────────────────────────────────────────────
function generateFallbackTrayIcon(): Electron.NativeImage {
@@ -341,7 +445,7 @@ function createWindow(): void {
: {}),
minWidth: 940,
minHeight: 500,
title: 'Backspace',
title: 'Resenhacord',
icon: path.join(__dirname, '..', 'build', 'icon.png'),
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
...(process.platform !== 'darwin' ? {
@@ -474,7 +578,7 @@ function createTray(): void {
const icon = loadTrayIcon();
tray = new Tray(icon);
tray.setToolTip('Backspace');
tray.setToolTip('Resenhacord');
// Context menu is set by the recoveryStore subscriber in app.whenReady,
// which keeps the menu in sync with update/recovery state.
@@ -492,7 +596,9 @@ function createTray(): void {
function showNotification(title: string, body: string, onClick?: () => void): void {
if (!Notification.isSupported()) return;
const notification = new Notification({ title, body, silent: false });
// silent: o som do sistema é o que mais incomoda numa notificação de chat, e
// o app toca o seu próprio efeito — que combina com os demais sons dele.
const notification = new Notification({ title, body, silent: true });
notification.on('click', onClick ?? (() => {
mainWindow?.show();
mainWindow?.focus();
@@ -576,12 +682,25 @@ function registerIpcHandlers(): void {
ipcMain.on('install-update', () => {
try {
const { autoUpdater } = require('electron-updater');
autoUpdater.quitAndInstall();
quitAndInstallSilently(autoUpdater);
} catch {
// Auto-updater not available
}
});
// The `update-downloaded` push is a one-shot: it reaches whatever page is
// loaded at that instant, and nothing else. It is lost when the window is in
// the tray (destroyed), when the check lands before the web app has mounted
// its listener, and on every reload. Since `downloaded` also latches off
// further polls, the banner could then never appear at all — which is why
// pressing "check for updates" by hand looked like the only thing that
// worked. The renderer now asks for the current state on mount instead of
// relying solely on catching the event live.
ipcMain.handle('get-update-status', () => {
const s = recoveryStore.get();
return { state: s.updateState, version: s.updateVersion };
});
ipcMain.on('check-for-updates', () => {
try {
const { autoUpdater } = require('electron-updater');
@@ -594,6 +713,10 @@ function registerIpcHandlers(): void {
ipcMain.handle('get-app-version', () => app.getVersion());
// Screen share picker coordination (used by setDisplayMediaRequestHandler)
ipcMain.on('native-audio-stop', () => {
stopNativeAudioCapture();
});
ipcMain.on('screen-share-selected', (_event, _sourceId: string | null, _shareAudio?: boolean) => {
// Handled via ipcMain.once in the display media handler — this is just
// a safety net to prevent unhandled-message warnings
@@ -608,7 +731,7 @@ function registerIpcHandlers(): void {
// executableWillLaunchAtLogin to honour Task Manager's StartupApproved state.
const osState = app.getLoginItemSettings({ path: process.execPath });
const ownEntry = osState.launchItems?.find(
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
(item) => item.name === 'Resenhacord' || item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
);
// When the Run entry exists, its args are the source of truth for startMinimized.
// When the entry is absent (autostart is off), there is no OS state to read, so we
@@ -643,7 +766,7 @@ function registerIpcHandlers(): void {
if (process.platform === 'win32') {
const osState = app.getLoginItemSettings({ path: process.execPath });
const ownEntry = osState.launchItems?.find(
(item) => item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
(item) => item.name === 'Resenhacord' || item.name === 'Backspace' || item.path?.toLowerCase() === process.execPath.toLowerCase(),
);
currentOpenAtLogin = osState.executableWillLaunchAtLogin ?? false;
// See `get-auto-launch-settings`: when the entry is absent, fall back to disk
@@ -702,6 +825,22 @@ function registerIpcHandlers(): void {
// ─── Auto-Update ────────────────────────────────────────────────────────────
/**
* Installs a staged update with no installer UI and relaunches the app.
*
* `quitAndInstall()` defaults to (isSilent=false, isForceRunAfter=false): on
* Windows that runs the NSIS installer with its full wizard, so "restart to
* update" meant clicking through a setup program. Silent mode passes /S and
* reuses the recorded install location; isForceRunAfter brings the app back up
* afterwards, which the wizard would otherwise leave to a checkbox.
*
* Both flags are inert on macOS and Linux, where the update is applied by
* swapping files — no installer runs either way.
*/
function quitAndInstallSilently(au: { quitAndInstall: (isSilent?: boolean, isForceRunAfter?: boolean) => void }): void {
au.quitAndInstall(true, true);
}
function initAutoUpdater(): void {
try {
const { autoUpdater } = require('electron-updater');
@@ -712,7 +851,14 @@ function initAutoUpdater(): void {
let updateConfirmed = false;
// Cadence state. `lastCheckAt` is stamped from the event rather than from
// the callers, so a manual "check for updates" from the settings panel
// counts too and a focus arriving right after it doesn't check twice.
let lastCheckAt = 0;
let downloaded = false;
autoUpdater.on('checking-for-update', () => {
lastCheckAt = Date.now();
recoveryStore.update({ updateState: 'checking', lastCheckResult: null });
updateConfirmed = false;
});
@@ -734,6 +880,7 @@ function initAutoUpdater(): void {
});
autoUpdater.on('update-downloaded', (info: { version: string }) => {
downloaded = true;
const version = info.version.slice(0, 32);
recoveryStore.update({ updateState: 'downloaded', updateVersion: version });
mainWindow?.webContents.send('update-downloaded', { version });
@@ -743,9 +890,9 @@ function initAutoUpdater(): void {
// (recovery mode) is visible — no need to also fire a native toast.
if (!mainWindow?.isFocused()) {
showNotification(
'Backspace update ready',
'Resenhacord update ready',
`Click to restart and install version ${version}.`,
() => autoUpdater.quitAndInstall(),
() => quitAndInstallSilently(autoUpdater),
);
}
});
@@ -768,22 +915,33 @@ function initAutoUpdater(): void {
if (updateConfirmed) {
mainWindow?.webContents.send('update-error', {
message,
releaseUrl: 'https://github.com/TheZwiss/backspace/releases/latest',
releaseUrl: RELEASES_URL,
});
}
});
// Initial check with 10s delay (existing behavior)
setTimeout(() => {
updateConfirmed = false;
autoUpdater.checkForUpdates().catch(() => {});
}, 10_000);
// A release published while the app sits open used to go unnoticed until
// the next poll — the startup check had long since run, and four hours is
// a long time to look at a stale version. Poll more often, and also check
// when the user comes back to the window, which is exactly when they would
// notice an update anyway.
const POLL_INTERVAL_MS = 30 * 60 * 1000;
const MIN_GAP_MS = 5 * 60 * 1000; // keeps alt-tabbing off the feed
// Periodic check every 4 hours (existing behavior)
setInterval(() => {
const maybeCheck = (): void => {
if (downloaded) return; // already staged; nothing left to look for
if (Date.now() - lastCheckAt < MIN_GAP_MS) return;
updateConfirmed = false;
autoUpdater.checkForUpdates().catch(() => {});
}, 4 * 60 * 60 * 1000);
};
setTimeout(maybeCheck, 10_000);
setInterval(maybeCheck, POLL_INTERVAL_MS);
// App-level rather than mainWindow-level: the window is destroyed and
// recreated when closed to tray and reopened, and a listener bound to the
// old instance would go quiet after the first close.
app.on('browser-window-focus', maybeCheck);
} catch {
// Auto-updater unavailable — recovery surface still functions, just
// without update affordances. autoUpdaterRef stays null; recovery action
@@ -953,7 +1111,19 @@ if (!gotTheLock) {
// `PulseaudioLoopbackForScreenShare` feature flag we enable above.
// Fails on PipeWire-only systems without pulse compat — the
// renderer catches that and toasts the user.
callback({ video: selected, ...(shareAudio ? { audio: 'loopback' } : {}) });
// Audio no longer rides on the Electron stream: `loopback` would put
// this app's own output (everyone else's voices) back into the share.
// The native module captures it separately, isolated by process, and
// the renderer turns it into the track LiveKit publishes.
if (shareAudio) {
const started = startNativeAudioCapture(sourceId);
if (!started) {
// Tell the renderer so it can say the share is going out silently,
// instead of the user assuming sound is included.
mainWindow?.webContents.send('native-audio-unavailable');
}
}
callback({ video: selected });
} catch (err) {
console.error('[Main:ScreenShare] Handler error:', err);
// @ts-ignore — deny the request without crashing
@@ -971,7 +1141,7 @@ if (!gotTheLock) {
// AGPL-3.0 § 13: native About panel advertises the version + source repo.
app.setAboutPanelOptions({
applicationName: 'Backspace',
applicationName: 'Resenhacord',
applicationVersion: app.getVersion(),
copyright: `AGPL-3.0-only · Source: ${UPSTREAM_SOURCE_URL}`,
website: UPSTREAM_SOURCE_URL,
+19
View File
@@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('backspace', {
checkForUpdates: () => {
ipcRenderer.send('check-for-updates');
},
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
getVersion: () => ipcRenderer.invoke('get-app-version'),
// Window focus
@@ -65,6 +66,24 @@ contextBridge.exposeInMainWorld('backspace', {
onScreenShareSources: (callback: (sources: unknown[]) => void) => {
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
},
// Raw PCM from the native capture. Arrives ~50x/second; the renderer turns it
// into a MediaStreamTrack for LiveKit.
onNativeAudioData: (callback: (data: ArrayBuffer, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => void) => {
const listener = (_event: unknown, data: Uint8Array, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => {
// Copied out of the transferred buffer: reusing it across IPC messages
// would let a later chunk overwrite one still being read.
callback(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer, meta);
};
ipcRenderer.on('native-audio-data', listener);
return () => ipcRenderer.off('native-audio-data', listener);
},
onNativeAudioUnavailable: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on('native-audio-unavailable', listener);
return () => ipcRenderer.off('native-audio-unavailable', listener);
},
stopNativeAudio: () => ipcRenderer.send('native-audio-stop'),
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => {
ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true);
},
+1 -1
View File
@@ -166,7 +166,7 @@ describe('buildTrayMenuTemplate', () => {
it('includes Show/Hide/Change Instance/Quit base items', () => {
const items = buildTrayMenuTemplate(defaultState());
const labels = items.map((i) => i.label);
expect(labels).toContain('Show Backspace');
expect(labels).toContain('Mostrar o Resenhacord');
expect(labels).toContain('Hide');
expect(labels).toContain('Change Instance');
expect(labels).toContain('Quit');
+13 -3
View File
@@ -17,6 +17,15 @@ export type UpdateState =
| 'downloaded'
| 'error';
/**
* Where a manual download lands when auto-update fails.
*
* The upstream GitHub repository this used to point at is private for this
* fork, so the link 404'd for everyone who followed it. Releases are on the
* fork's own Gitea, which is also where the updater feed lives.
*/
export const RELEASES_URL = 'https://git.resenha.website/devsyncwrld/backspace/releases';
export interface RecoveryState {
mode: 'normal' | 'recovery';
reason: { code: RecoveryReasonCode; detail: string } | null;
@@ -122,7 +131,7 @@ export function buildTrayMenuTemplate(
actions?: Partial<MenuActions>,
): MenuItemConstructorOptions[] {
const items: MenuItemConstructorOptions[] = [
{ label: 'Show Backspace', click: actions?.onShow },
{ label: 'Mostrar o Resenhacord', click: actions?.onShow },
{ label: 'Hide', click: actions?.onHide },
{ type: 'separator' },
checkForUpdatesItem(state, () => actions?.onCheckForUpdates?.()),
@@ -419,7 +428,8 @@ export function handleRecoveryAction(action: RecoveryAction): void {
// Direct quitAndInstall — does NOT rely on autoInstallOnAppQuit.
// Force-kill-fix: if user reaches recovery and clicks here, install
// happens cleanly even if the on-quit hook would otherwise be bypassed.
autoUpdaterRef?.quitAndInstall();
// Silent + relaunch, matching the in-app banner: no installer wizard.
autoUpdaterRef?.quitAndInstall(true, true);
return;
}
case 'change-instance': {
@@ -440,7 +450,7 @@ export function handleRecoveryAction(action: RecoveryAction): void {
return;
}
case 'open-releases': {
shell.openExternal('https://github.com/TheZwiss/backspace/releases/latest');
shell.openExternal(RELEASES_URL);
return;
}
case 'quit': {
+7
View File
@@ -0,0 +1,7 @@
node_modules/
# Gerados por `cap sync` a cada build — versioná-los só criaria diffs ruidosos
# e o risco de o APK sair com uma cópia velha.
android/app/src/main/assets/public/
android/app/src/main/assets/capacitor.config.json
android/app/src/main/assets/capacitor.plugins.json
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+57
View File
@@ -0,0 +1,57 @@
apply plugin: 'com.android.application'
android {
namespace "com.resenhacord.app"
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.resenhacord.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
// Mantidos em sincronia com a versao do monorepo. O versionCode e o
// numero que o Android usa para decidir se um APK e mais novo, e ele
// precisa ser inteiro e sempre crescente: 2.1.0 -> 20100.
versionCode 20100
versionName "2.1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
@@ -0,0 +1,19 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
O tipo "microphone" tem de bater com o que o servico passa para o
startForeground e com a permissao FOREGROUND_SERVICE_MICROPHONE. A
partir do Android 14 os tres precisam concordar, senao o sistema
derruba o app na hora de entrar na call.
-->
<service
android:name=".VoiceForegroundService"
android:exported="false"
android:foregroundServiceType="microphone" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<!--
Voz e video. A WebView nao pede nada ao sistema sozinha: quando a pagina
chama getUserMedia, o bridge do Capacitor intercepta o pedido e traduz
para estas permissoes nativas. Sem declara-las aqui, a chamada falha com
NotAllowedError e o microfone simplesmente nao liga.
-->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Trocar entre alto-falante e fone durante a chamada. -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Android 13+ nao mostra notificacao nenhuma sem esta permissao. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!--
Chamada com a tela apagada. O servico em primeiro plano impede o sistema
de congelar o processo; o wake lock impede a CPU de dormir. Sem os dois, a
WebView para de rodar o JavaScript e a call cai.
-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!--
O aparelho precisa ter microfone; camera e opcional para nao excluir
tablets sem camera frontal da lista de aparelhos compativeis.
-->
<uses-feature android:name="android.hardware.microphone" android:required="true" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest>
@@ -0,0 +1,36 @@
package com.resenhacord.app;
import android.Manifest;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
private static final int REQ_NOTIFICATIONS = 100;
@Override
public void onCreate(Bundle savedInstanceState) {
// Plugins que moram no proprio app nao sao descobertos sozinhos, e o
// registro tem de acontecer antes do super.onCreate — depois disso a
// ponte ja foi montada e o plugin nao aparece para o JavaScript.
registerPlugin(VoicePlugin.class);
super.onCreate(savedInstanceState);
requestNotificationPermission();
}
/**
* A partir do Android 13 a notificacao da chamada nao aparece sem esta
* permissao. O servico continua rodando de qualquer forma — a chamada nao
* cai se o usuario recusar —, mas ele fica sem nada visivel na barra, o que
* so parece defeito. Por isso perguntamos, e seguimos com a recusa.
*/
private void requestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return;
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED) return;
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQ_NOTIFICATIONS);
}
}
@@ -0,0 +1,151 @@
package com.resenhacord.app;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.os.Build;
import android.os.IBinder;
import android.os.PowerManager;
/**
* Mantem o app vivo enquanto ha uma chamada em andamento.
*
* Sem isto, trancar a tela derruba a call. O Android congela processos em
* segundo plano — e uma WebView congelada para de rodar o JavaScript que
* mantem a conexao do LiveKit de pe. Um servico em primeiro plano e a unica
* coisa que o sistema aceita como "este app precisa continuar rodando", e ele
* exige a notificacao permanente que aparece junto: e o preco combinado, o
* usuario sempre ve que algo esta ativo.
*
* O wake lock parcial e separado e igualmente necessario: o servico impede o
* congelamento do processo, mas nao impede a CPU de dormir.
*/
public class VoiceForegroundService extends Service {
public static final String ACTION_START = "com.resenhacord.app.VOICE_START";
public static final String ACTION_STOP = "com.resenhacord.app.VOICE_STOP";
public static final String ACTION_HANGUP = "com.resenhacord.app.VOICE_HANGUP";
public static final String EXTRA_CHANNEL = "channel";
private static final String CHANNEL_ID = "voice_call";
private static final int NOTIFICATION_ID = 1;
private PowerManager.WakeLock wakeLock;
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String action = intent != null ? intent.getAction() : null;
if (ACTION_STOP.equals(action)) {
stop();
return START_NOT_STICKY;
}
if (ACTION_HANGUP.equals(action)) {
// O botao da notificacao nao desliga a chamada sozinho: quem sabe
// como sair da sala e o app web. Avisamos o JavaScript e deixamos
// ele encerrar; o servico para quando o JS confirmar a saida.
VoicePlugin.emitHangup();
return START_NOT_STICKY;
}
String channel = intent != null ? intent.getStringExtra(EXTRA_CHANNEL) : null;
startForegroundWithNotification(channel);
acquireWakeLock();
return START_STICKY;
}
private void startForegroundWithNotification(String channelName) {
createNotificationChannel();
Intent open = new Intent(this, MainActivity.class);
open.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent openPending = PendingIntent.getActivity(
this, 0, open, PendingIntent.FLAG_IMMUTABLE);
Intent hangup = new Intent(this, VoiceForegroundService.class);
hangup.setAction(ACTION_HANGUP);
PendingIntent hangupPending = PendingIntent.getService(
this, 1, hangup, PendingIntent.FLAG_IMMUTABLE);
String text = channelName != null && !channelName.isEmpty()
? channelName
: getString(R.string.voice_notification_text);
Notification notification = new Notification.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.voice_notification_title))
.setContentText(text)
.setSmallIcon(R.drawable.ic_stat_voice)
.setContentIntent(openPending)
.addAction(new Notification.Action.Builder(
null, getString(R.string.voice_notification_hangup), hangupPending).build())
.setOngoing(true)
.setUsesChronometer(true)
.build();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// O tipo e obrigatorio a partir do Android 14: sem ele o sistema
// recusa o servico e derruba o app com uma excecao.
startForeground(NOTIFICATION_ID, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE);
} else {
startForeground(NOTIFICATION_ID, notification);
}
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationManager manager = getSystemService(NotificationManager.class);
if (manager.getNotificationChannel(CHANNEL_ID) != null) return;
// Importancia baixa de proposito: a notificacao existe porque o sistema
// exige, nao para chamar atencao. Som ou vibracao aqui tocariam a cada
// entrada em call, por cima da propria chamada.
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
getString(R.string.voice_channel_name),
NotificationManager.IMPORTANCE_LOW);
channel.setDescription(getString(R.string.voice_channel_description));
channel.setShowBadge(false);
manager.createNotificationChannel(channel);
}
private void acquireWakeLock() {
if (wakeLock != null && wakeLock.isHeld()) return;
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Resenhacord::voice");
// Sem timeout infinito: se o app morrer de um jeito que nao passe pelo
// stop(), o sistema solta o lock sozinho depois de duas horas em vez de
// segurar a CPU para sempre.
wakeLock.acquire(2 * 60 * 60 * 1000L);
}
private void releaseWakeLock() {
if (wakeLock != null && wakeLock.isHeld()) {
wakeLock.release();
}
wakeLock = null;
}
private void stop() {
releaseWakeLock();
stopForeground(true);
stopSelf();
}
@Override
public void onDestroy() {
releaseWakeLock();
super.onDestroy();
}
}
@@ -0,0 +1,66 @@
package com.resenhacord.app;
import android.content.Intent;
import android.os.Build;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.lang.ref.WeakReference;
/**
* Ponte entre o app web e o servico de chamada.
*
* O app web e quem sabe quando ha uma call: ele fala com o LiveKit. O lado
* nativo e quem sabe pedir ao Android para nao congelar o processo. Este plugin
* so liga as duas pontas.
*/
@CapacitorPlugin(name = "VoiceForeground")
public class VoicePlugin extends Plugin {
/**
* Referencia fraca de proposito: o servico precisa avisar o JavaScript
* quando alguem toca em "Sair" na notificacao, mas ele nao pode ser o
* motivo de a instancia do plugin (e a Activity presa a ela) continuar
* viva depois de destruida.
*/
private static WeakReference<VoicePlugin> instance = new WeakReference<>(null);
@Override
public void load() {
instance = new WeakReference<>(this);
}
@PluginMethod
public void start(PluginCall call) {
Intent intent = new Intent(getContext(), VoiceForegroundService.class);
intent.setAction(VoiceForegroundService.ACTION_START);
intent.putExtra(VoiceForegroundService.EXTRA_CHANNEL, call.getString("channel"));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getContext().startForegroundService(intent);
} else {
getContext().startService(intent);
}
call.resolve();
}
@PluginMethod
public void stop(PluginCall call) {
Intent intent = new Intent(getContext(), VoiceForegroundService.class);
intent.setAction(VoiceForegroundService.ACTION_STOP);
getContext().startService(intent);
call.resolve();
}
/** Chamado pelo servico quando o botao "Sair" da notificacao e tocado. */
static void emitHangup() {
VoicePlugin plugin = instance.get();
if (plugin != null) {
plugin.notifyListeners("hangup", new JSObject());
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Icone pequeno da notificacao. O Android descarta a cor e usa so o formato
(a partir do Lollipop ele e pintado de branco sobre a silhueta), entao aqui
vale desenho solido e simples — um microfone.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z"/>
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Tela de abertura como camadas em vez das splash.png que o Capacitor gera para
cada densidade e orientacao. Duas razoes: a cor de fundo fica exata em
qualquer aparelho (as PNGs padrao sao claras e piscavam branco antes da
WebView pintar), e a marca escala sozinha sem manter doze arquivos.
-->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/brand_background" />
<item
android:drawable="@drawable/brand_mark"
android:gravity="center"
android:width="128dp"
android:height="128dp" />
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Some files were not shown because too many files have changed in this diff Show More