Commit Graph

2060 Commits

Author SHA1 Message Date
Jeff Emmett d3e65a0522 feat(canvas): shared CanvasInteractionController for rApp mini-canvases
CI/CD / deploy (push) Successful in 3m38s Details
Extract the rSpace main canvas wheel + touch interaction model into a
single shared controller (shared/canvas-interaction.ts). Wheel defaults
to pan, Ctrl/Cmd+wheel or trackpad pinch (ctrlKey) zooms at cursor,
two-finger touch pans + pinch-zooms at gesture center.

Migrate 8 rApp mini-canvases to use it, replacing 8 slightly-different
hand-rolled wheel handlers:
- rsocials campaign-planner (the bug report — was zoom-only)
- rsocials campaign-workflow
- rminders automation canvas (was zoom-only)
- rtime timebank weave
- rflows canvas
- rgov circuit
- rnetwork CRM graph (was zoom-only)
- lib/applet-circuit-canvas (was zoom-only)

Fixes the "wheel = zoom" regression everywhere and gives each rApp
canvas two-finger touch pan + pinch for free. Pointer-based pan and
marquee selection remain per-rApp because they depend on per-rApp
hit-testing. Bump asset cache versions for all affected routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 15:07:32 -04:00
Jeff Emmett 02889ce3ec merge(dev): campaign canvas pan/zoom fix
CI/CD / deploy (push) Successful in 3m48s Details
2026-04-17 14:37:35 -04:00
Jeff Emmett 04e38088b7 fix(rsocials): campaign canvas wheel = pan, Ctrl/pinch = zoom
Align /rsocials/campaign canvas with main rspace canvas behavior:
two-finger scroll now pans; Ctrl/Cmd+wheel or trackpad pinch
(ctrlKey) zooms at cursor. Previously any wheel event zoomed.
Sibling folk-campaign-workflow.ts already used this pattern.
Bump asset cache to v=4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:37:31 -04:00
Jeff Emmett dc87eeb91f feat(rsocials): two-way planner↔Postiz sync with drift detection
Outbound: forward per-node platformSettings + title through
createPost/createThread via new PostizIntegrationRef.settings/title
merged on top of defaultSettingsFor. Record postizSyncedHash on the
node after each successful send.

Inbound: postizReconcile pulls remote content/publishDate onto queued
and draft nodes when the local hash matches postizSyncedHash, so
Postiz-side edits surface in the planner without clobbering pending
local edits. Published posts always take remote content as final.

Drift→Resync: inline editor shows a yellow "Local edits not yet on
Postiz" row when client SHA-1 (normalized content+title+schedule+tags+
platformSettings) drifts from postizSyncedHash. New
/resync-postiz route deletes the Postiz-side post (via new deletePost
helper, 404-tolerant) then re-creates with current content. Rejected
once published.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:36:56 -04:00
Jeff Emmett 0be5e15c22 merge(dev): rsocials per-platform post fields + auth headers
CI/CD / deploy (push) Successful in 3m44s Details
2026-04-17 14:16:20 -04:00
Jeff Emmett 98f554888c feat(rsocials): per-platform post fields, media uploads, auth on campaigns dashboard
Extend campaign planner with platform-aware post composition: optional
title (YouTube/TikTok/Reddit/Pinterest), mediaUrls attachments, and
per-platform settings overrides merged into Postiz __type. New
platform-specs.ts library encodes platform constraints. Bump asset
cache version to v=3.

Campaigns dashboard now sends Authorization headers on flow fetch/create
and surfaces 401/403 with a clear message instead of silently failing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:16:12 -04:00
Jeff Emmett 83671d3e35 merge(dev): redesign space invitation email
CI/CD / deploy (push) Successful in 3m40s Details
2026-04-17 13:46:52 -04:00
Jeff Emmett 08326ea834 feat(invites): redesign space invitation email with prominent CTA
Unify space invite emails across three code paths (new-user identity
invite, existing-user invite, add-by-username, plus notification-service
fallback). Template mentions the space name + rSpace, lists shared
collaboration tools, and shows a large gradient CTA button. Pass
spaceName through from server/spaces.ts so the display uses the
friendly name (e.g. "Crypto Commons") instead of the slug.

Also fix a bug in notification-service where actionUrl starting with
http would be double-prefixed with https://{slug}.rspace.online.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:46:34 -04:00
Jeff Emmett 8c99e640b8 Merge branch 'dev'
CI/CD / deploy (push) Successful in 3m45s Details
2026-04-17 13:16:36 -04:00
Jeff Emmett 7b70865a81 fix(rsplat): inline resized image as data URI for Hunyuan3D
fal.ai was returning "invalid images found in the input, failed to
read/process" because it couldn't reliably fetch the staged image URL
through Cloudflare/Traefik. Encode the resized JPEG inline so the fetch
stays inside the fal.ai request and doesn't depend on our origin being
reachable from their crawler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:16:29 -04:00
Jeff Emmett 4f987d8875 Merge branch 'dev'
CI/CD / deploy (push) Successful in 3m23s Details
2026-04-17 13:11:39 -04:00
Jeff Emmett 8b8041da9f fix(rsplat): stop double fileInput.click on mobile generate upload
Tapping inline "browse" fired both the browse handler and the bubbled
drop-area handler, causing mobile Safari to cancel and reopen the file
picker so the first selection was silently dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:11:34 -04:00
Jeff Emmett be762ba2e8 Merge branch 'dev': cross-subdomain passkey hint
CI/CD / deploy (push) Successful in 3m3s Details
2026-04-17 12:05:36 -04:00
Jeff Emmett 2ecee5b2e7 feat(auth): cross-subdomain username hint for passkey SSO
Adds a non-HttpOnly `rspace_hint` cookie scoped to .rspace.online carrying
only the public username (+ optional displayName). No JWT, session, or
credential crosses origins — WebAuthn RP ID rspace.online still drives
the actual ceremony, so moving between space subdomains requires a single
passkey tap instead of re-registering an account.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 12:05:07 -04:00
Jeff Emmett ca4a0b9503 Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m49s Details
2026-04-17 11:35:56 -04:00
Jeff Emmett c15462a37b harden(mailcow): detect silent API failures on write endpoints
Mailcow's JSON API returns HTTP 200 with a generic "Task completed" message
even when a write silently no-ops — e.g. wrong payload shape (array-wrapped
instead of {items, attr}), missing authsource, or any per-item ACL/validation
failure. The previous client only checked res.ok, so those failures looked
like successes and reconciler loops kept running without noticing.

parseMailcowResponse now:
- requires HTTP 2xx (unchanged)
- reads the body (array or object) and throws on type=danger/error
- requires a specific per-item success marker (alias_added, mailbox_modified,
  etc.) — the generic "Task completed" fallback indicates every item was
  silently skipped and raises a "likely silent no-op" error.

Applied to createAlias / updateAlias / deleteAlias / createMailbox /
deleteMailbox. Adds updateMailbox for password+attribute edits with the
correct {items, attr} shape and authsource gate pre-filled so new callers
can't hit the silent-swallow footgun.
2026-04-17 11:35:53 -04:00
Jeff Emmett 5528474dde Merge branch 'dev'
CI/CD / deploy (push) Successful in 3m40s Details
2026-04-17 11:24:20 -04:00
Jeff Emmett 6f8d938e1f fix(presence): keep peers online across rApp navigation in same space
- Drop beforeunload leave broadcast — was flashing users offline during
  intra-space page nav between rApps
- Heartbeat 10s → 5s; GC stale threshold 15s → 12s
- Presence payload now carries userId; overlay keys peer map by userId
  so reconnects with a new WS peerId map back to the same row
- uniquePeers() prefers entries with module/context metadata so the panel
  shows which rApp a peer is in, even alongside cursor-only awareness
2026-04-17 11:23:50 -04:00
Jeff Emmett 06b84bf2d7 fix(rsocials): list-and-match fallback to recover postizPostId after create
Postiz's POST /public/v1/posts response shape doesn't reliably include the
new post's id (varies by version). When parsing fails, query listPosts in a
narrow window right after create and match by integration+content to recover
the real id. Without this, reconcile can't find queued posts back in Postiz.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:08:47 -04:00
Jeff Emmett 6a335e4dba fix(rsocials): include Postiz platform-specific settings defaults
Postiz's POST /public/v1/posts validates required per-platform fields on
settings (e.g. X needs who_can_reply_post). Added defaultSettingsFor()
covering x, linkedin, instagram, youtube.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:03:21 -04:00
Jeff Emmett b2fbe7ded4 fix(rsocials): postiz sweep skips nodes with any postizStatus set
Earlier version only skipped when postizPostId was set. If a send attempt
failed (no postizPostId but postizStatus='failed'), the sweep retried every
60s and hammered Postiz's throttler. Retries now require the user to clear
postizStatus — same gate as a fresh node.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:56:23 -04:00
Jeff Emmett 1cde95f3fc fix(rsocials): Postiz createPost/createThread use nested posts[] shape
Postiz's /public/v1/posts expects a nested envelope:
  { type, date, shortLink, tags, posts: [{ integration:{id}, value:[{content}], settings:{__type} }] }

The earlier shim's flat {content, integrationIds, type, scheduledAt} was
rejected with "All posts must have an integration id" (the field is named
integration, not integrationIds; it's a nested object, not an array of strings).

Also: Postiz treats PostItem.value[] as thread segments, so createThread now
sends ONE request with multiple value entries rather than N grouped posts.

Callers now pass full {id, identifier} tuples so settings.__type is set
correctly (otherwise Postiz can't route to the provider handler).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:49:18 -04:00
Jeff Emmett 878646a399 fix(rsocials): Postiz public API uses raw key + /api prefix, not Bearer
Postiz's Nest backend expects Authorization: <apiKey> (no "Bearer"
prefix) and the public API is reverse-proxied at
<hostname>/api/public/v1/*. Our client was hitting /public/v1/*
directly and using Bearer, which 401'd.

Tolerate both config.url shapes (with or without /api) so older
module settings that point at the root host keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:40:17 -04:00
Jeff Emmett 5c32119946 feat(rsocials): auto-push scheduled posts to Postiz + reconcile published state
Per-post "Schedule to Postiz" button on Post nodes (rSpace → Postiz), a 60s
server sweep that auto-pushes Scheduled posts within a 10min lead window,
and a Postiz → rSpace reconcile poll that flips postizStatus to
'published' (or 'failed') and records publishedAt + releaseURL.

- schemas: PostNodeData gains postizPostId, postizIntegrationId,
  postizStatus, postizError, postizSentAt, postizCheckedAt,
  postizReleaseURL, publishedAt.
- postiz-client: listPosts(startDate, endDate) for reconciliation.
- mod.ts: sendCampaignNodeToPostiz() helper, POST /api/campaign/flows/
  :flowId/nodes/:nodeId/send-postiz, postizSweep() + postizReconcile()
  wired into onInit via startPostizScheduler.
- folk-campaign-planner: button + status badge in Post inspector,
  timeline/table dot colors reflect postizStatus (queued=purple,
  published=green, failed=red). Timeline bucket stays by scheduledAt.
- campaign-planner.css: postiz state pill styles.
- Bump campaign-planner JS/CSS to ?v=2 to bust CF cache.

Mock-Postiz smoke against the client lib passes 20/20 assertions.
Live Postiz round-trip pending deploy to Netcup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:28:26 -04:00
Jeff Emmett 97a61d6bec Merge branch 'dev'
CI/CD / deploy (push) Successful in 3m15s Details
2026-04-17 10:27:58 -04:00
Jeff Emmett 1471d1d578 fix(passkey): derive WebAuthn user.id deterministically to stop duplicates
Retries of /api/register/start previously generated a fresh random user.id
each time, so the authenticator (iCloud Keychain, Windows Hello, 1Password,
etc.) stored a brand-new passkey per attempt. Users who hit the failing
registration flow ended up with three or four orphan passkeys in their
password manager for every successful one.

WebAuthn spec: a create() ceremony with the same (rpId, user.id) overwrites
the existing passkey. Deriving user.id as SHA-256(salt + username) means
repeated start calls for the same username produce the same user.id and the
authenticator overwrites in place.

Salt chain: USER_ID_SALT → JWT_SECRET → fallback constant. No new env var
needed in prod — JWT_SECRET is already set.
2026-04-17 10:27:55 -04:00
Jeff Emmett 609bae45f5 Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m59s Details
2026-04-17 10:18:01 -04:00
Jeff Emmett 43d68fd521 fix(rsocials+spaces): merge Posts button; redact password settings from module GET
- rsocials: collapse dead "posts" outputPath into "threads" entry (💬 Posts)
  so the sub-nav shows one button linking to the thread gallery + builder
- spaces GET /:slug/modules: redact settingsSchema fields of type 'password'
  to '********' for non-owner callers; PATCH preserves existing password
  when the sentinel is sent back unchanged

Closes the leak where unauth'd GET returned Listmonk/Postiz credentials in plaintext.
2026-04-17 10:17:10 -04:00
Jeff Emmett 03d2a6594d Merge branch 'dev'
CI/CD / deploy (push) Has been cancelled Details
2026-04-17 10:14:57 -04:00
Jeff Emmett 93a63cd42b fix(register): reject duplicate usernames before passkey prompt
Friend hit a 500 on /api/register/complete because the Postgres unique
constraint fired after the WebAuthn ceremony — they'd already burned a
passkey creation by the time the server refused. Pre-check the username in
/api/register/start so the join page shows "Username is already taken"
before the browser prompts. Also catch the 23505 duplicate-key error in
/api/register/complete as a race-condition safety net.
2026-04-17 10:14:54 -04:00
Jeff Emmett 837ecf4ba0 Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m31s Details
2026-04-17 10:09:54 -04:00
Jeff Emmett 5bb46afe6d fix(invite-claim): send publicKey + JSON error responses
The /join and OIDC accept pages were calling /api/register/complete without
the public key extracted from the WebAuthn attestation. storeCredential writes
a credentials row with a NOT NULL public_key column, so the request crashed
with an unhandled exception and Hono replied "Internal Server Error" as plain
text — the client JSON.parse choked on it.

- Extract credential.response.getPublicKey() on the client and include it in
  the completion payload (both /join and /oidc/accept flows).
- Add app.onError to return JSON 500s on /api/* routes so any future crashes
  produce parseable error bodies instead of cascading into confusing "Unexpected
  token 'I'" errors in the browser.
2026-04-17 10:09:52 -04:00
Jeff Emmett a99c2eb56e Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m42s Details
2026-04-17 10:05:55 -04:00
Jeff Emmett 0a896f5740 security(encryptid): gate /api/internal/* against public reach
The /api/internal/* routes (user lookups, space invites, fund claims, agent
mailbox creds) were publicly reachable via auth.rspace.online because Traefik
forwards everything under the encryptid host rule. They were designed for
service-to-service calls over the Docker network only.

Add a Hono middleware that rejects /api/internal/* with 404 when the request
arrives through the public edge (X-Forwarded-For / X-Real-IP set by Traefik)
unless it carries a valid X-Internal-Key matching INTERNAL_API_KEY. Direct
container-to-container fetches don't set forwarded headers so existing
callers keep working untouched.
2026-04-17 10:05:49 -04:00
Jeff Emmett 48c86c6c1f Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m40s Details
2026-04-17 09:53:59 -04:00
Jeff Emmett cfe060dc61 fix: invite UX — hide disabled modules, resolve usernames, show invites list, require userId on claim
- rApp dropdown no longer shows a "Manage rApps" panel for disabled modules.
  Enable/disable lives in Edit Space → Modules only.
- /api/internal/user-email/:userId now resolves by id OR did (real did🔑z6Mk…
  or legacy synthetic did🔑<32-char id prefix>), so Edit Space members list
  shows display names instead of raw DIDs.
- /api/spaces/:slug/invites admin check now accepts ownerDID stored as either
  raw userId or did🔑. Routes the list through a new internal endpoint on
  encryptid so Automerge-owned spaces without a space_members row still work.
- /join page registration payload was missing userId, causing every invite
  claim to fail with "Missing required fields: userId, credential, username".
  Same fix applied to the OIDC accept page flow.
2026-04-17 09:53:53 -04:00
Jeff Emmett c6ae38d0d7 Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m51s Details
2026-04-17 09:37:15 -04:00
Jeff Emmett 12b5938248 fix(email): align SMTP envelope with From header for DMARC pass
Space-agent and mailbox emails set From to {slug}-agent@rspace.online but
the envelope MAIL FROM was noreply@rmail.online. Rspamd DKIM-signs with
the envelope domain (rmail.online), which fails DMARC alignment against
the rspace.online From header — Gmail was quarantining invites.

Set envelope.from to match the From header so rspamd signs with
rspace.online's DKIM key and SPF/DKIM align. Verified via test send:
DKIM_SIGNED{rspace.online:s=dkim}, FROM_EQ_ENVFROM, no DMARC:Quarantine.

Covers: space invite emails (identity + existing-user paths), agent
notifications, rinbox MI agent blasts, mailbox approval sends.
2026-04-17 09:36:36 -04:00
Jeff Emmett 9537c9227c Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m7s Details
2026-04-16 19:18:38 -04:00
Jeff Emmett 97141560ef chore(backlog): follow-ups from 2026-04-16 deployment session
5 new backlog tasks covering loose ends from tonight's rTasks canvas +
Sablier sidecar migration + infra cleanup work:

- MEDIUM.8: create 5 sidecar containers on Netcup
- MEDIUM.9: wire ollama into sidecar lifecycle (currently no-op)
- MEDIUM.10: roll canvas-with-widgets UX to remaining rApps
- LOW.1: Netcup memory pressure audit
- LOW.2: version-control deploy of /opt/scripts/
- LOW.3: Sablier-for-encryptid (retargeted/deferred)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 19:18:35 -04:00
Jeff Emmett d13a09208b Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m21s Details
2026-04-16 19:08:36 -04:00
Jeff Emmett dcd6e1a261 chore(infra): drop /var/run/docker.sock mount from rspace container
No longer needed — sidecar lifecycle is delegated to Sablier via HTTP API
(see ee251fd). Removes an unnecessary Docker socket exposure from the
main app container.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 19:08:33 -04:00
Jeff Emmett b3c7fd28ee Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m40s Details
2026-04-16 19:00:33 -04:00
Jeff Emmett 03e96c215a chore(backlog): TASK-MEDIUM.7 — Sablier sidecar migration (Done)
Completion notes: rewritten sidecar-manager delegates to Sablier HTTP API,
sablier joined rspace-internal network, deployed image 000ee0da verified
on demo.rspace.online.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 19:00:29 -04:00
Jeff Emmett 000ee0da9a Merge branch 'dev'
CI/CD / deploy (push) Failing after 3m10s Details
2026-04-16 18:50:15 -04:00
Jeff Emmett ee251fd621 refactor(sidecar): delegate lifecycle to Sablier instead of Docker socket
Replaces the custom Docker Engine API implementation in sidecar-manager.ts
with HTTP calls to Sablier's blocking strategy endpoint. Sablier owns the
Docker socket, handles start + readiness + session-TTL idle stop.

- Drops ~80 lines of Docker API plumbing and the idle-watcher interval
- Public API (ensureSidecar/markSidecarUsed/isSidecarRunning/startIdleWatcher)
  unchanged — callers in server/index.ts untouched
- SABLIER_URL defaults to http://sablier:10000 (reachable once sablier is
  attached to rspace-online_rspace-internal; dev-ops change separate)
- SIDECAR_SESSION_DURATION env (default 5m) matches previous idle timeout
- Graceful no-op when Sablier unreachable (local dev)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 18:49:52 -04:00
Jeff Emmett 8dc1c11227 Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m16s Details
2026-04-16 18:42:51 -04:00
Jeff Emmett 7ada95b46a fix(infra): sablier-support must be no-op until traefik wiring is ready
Copying the sablier-encryptid.yml labels would immediately apply
traefik.enable=false to the live encryptid container, breaking auth
before Sablier has a middleware route in place. Reduced to empty
services:{} with a comment explaining what's needed to activate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 18:42:44 -04:00
Jeff Emmett a855e698be Merge branch 'dev'
CI/CD / deploy (push) Has been cancelled Details
2026-04-16 18:42:17 -04:00
Jeff Emmett 4f2aebe46e chore(infra): add sablier-support compose override referenced in .env
.env on Netcup sets COMPOSE_FILE=docker-compose.yml:docker-compose.sablier-support.yml
but the file was never created, causing docker compose commands to fail
without explicit -f flag. Adds the missing override with Sablier labels for
encryptid services (matching the existing sablier-encryptid.yml pattern).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 18:42:09 -04:00