diff --git a/docs/architecture-audit-2026-07-28/CloudOrgInviteDeepLinks.md b/docs/architecture-audit-2026-07-28/CloudOrgInviteDeepLinks.md new file mode 100644 index 0000000000..608f56be38 --- /dev/null +++ b/docs/architecture-audit-2026-07-28/CloudOrgInviteDeepLinks.md @@ -0,0 +1,98 @@ +# Cloud organization invite deep-link audit + +## Scope and acceptance criteria + +This audit covers the organization-invite path from link creation through the +HTTPS handoff page, native app activation, authentication, invite acceptance, +and organization refresh. + +The feature is accepted when: + +1. A newly created invite is copied as a clickable HTTPS URL. +2. The plaintext invite capability stays in the URL fragment and is not sent + to the handoff web host. +3. The handoff opens `orgii://cloud/join?invite=…` and preserves direct native + links for backward compatibility. +4. Cold-start and already-running app paths both deliver the invite to the + same frontend owner. +5. Signed-out users can authenticate without losing the pending invite. +6. Success refreshes membership and clears the pending invite; backend errors + remain visible and retryable. + +## Lifecycle and ownership + +| Stage | Authoritative owner | State transition | +| --- | --- | --- | +| Create | `createCloudInvite` | Generate 32 random bytes locally, send only the SHA-256 hash to `create_invite`, return the plaintext once | +| Share | `buildCloudInviteLink` | Encode the plaintext as `#invite=` on the fixed HTTPS handoff origin | +| Handoff | deployed `orgii-invite` site | Validate a 64-character hex capability and launch the native `orgii://cloud/join` URL | +| OS delivery | Tauri deep-link plugin | Deliver cold-start URLs through `getCurrent` and live URLs through `onOpenUrl` | +| Warm-instance recovery | Tauri single-instance plugin | Forward Windows/Linux deep-link argv before the callback, then restore or recreate the main window | +| Route | `useDeepLinkHandler` | Parse the native URL, set `org2CloudPendingInviteAtom`, and open Workstation | +| Authenticate | `JoinCloudOrgDialog` | Keep the pending capability while the user signs in | +| Accept | `acceptCloudInvite` | Hash the plaintext locally, call `accept_invite`, and refetch until membership is visible | +| Terminal | `JoinCloudOrgDialog` | Clear pending state on success or explicit dismissal; retain it on retryable failure | + +The frontend hook is the single routing owner. Rust restores application +availability but does not parse, persist, or log invite capabilities. + +## State and edge-case matrix + +| Condition | Expected behavior | Coverage/evidence | +| --- | --- | --- | +| New generated link | HTTPS link contains `#invite=` and no query | Unit test | +| Existing direct native link | Parsed and routed unchanged | Existing parser/hook tests | +| Legacy HTTPS query link | Accepted when the fixed origin/path match | Unit test and handoff-site test | +| Fragment and query both present | Non-empty fragment wins | Unit test and handoff-site test | +| Empty fragment invite plus query | Query fallback is used | Unit test | +| Foreign HTTP(S) or other scheme | Rejected instead of treated as a raw code | Unit test | +| App cold start | `getCurrent` drains the initial native URL | Code-path audit; manual acceptance | +| App already running | `onOpenUrl` receives the forwarded URL and the window is restored | Plugin source/config audit; manual acceptance | +| User signed out | Pending invite remains while login is requested | Dialog state-path audit | +| Invite valid | Membership refreshes, organization becomes selectable, pending state clears | Manual acceptance with a second account | +| Invite exhausted | Backend error remains visible and retryable | Manual acceptance | +| Duplicate join click | Join control is disabled while acceptance is in flight | Dialog state-path audit | + +## Architecture audit + +| Layer | Verdict | Evidence | +| --- | --- | --- | +| 1. Compile and baseline | Pass | Targeted ESLint, TypeScript typecheck, 71 frontend tests, and `cargo check -p org2` pass | +| 2. Structural uniqueness | Pass | One HTTPS builder, one native parser, one app-lifetime routing hook, and one pending-invite atom | +| 3. Naming and semantic clarity | Pass | “HTTPS handoff link,” “native deep link,” plaintext “invite code,” and on-wire “invite hash” remain distinct | +| 4. Type/domain soundness | Pass | Generated invite codes retain the 32-byte/64-hex invariant; link parsing returns `null` on invalid URL boundaries | +| 5. Branch/default behavior | Pass | Missing capability, foreign origin/path, missing window, sign-out, backend failure, and success each have explicit outcomes | +| 6. Domain ownership | Pass | Cloud invite parsing/building stays in `Org2Cloud`; Rust owns only process/window lifecycle | +| 7. Developer clarity | Pass | Comments explain fragment privacy, compatibility parsing, plugin order, and why argv must never be logged | +| 8. Wire/storage contracts | Pass | Only SHA-256 hashes cross management RPCs; the plaintext exists only in the share URL/native delivery path | +| 9. Initialization parity | Pass | Cold `getCurrent`, warm `onOpenUrl`, and second-process forwarding converge on `routeToCloudJoin` | +| 10. Data-shape alignment | Pass | App and handoff page use the same fragment-first/query-fallback precedence | + +The full `cargo fmt --check` command reports pre-existing formatting +differences in unrelated `agent_sessions` files on the `develop` baseline. +The changed Rust file passes a scoped `rustfmt --check`, and no unrelated +formatting changes are included. + +## Verification + +- `pnpm vitest run` on invite management, management client, deep-link handler, + and billing-completion suites: 4 files, 71 tests passed. +- Targeted ESLint on all changed TypeScript/TSX files: passed. +- `pnpm run typecheck`: passed. +- `cargo check --manifest-path src-tauri/Cargo.toml -p org2`: passed. +- Deployed handoff source: lint passed, production build passed, 5 tests passed. +- Manual acceptance: owner generated an invite for `ORG2-Invite-Test`; a + different signed-in account opened the HTTPS link and joined successfully. + An exhausted invite displayed the backend “no uses left” error. + +## Remaining risks and non-goals + +- The handoff site is deployed from a separate site repository, so its + production URL is an external runtime dependency rather than part of this + application PR. +- Custom-scheme registration still requires an installed build containing the + `orgii` scheme; browser-only joining and app-store installation fallback are + not part of this change. +- Automated WebDriver tests enter at the parsed deep-link boundary because + they cannot generate an operating-system URL-open event. Cold/warm OS + dispatch therefore retains manual acceptance coverage. diff --git a/docs/architecture-audit-2026-08-10/PR552-CloudInviteHttpsHandoff.md b/docs/architecture-audit-2026-08-10/PR552-CloudInviteHttpsHandoff.md new file mode 100644 index 0000000000..cdd82dbfc7 --- /dev/null +++ b/docs/architecture-audit-2026-08-10/PR552-CloudInviteHttpsHandoff.md @@ -0,0 +1,184 @@ +# PR 552 audit — org invites via HTTPS deep-link handoff + +Audited head: `cc413d26e` (merge of develop into `junyu/deep-link-org-joining`), +diff vs `origin/develop` (merge-base `d26e2dd7d`), 7 files +197/−26. +Auditor worktree: `~/Projects/orgii-wt-pr552`. Live dual-instance results in +`PR552-dual-instance-live-test.md` (same folder). + +## Verdict summary + +| # | Severity | Finding | Status | +| --- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | +| 1 | P1 | Rendered dual-instance E2E invite path breaks: spec asserts `orgii://cloud/join?invite=` prefix on the rendered link | fix required | +| 2 | P1 | Invite capability handoff rides a third-party-controlled origin (`atah2000.chatgpt.site`) | product decision needed before release | +| 3 | P2 | Handoff site enforces `/^[0-9a-f]{64}$/i` + lowercases; app web-link parser accepts any non-empty code — contract drift across repos | recommend symmetric validation | +| 4 | P2 | Join-dialog placeholder copy (13 locales) still says "paste orgii:// invite link or code" | copy sweep | +| 5 | P3 | Single-instance plugin now active in ALL builds; wdio primary shares `yorg.orgii` with the real app — suite must not run while the real app runs | document | +| 6 | P3 | `pathname.replace(/\/+$/, "/")` normalization only correct while the base path is `/` | nit | +| 7 | P3 | Session share links stay `orgii://` while invites moved to HTTPS — asymmetric shareability | product note | + +No blocking correctness defect found in the shipped code paths themselves; the +P1s are a test-surface regression and a trust-boundary decision. + +## Findings + +### 1. P1 — dual-instance E2E invite flow now fails (invisible to CI) + +`tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs`: + +- `createInviteFromOwner` (line ~691) waits until the rendered + `cloud-org-invite-link` text `startsWith("orgii://cloud/join?invite=")` — + after this PR the UI renders the HTTPS handoff link, so the waitUntil times + out ("owner invite plaintext did not refresh"). +- Line ~1242 hard-throws `rendered team invite is not a valid orgii join link`. +- Even with the prefix check fixed, the copied link is fed to + `cloudSeedPendingInvite` (`src/app/root/e2e/helpers/cloud.ts:650`), which + runs `parseCloudInviteDeepLink` only and rejects HTTPS links by design. + +CI runs vitest only; the rendered wdio surface is exactly where the PR's own +audit doc says OS-dispatch coverage lives. Fix candidates: assert the new +`CLOUD_INVITE_WEB_BASE_URL#invite=` shape, derive the `orgii://` seed link via +the production `parseCloudInviteInput`, or extend the seed helper with an +explicit web-link arm (keeping the deep-link-parser-only arm for the handler +fidelity cell). + +### 2. P1 — capability handoff through a third-party-controlled origin + +`CLOUD_INVITE_WEB_BASE_URL = "https://orgii-invite-link.atah2000.chatgpt.site/"` +(`org2CloudOrgManagement.ts:70`). The invite capability lives in the URL +fragment, so it is never sent over HTTP — but the fragment IS readable by the +JavaScript served from that origin, and the origin is a personal subdomain +outside org2AI/yorgai control. Whoever controls (or later acquires) it can +harvest every invite capability at click time and join the org. + +Deployed code inspected 2026-08-10 and currently clean: +`assets/InviteLauncher-*.js` parses fragment-first/query-fallback, validates +`/^[0-9a-f]{64}$/i`, then `window.location.assign("orgii://cloud/join?invite=…")` +after ~450 ms; the only `fetch` hits are image `fetchPriority` internals; no +beacon/analytics/websocket. But the app pins trust to the ORIGIN, not to this +code — tomorrow's deploy can differ. The URL is also hardcoded: rotating the +host requires an app release and kills every previously shared link. + +Live re-confirmation 2026-08-10 (in-app Browser, real 64-hex fragment): the +page recognized the code ("Invite code ending in 8582") and rendered the +"Open ORG2 →" handoff; network capture shows the capability never leaves the +browser — only static assets plus **Cloudflare's own challenge-platform JSD** +(`/cdn-cgi/challenge-platform/.../jsd/oneshot/...`). So today no code exfil, +but the origin is both a personal subdomain AND Cloudflare-fronted (CF script +executes in-origin and can read the fragment) — two out-of-org trust surfaces. + +Recommendation: before public release, host the page on an org-controlled +domain (e.g. the existing Vercel project) with the site source in an +org-reviewed repo. The PR body itself lists this as a risk; the audit's +position is that it should be a release blocker, not a footnote. + +### 3. P2 — invite-code contract drift between site and app + +Site: `/^[0-9a-f]{64}$/i` + `toLowerCase()` before building the deep link. +App `parseCloudInviteWebLink`: any non-empty `invite` value passes. Two +parsers of the same link now disagree: a future code-format change silently +breaks all handoff links (site renders "invalid"), while pasting the same +HTTPS link into the app dialog would still parse. Recommend the app validate +the same 64-hex invariant (defense in depth), or a comment pinning the site's +regex next to the code-generation invariant so format changes update both. + +### 4. P2 — stale placeholder copy in 13 locales + +`navigation.json` `inviteCodePlaceholder` ("貼上 orgii:// 邀請連結或邀請代碼" +etc.) predates the HTTPS link. Functionality is fine (`parseCloudInviteInput` +accepts HTTPS/orgii/raw), but the user pasting the link they actually received +is told it should look like `orgii://`. `importInputPlaceholder` +(session shares) is correctly still `orgii://` — do not sweep that one. + +### 5. P3 — single-instance semantics now apply to every build + +The plugin was previously commented out ("disabled for development"); it is now +unconditional for macOS/Windows/Linux. Verified identifiers: dev/prod +`yorg.orgii`, user instance-2 `yorg.orgii.instance2` +(`scripts/tauri/instance-profile.cjs:21`), wdio secondary +`yorg.orgii.e2e.instance2` (`tests/e2e/support/core/dualCloudHarness.mjs:162`) +— the dual harness and the dual-checkout workflow survive. But the wdio +PRIMARY keeps `yorg.orgii`: launching the suite while the real app is running +now forwards-and-exits instead of starting, which will look like a harness +timeout. Same for `pnpm tauri dev` alongside the running app. + +### 6/7. P3 nits + +- `parsed.pathname.replace(/\/+$/, "/")` collapses to `/` only because the + expected path IS `/`; if the base URL ever gains a path segment, + `/invite/` ≠ `/invite`. Normalize both sides. +- Session share links (`buildCloudSessionShareLink`) remain custom-scheme; + invites are now the only HTTPS-shareable artifact. Deliberate scope cut per + PR body; flagging for product consistency. + +## 10-layer walk (architecture-audit) + +| Layer | Verdict | Evidence | +| ----------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 Compile/baseline | Pass | worktree: 66/66 focused vitest, `tsc --noEmit` clean; CI clippy green | +| 2 Structural uniqueness | Pass | one builder (`buildCloudInviteLink`, sole caller `createCloudInvite`), one web parser, one native parser; `isCloudInviteDeepLink` only consumed by `parseCloudInviteDeepLink` | +| 3 Naming | Pass w/ note | code/comments consistent; stale copy is i18n (finding 4) | +| 4 Type/domain | Pass w/ note | 64-hex invariant enforced at generation but not at web-link parse (finding 3) | +| 5 Branch/default | Pass | `://` guard closes the raw-code fallthrough for foreign schemes (improvement over baseline); http→https origin mismatch rejects downgrade links | +| 6 Domain ownership | Pass | Rust owns process/window lifecycle only; argv never parsed in Rust; routing stays in `useDeepLinkHandler` | +| 7 New-dev clarity | Pass | comments explain fragment privacy + plugin order | +| 8 Wire/serialization | Pass | only SHA-256 crosses RPC (unchanged); deployed handoff JS dumped and inspected — no exfil path today (finding 2 caveat: trust is origin-pinned) | +| 9 Init parity | Pass (live-verified) | cold `getCurrent`, warm `onOpenUrl`, second-process argv forward, and `RunEvent::Reopen` window recovery converge on the same frontend owner; e2e enters via seeded atom (parity gap = finding 1) | +| 10 Data-shape alignment | Pass | app and site precedence identical (fragment-first, empty-fragment→query); site tolerates `#/invite=` prefix which the app never emits | + +Live UI addendum (2026-08-10, screen access granted after the headless pass): +the Manage Org UI renders the HTTPS handoff link (finding 1's premise) and the +join dialog still shows the `orgii://` placeholder (finding 4) — both +live-confirmed. The join dialog accepts the HTTPS link end-to-end +(`parseCloudInviteInput` → `accept_invite` RPC), and rejects both a +wrong-host `orgii://collaboration/…` link and a foreign-origin +`https://evil.example.com/#invite=<64hex>` link client-side with the friendly +invalid-invite error (layer-5 origin guard verified live). Two pre-existing +(non-PR) defects surfaced during the pass — owner self-accept consumes the +invite use and returns `role:"owner"` which the client schema rejects as a +raw Zod dump, and malformed `orgii://cloud/*` links land on the 404 +`` and re-trigger on every boot via the initial deep-link drain — +both root-caused in `PR552-dual-instance-live-test.md`. + +## Remediation on this branch (2026-08-10, post-audit) + +Branch `pr552-audit` was rebased onto develop `77330c736` and the findings +were fixed in place: + +- **Finding 1 (P1, e2e)** — FIXED. Spec asserts the + `https://invite.org2.dev/#invite=` shape; `cloudSeedPendingInvite` gained a + production-parser web-link arm (`parseCloudInviteInput`), still rejecting + raw codes. Rendered wdio run itself still requires a webdriver build. +- **Finding 2 (P1, third-party origin)** — FIXED. + `CLOUD_INVITE_WEB_BASE_URL` now `https://invite.org2.dev/`; the handoff + page source is org-reviewed in ORGII-cloud-infra `apps/invite-link/` + (self-contained static page, CSP `default-src 'none'`, verified locally: + missing/invalid/valid states, lowercase normalization, zero + invite-carrying requests, and the built deep link routes into the live + app's join dialog). Remaining ops step: create the Vercel project and + bind `invite.org2.dev`. +- **Finding 3 (P2, contract drift)** — FIXED. The app's web-link parser now + enforces the same `/^[0-9a-f]{64}$/i` + lowercase as the page + (`CLOUD_INVITE_CODE_PATTERN`, pinned by comments on both sides). +- **Finding 4 (P2, placeholder copy)** — still open (13-locale copy sweep). +- **Findings 5–7 (P3)** — unchanged, documented. + +Pre-existing defects from the live test, also fixed here: + +- **Owner/member self-accept** — server migration 0018 (cloud-infra) makes + `accept_invite` raise `ORG2_ALREADY_MEMBER` before consuming a use + (verified in a disposable Postgres: refused self-accept burns nothing, + reactivation still consumes); client maps the code to a translated + message (13 locales) and wraps response-schema failures into a friendly + error instead of the raw Zod dump. +- **Malformed `orgii://cloud/*` deep links** — now logged no-ops in both + the warm listener and the initial drain (`isUnclaimedCloudDeepLink`); + they no longer navigate to the 404 error page nor resurrect it on every + boot via `getCurrent()`. + +Rust specifics verified: `tauri-plugin-single-instance = 2.0.0 features +["deep-link"]` (Cargo.toml:540) locked at 2.4.2 (macOS-capable); plugin +registered FIRST in the builder chain as the deep-link forwarding contract +requires; window label `"main"` matches `tauri.conf.json`; +`recreate_main_window` (crates/app-window/src/lib.rs:275) is idempotent and +rebuilds from the startup config; callback logs `argument_count` only. diff --git a/docs/architecture-audit-2026-08-10/PR552-dual-instance-live-test.md b/docs/architecture-audit-2026-08-10/PR552-dual-instance-live-test.md new file mode 100644 index 0000000000..39d8188e66 --- /dev/null +++ b/docs/architecture-audit-2026-08-10/PR552-dual-instance-live-test.md @@ -0,0 +1,136 @@ +# PR 552 dual-instance live test — invite HTTPS handoff + single-instance + +Protocol: `.orgii/skills/dual-instance-verification/SKILL.md`. Builds: both +instances from worktree `~/Projects/orgii-wt-pr552` (head `cc413d26e`); +inst-1 = `ORG2.app` (yorg.orgii, data `~/.orgii`, account vinceorz), +inst-2 = `ORG2 Instance 2.app` (yorg.orgii.instance2, data +`~/.orgii-instance2`, account vinceorz418). Scheme note: `orgii://` routes to +inst-1 only (inst-2 claims `orgii-instance2://`), so inst-2 exercises the +paste-into-join-dialog arm — which is precisely the new +`parseCloudInviteInput` HTTPS path. + +## Environment constraint (shapes the whole run) + +Screen-control access was denied for the first (headless) pass, then granted +by the user; the UI-click cells (U1–U4 below) were completed in a second pass. +Both instances were signed in to the **same** cloud identity +(`vinceorz@hotmail.com`, sub `394af2b7`) throughout — so a real cross-account +"second account joins" flow was not possible (you cannot accept your own org's +invite; entering credentials for a second account is outside what the agent +may do). The headless pass targets exactly the code paths PR 552 _changes_ — +the Rust single-instance plugin, the OS `orgii://` deep-link dispatch/routing, +the server invite RPC round-trip, and the deployed handoff site — driven via +`open`, service-key RPC, log/ledger inspection, and in-app Browser network +capture. Cross-account-accept remains UNCOVERED below with reason. + +## Cell matrix (result) + +| Cell | Scenario | Result | +| ------ | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| B | Warm deep link `orgii://cloud/join?invite=` → running inst-1 | **PASS** — frontend `[DeepLinkHandler] Routing ORG2 Cloud invite into join confirmation`; process set unchanged (no stale bundle launched) | +| C | Second process (`open -n` of the exact worktree bundle) forwards + exits | **PASS** — 2nd pid 31268 forwarded and exited; sole survivor = inst-1; backend `external open request forwarded to the running app argument_count=1` (count only) | +| F | Cold start: quit inst-1, `open -a orgii://…invite=` | **PASS** — new pid 32531 cold-booted; frontend `Routing initial ORG2 Cloud invite into join confirmation` (the `getCurrent` drain path) | +| M | Fault: `orgii://cloud/join` (no invite), `?invite=` (empty), `cloud/nonsense` | **PASS with baseline caveat** — all received, **zero** "Routing … invite" lines, no process crash, process set stable. But malformed `cloud/*` links are not fully no-op: they fall through to the generic `parseDeepLink` conversion and navigate to `/orgii/cloud/…`, which no real route matches, so the router's 404 catch-all renders `` ("Something went wrong"). Root-caused below — **baseline**, not PR 552 | +| N | Log privacy: plaintext code in any log | **PASS** — code `624f…8582` absent from every inst-1/inst-2 frontend+backend log; argv logged as count only | +| O | Destructive-effect verb audit (both instances, test window) | **PASS** — only hit was a routine housekeeping GC (`session_cache_rows_evicted=1`), unrelated to invites | +| P | Fleet ledger before/after (1431 sessions / 192 orgs / 273 members) | **PASS** — invite flow footprint = **+1 org, +1 membership, 0 session rows**; 3 session-level deltas (1 add, 1 epoch 23→24, 1 soft-delete @16:02:21Z _before_ the test) all in the live workspace org `bfa7b134`, none in the test org, none producible by this PR's diff | +| Server | `create_org` + `create_invite` RPC round-trip | **PASS** — only the SHA-256 hash crossed the wire (`invite_code_hash`); plaintext stayed local; returned `inviteId` | +| Site | Deployed handoff page with the **real** fragment | **PASS** — recognized the code ("Invite code ending in **8582**"), rendered "Open ORG2 →"; network capture shows **no** request carrying `invite=`/the code — only static assets + Cloudflare challenge JSD | + +## UI cells (second pass, screen access granted) + +Test org: `PR552 UI Invite Test` (`aaf35493…`), created from inst-1 UI as +vinceorz (owner); invite Member / 1 use / 7 days, code `f94a…2d12c`. + +| Cell | Scenario | Result | +| ---- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| U1 | Create invite via Manage Org UI (inst-1), inspect rendered link | **PASS** — invite created; rendered link is the new HTTPS handoff form (`https://orgii-invite-link.atah2000.chatgpt.site/#invite=<64hex>`), live-confirming audit finding 1's premise; join-dialog placeholder still says `orgii://` (finding 4 live-confirmed) | +| U2 | Paste the rendered **HTTPS** link into inst-2's Add ORG → Cloud → Join dialog and submit | **PASS (PR-relevant path)** — the link is accepted by `parseCloudInviteInput`, the `accept_invite` RPC fires and the server processes it. The visible failure is a **pre-existing** edge (below): the tester owns the org, the server returns `role:"owner"`, and `AcceptInviteResponseSchema` (admin\|member) rejects the response with a raw Zod error. Round-trip through the new parse path verified end-to-end | +| U3 | Submit `orgii://collaboration/join?invite=notaninvite` (valid scheme, wrong host) | **PASS** — friendly "This invite code isn't valid." (`invalid_invite` thrown by `joinOrganization` before any RPC) | +| U4 | Submit `https://evil.example.com/#invite=<64hex>` (foreign origin, real-shaped code) from a freshly remounted form | **PASS** — fresh "This invite code isn't valid." appears from a clean no-error state; the origin check rejects client-side, no RPC. (First attempt was ambiguous because the previous error banner shows identical text; the form was remounted via Session→More→Add ORG to make the assertion clean) | + +### Pre-existing defects observed while driving the UI (NOT PR 552) + +1. **Owner self-accept consumes the invite and returns an unparseable role.** + Server-side `accept_invite` succeeded for the org owner — inst-1's Manage + Org page now shows the invite as **"Used up"** — and returned + `role:"owner"`, which the client's `AcceptInviteResponseSchema` + (`admin|member`) rejects, dumping a raw Zod error into the join dialog. + Two layered issues: the server neither rejects self-accept nor refunds the + use, and the client renders a schema dump instead of a friendly error. + Attribution: the accept path is untouched by PR 552's diff. +2. **Malformed `orgii://cloud/*` deep links land on the error page and it + survives restart.** Root cause chain (all baseline code, none of it in the + PR diff): `parseCloudInviteDeepLink`/session parser return null → generic + `parseDeepLink` (`useDeepLinkHandler.ts:108`) converts any leftover URL to + `/orgii//` → no real route matches `/orgii/cloud/…` (a valid + invite never navigates there; `routeToCloudJoin` uses a pending-invite atom + - the workstation route) → the router's 404 catch-all + (`src/router/index.tsx`, `path:"*"`) renders `` — the same + alarming "Something went wrong" screen used for real errors. Worse, the + initial-deep-link drain re-navigates on every boot (log 09:33:13 + "Navigating to initial deep link: /orgii/cloud/nonsense?x=1"), so the + error page reappears after Restart — it looks like a crash loop but is + stale-link re-delivery into a 404. The comment in `parseDeepLink` claiming + `orgii://cloud/join` "never reach[es] this generic conversion" is false + for malformed variants. `useDeepLinkHandler.ts` and `router/index.tsx` are + both absent from PR 552's changed-file list (last touched by the + session-reference commits), so this is **baseline**, surfaced — not + introduced — by the fault-injection cell. + +### UNCOVERED (with reason) + +- **Join-dialog accept with a role the schema accepts** — a genuine + member/admin accept (second account) would exercise the success render; + blocked by the single-identity constraint below. +- **Real cross-account `accept_invite`** — both instances share one cloud + identity; joining one's own org is a no-op. The accept RPC wire shape + (hash-only) is code-audited but not fired by a second member. +- **Minimized-window restore (cell D)** — could not set a truly-minimized + starting state without accessibility control; the `show`/`set_focus`/ + `unminimize` calls ran on the forward path with **no** warning logged, so + they succeeded, but a minimized→restored transition was not visually staged. +- **`RunEvent::Reopen` window recreate (cell E)** — not staged (needs closing + the last window via UI). +- **Windows/Linux argv forwarding** — macOS host only; PR body also lists this + as remaining release-bundle coverage. +- **Rendered wdio invite specs** — currently broken by audit finding 1 (they + assert the `orgii://` prefix on the rendered link); not run. + +Upgrade note: this PR persists no new durable state, so there is no +version-boundary migration cell. The compatibility surface (old-format links) +is covered by unit tests for `orgii://` + legacy HTTPS `?invite=` parsing. + +## Post-fix verification (third pass, rebuilt from the remediated branch) + +inst-1 rebuilt from `pr552-audit` after the rebase + fix commits (see the +audit report's Remediation section) and relaunched. One trap re-observed on +the way: `open` of the new bundle while the old instance still ran was +absorbed by the single-instance forward (the PR's own plugin) — all +`yorg.orgii` processes must be killed before relaunching a new build. + +| Cell | Scenario | Result | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| V1 | Warm malformed deep links (`cloud/join`, `?invite=`, `cloud/nonsense?x=1`) | **PASS** — three `Ignoring malformed ORG2 Cloud deep link` warns, **zero** new `Navigating to:` lines, UI stays on the normal workstation view (no error page) | +| V2 | Cold start WITH the malformed URL (`open -a orgii://cloud/nonsense?x=1`) — the exact resurrection scenario from the 09:33 incident | **PASS** — `Ignoring malformed initial ORG2 Cloud deep link` (×2, known double-mount), app boots to the normal UI; the restart loop is gone | +| V3 | UI create org + invite (`PR552 Fix Verify`) | **PASS** — rendered link is `https://invite.org2.dev/#invite=<64hex>` (new org-controlled domain live in the UI) | +| V4 | Owner pastes their own invite link into the join dialog | **PASS** — friendly translated "Couldn't load cloud org details" (ZodError→`unexpected_response` containment), NOT the raw Zod dump. The live server predates migration 0018, so the use was still consumed server-side; after 0018 is applied the same action returns `ORG2_ALREADY_MEMBER` → "You're already a member of this organization." and burns nothing | + +Server-side 0018 behavior was separately proven in a disposable Postgres 16 +(baseline 0001 + 0018): owner/member self-accept refused with `used_count` +untouched; fresh accept and post-removal reactivation still consume; +re-paste idempotent. + +## Cleanup + +- Test org `PR552-Invite-Test` (`49634658…`) soft-deleted via `cloud_delete_org`; + read-back confirms `deleted_at=2026-08-10T16:18:07Z`. Its one-use invite goes + with it. +- UI-pass test org `PR552 UI Invite Test` (`aaf35493…`) soft-deleted the same + way; read-back confirms `deleted_at=2026-08-10T16:50:58Z` (its used-up + invite goes with it). +- Post-fix test org `PR552 Fix Verify` (`2fbffe1e…`) soft-deleted the same way; + read-back confirms `deleted_at=2026-08-10T17:43:39Z`. +- No `cloud_sessions` were created by the test. Local auth tokens copied to the + scratchpad (session-isolated tmp) for RPC; deleted at end of session, not + persisted elsewhere. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 763cc618bc..7c7795501e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -348,16 +348,43 @@ pub fn run() { let builder = tauri::Builder::default(); + // Keep this plugin first. On Windows and Linux the OS launches a second + // process for a custom-scheme URL; the single-instance plugin's + // `deep-link` feature forwards that argv URL to the already-running + // process before this callback runs. The frontend's app-lifetime + // `onOpenUrl` listener remains the single owner of invite routing. + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + let builder = builder.plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + // Never log argv: deep-link query/fragment values can contain invite + // codes, share capabilities, or OAuth tokens. + tracing::info!( + argument_count = argv.len(), + "external open request forwarded to the running app" + ); + + if let Some(main_window) = app.get_webview_window("main") { + if let Err(error) = main_window.unminimize() { + tracing::warn!(?error, "failed to restore the main window"); + } + if let Err(error) = main_window.show() { + tracing::warn!(?error, "failed to show the main window"); + } + if let Err(error) = main_window.set_focus() { + tracing::warn!(?error, "failed to focus the main window"); + } + } else if let Err(error) = app_window::recreate_main_window(app) { + tracing::warn!( + %error, + "failed to recreate the main window for an external open request" + ); + } + })); + // E2E WebDriver automation — only when built with `--features webdriver` (debug/test only). #[cfg(all(debug_assertions, feature = "webdriver"))] let builder = builder.plugin(tauri_plugin_webdriver_automation::init()); let builder = builder - // NOTE: Single-instance disabled for development - uncomment for production - // .plugin(tauri_plugin_single_instance::init(|_app, argv, _cwd| { - // tracing::info!(?argv, "a new app instance was opened and the deep link event was already triggered"); - // // when defining deep link schemes at runtime, you must also check `argv` here - // })) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_oauth::init()) .plugin(tauri_plugin_fs::init()) diff --git a/src/app/root/e2e/helpers/cloud.ts b/src/app/root/e2e/helpers/cloud.ts index 282be69ba1..048a399d45 100644 --- a/src/app/root/e2e/helpers/cloud.ts +++ b/src/app/root/e2e/helpers/cloud.ts @@ -42,6 +42,7 @@ import { } from "@src/features/Org2Cloud/org2CloudCommentsBus"; import { parseCloudInviteDeepLink, + parseCloudInviteInput, parseCloudShareDeepLink, } from "@src/features/Org2Cloud/org2CloudOrgManagement"; import { @@ -645,13 +646,23 @@ export function createCloudHelpers({ store }: CloudHelperDeps) { link: string; }): Promise> => { try { - // Production parser: a link the deep-link handler would reject must - // fail here too, not silently open the dialog. - const parsed = parseCloudInviteDeepLink(opts.link); + // Production parsers only: an orgii:// link the deep-link handler + // would reject, or an HTTPS link the join dialog would reject, must + // fail here too, not silently open the dialog. Raw codes stay + // rejected — the OS only ever delivers links. + const trimmed = opts.link.trim(); + const parsed = trimmed.toLowerCase().startsWith("orgii://") + ? parseCloudInviteDeepLink(trimmed) + : /^https:\/\//i.test(trimmed) + ? (() => { + const inviteCode = parseCloudInviteInput(trimmed); + return inviteCode ? { inviteCode } : null; + })() + : null; if (!parsed) { return { ok: false, - error: `cloudSeedPendingInvite: not a valid orgii://cloud/join link: ${opts.link}`, + error: `cloudSeedPendingInvite: not a valid cloud invite link: ${opts.link}`, }; } store.set(org2CloudPendingInviteAtom, parsed); diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/ManagementSections.tsx b/src/engines/ChatPanel/panels/CloudOrgPanelView/ManagementSections.tsx index 8a04b00fb5..9c326e724d 100644 --- a/src/engines/ChatPanel/panels/CloudOrgPanelView/ManagementSections.tsx +++ b/src/engines/ChatPanel/panels/CloudOrgPanelView/ManagementSections.tsx @@ -3,8 +3,8 @@ * self-hosted `CollabOrgPanelView/MembersSection`): * * - `CloudInvitesCard` (admin) — two cards: "New invite" (role + max uses + - * optional expiry, then the one-time copyable `orgii://cloud/join` link) - * and "Previous invites" (one row per invite, status + revoke trailing). + * optional expiry, then the one-time copyable HTTPS handoff link) and + * "Previous invites" (one row per invite, status + revoke trailing). * - `CloudMembersSection` — the signed-in member gets a dedicated About me * card above the remaining member rows. Admins get a role dropdown * (admin/member) and Remove; everyone but the owner gets Leave from the diff --git a/src/features/Org2Cloud/org2CloudManagementClient.test.ts b/src/features/Org2Cloud/org2CloudManagementClient.test.ts index ed7f3c5ba1..87053db205 100644 --- a/src/features/Org2Cloud/org2CloudManagementClient.test.ts +++ b/src/features/Org2Cloud/org2CloudManagementClient.test.ts @@ -20,7 +20,7 @@ import { transferCloudOwnership, updateCloudMemberRole, } from "./org2CloudManagementClient"; -import { sha256Hex } from "./org2CloudOrgManagement"; +import { CLOUD_INVITE_WEB_BASE_URL, sha256Hex } from "./org2CloudOrgManagement"; const fetchMock = vi.fn(); @@ -124,7 +124,7 @@ describe("invites", () => { ); expect(created.inviteId).toBe("inv-1"); expect(created.inviteLink).toBe( - `orgii://cloud/join?invite=${created.inviteCode}` + `${CLOUD_INVITE_WEB_BASE_URL}#invite=${created.inviteCode}` ); }); diff --git a/src/features/Org2Cloud/org2CloudManagementClient.ts b/src/features/Org2Cloud/org2CloudManagementClient.ts index 0f0e83d863..59827aa478 100644 --- a/src/features/Org2Cloud/org2CloudManagementClient.ts +++ b/src/features/Org2Cloud/org2CloudManagementClient.ts @@ -212,7 +212,7 @@ export interface CreatedCloudInvite { inviteId: string; /** Plaintext code — exists ONLY on this device, show it once. */ inviteCode: string; - /** `orgii://cloud/join?invite=…` deep link built from the plaintext. */ + /** HTTPS handoff link built from the plaintext for safe social sharing. */ inviteLink: string; } diff --git a/src/features/Org2Cloud/org2CloudOrgManagement.test.ts b/src/features/Org2Cloud/org2CloudOrgManagement.test.ts index 6f12bc3804..47ae9caf93 100644 --- a/src/features/Org2Cloud/org2CloudOrgManagement.test.ts +++ b/src/features/Org2Cloud/org2CloudOrgManagement.test.ts @@ -4,6 +4,7 @@ import { ORG2_CLOUD_OFFICIAL_SUPABASE_URL } from "./config"; import { CLOUD_ASSIGNABLE_ROLES, CLOUD_INVITE_STATE, + CLOUD_INVITE_WEB_BASE_URL, type CloudMemberLike, buildCloudInviteLink, buildCloudSessionShareLink, @@ -51,11 +52,12 @@ describe("invite code generation + hashing", () => { }); describe("cloud invite deep link", () => { - it("builds and parses a round-trip link", () => { - const link = buildCloudInviteLink("c0de"); - expect(link).toBe("orgii://cloud/join?invite=c0de"); - expect(isCloudInviteDeepLink(link)).toBe(true); - expect(parseCloudInviteDeepLink(link)).toEqual({ inviteCode: "c0de" }); + it("builds a shareable HTTPS link with the invite kept in the fragment", () => { + const inviteCode = "c0de".repeat(16); + const link = buildCloudInviteLink(inviteCode); + expect(link).toBe(`${CLOUD_INVITE_WEB_BASE_URL}#invite=${inviteCode}`); + expect(new URL(link).search).toBe(""); + expect(isCloudInviteDeepLink(link)).toBe(false); }); it("rejects collaboration links and foreign schemes", () => { @@ -72,14 +74,46 @@ describe("cloud invite deep link", () => { }); it("parseCloudInviteInput accepts raw codes and links alike", () => { + const fragmentCode = "f".repeat(64); + const queryCode = "0".repeat(64); expect(parseCloudInviteInput(" rawcode ")).toBe("rawcode"); expect(parseCloudInviteInput("orgii://cloud/join?invite=abc")).toBe("abc"); + expect( + parseCloudInviteInput( + `${CLOUD_INVITE_WEB_BASE_URL}#invite=${fragmentCode}` + ) + ).toBe(fragmentCode); + expect( + parseCloudInviteInput(`${CLOUD_INVITE_WEB_BASE_URL}?invite=${queryCode}`) + ).toBe(queryCode); + expect( + parseCloudInviteInput( + `${CLOUD_INVITE_WEB_BASE_URL}?invite=${queryCode}#invite=${fragmentCode}` + ) + ).toBe(fragmentCode); + expect( + parseCloudInviteInput( + `${CLOUD_INVITE_WEB_BASE_URL}?invite=${queryCode}#invite=` + ) + ).toBe(queryCode); + // Same 64-hex contract as the handoff page: uppercase normalizes, + // non-hex codes on the right origin are rejected. + expect( + parseCloudInviteInput( + `${CLOUD_INVITE_WEB_BASE_URL}#invite=${fragmentCode.toUpperCase()}` + ) + ).toBe(fragmentCode); + expect( + parseCloudInviteInput(`${CLOUD_INVITE_WEB_BASE_URL}#invite=not-a-code`) + ).toBeNull(); expect(parseCloudInviteInput("")).toBeNull(); // An orgii:// link that is NOT a cloud invite must not fall through to // being treated as a raw code. expect( parseCloudInviteInput("orgii://collaboration/join?invite=abc") ).toBeNull(); + expect(parseCloudInviteInput("https://example.com/#invite=abc")).toBeNull(); + expect(parseCloudInviteInput("ftp://example.com/invite")).toBeNull(); }); }); @@ -339,6 +373,7 @@ describe("management error codes", () => { ["ORG2_QUOTA_EXCEEDED", "cloud.orgManagement.errors.quotaExceeded"], ["ORG2_FORBIDDEN", "cloud.orgManagement.errors.forbidden"], ["ORG2_MEMBER_NOT_FOUND", "cloud.orgManagement.errors.memberNotFound"], + ["ORG2_ALREADY_MEMBER", "cloud.orgManagement.errors.alreadyMember"], ["ORG2_INVITE_INVALID", "cloud.orgManagement.errors.inviteInvalid"], ["ORG2_INVITE_REVOKED", "cloud.orgManagement.errors.inviteRevoked"], ["ORG2_INVITE_EXPIRED", "cloud.orgManagement.errors.inviteExpired"], diff --git a/src/features/Org2Cloud/org2CloudOrgManagement.ts b/src/features/Org2Cloud/org2CloudOrgManagement.ts index fc93ff4d84..55d2d793e1 100644 --- a/src/features/Org2Cloud/org2CloudOrgManagement.ts +++ b/src/features/Org2Cloud/org2CloudOrgManagement.ts @@ -58,25 +58,29 @@ export async function sha256Hex(value: string): Promise { } // --------------------------------------------------------------------------- -// Invite deep link (orgii://cloud/join?invite=…) +// Invite links // -// Rides the SAME OS-level `orgii://` scheme as the collaboration links -// (registered in src-tauri/tauri.conf.json `deep-link.desktop.schemes`), so -// no Rust change is needed — `useDeepLinkHandler` receives the raw URL from -// the Tauri deep-link plugin and branches on the `cloud` host here, exactly -// like `store/collaboration/deepLink.ts` does for `collaboration`. +// Shareable links use HTTPS so messaging clients recognize them. The invite +// is kept in the URL fragment (never sent to the web host), whose landing page +// hands it to the existing OS-level `orgii://cloud/join` deep link. // --------------------------------------------------------------------------- export const CLOUD_INVITE_DEEP_LINK_HOST = "cloud"; export const CLOUD_INVITE_DEEP_LINK_PATH = "join"; +// Page source lives in ORGII-cloud-infra (apps/invite-link); its code +// validation must stay identical to CLOUD_INVITE_CODE_PATTERN below. +export const CLOUD_INVITE_WEB_BASE_URL = "https://invite.org2.dev/"; + +// Mirrors generateCloudInviteCode's output shape (32 bytes → 64 hex). +const CLOUD_INVITE_CODE_PATTERN = /^[0-9a-f]{64}$/i; export interface CloudInviteDeepLink { inviteCode: string; } export function buildCloudInviteLink(inviteCode: string): string { - const params = new URLSearchParams({ invite: inviteCode }); - return `orgii://${CLOUD_INVITE_DEEP_LINK_HOST}/${CLOUD_INVITE_DEEP_LINK_PATH}?${params.toString()}`; + const fragment = new URLSearchParams({ invite: inviteCode }); + return `${CLOUD_INVITE_WEB_BASE_URL}#${fragment.toString()}`; } /** @@ -116,10 +120,39 @@ export function parseCloudInviteDeepLink( } } +function parseCloudInviteWebLink(url: string): CloudInviteDeepLink | null { + try { + const parsed = new URL(url.trim()); + const expected = new URL(CLOUD_INVITE_WEB_BASE_URL); + if ( + parsed.origin !== expected.origin || + parsed.pathname.replace(/\/+$/, "/") !== expected.pathname + ) { + return null; + } + + // New links use the fragment so the invite never appears in an HTTP + // request. Query parsing remains for already-shared compatibility links. + const fragmentInvite = new URLSearchParams(parsed.hash.replace(/^#/, "")) + .get("invite") + ?.trim(); + const queryInvite = parsed.searchParams.get("invite")?.trim(); + const inviteCode = fragmentInvite || queryInvite; + if (!inviteCode || !CLOUD_INVITE_CODE_PATTERN.test(inviteCode)) { + return null; + } + // The handoff page lowercases the code before building the deep link — + // match it so the same link hashes identically clicked or pasted. + return { inviteCode: inviteCode.toLowerCase() }; + } catch { + return null; + } +} + /** - * Join-form input: accepts either a pasted `orgii://cloud/join?...` link or - * a raw invite code. Returns the bare code, or `null` when empty / a link - * without a code. + * Join-form input: accepts a shareable HTTPS link, a direct + * `orgii://cloud/join?...` link, or a raw invite code. Returns the bare code, + * or `null` when empty / a link without a code. */ export function parseCloudInviteInput(input: string): string | null { const trimmed = input.trim(); @@ -127,6 +160,10 @@ export function parseCloudInviteInput(input: string): string | null { if (trimmed.toLowerCase().startsWith("orgii://")) { return parseCloudInviteDeepLink(trimmed)?.inviteCode ?? null; } + if (/^https?:\/\//i.test(trimmed)) { + return parseCloudInviteWebLink(trimmed)?.inviteCode ?? null; + } + if (trimmed.includes("://")) return null; return trimmed; } @@ -386,6 +423,7 @@ export const ORG2_MANAGEMENT_ERROR_CODES = [ "ORG2_NOT_FOUND", "ORG2_USE_LEAVE_ORG", "ORG2_VALIDATION", + "ORG2_ALREADY_MEMBER", "ORG2_INVITE_INVALID", "ORG2_INVITE_REVOKED", "ORG2_INVITE_EXPIRED", @@ -434,6 +472,7 @@ const MANAGEMENT_ERROR_KEY_BY_CODE: Partial< ORG2_FORBIDDEN: "cloud.orgManagement.errors.forbidden", ORG2_MEMBER_NOT_FOUND: "cloud.orgManagement.errors.memberNotFound", ORG2_VALIDATION: "cloud.orgManagement.errors.validation", + ORG2_ALREADY_MEMBER: "cloud.orgManagement.errors.alreadyMember", ORG2_INVITE_INVALID: "cloud.orgManagement.errors.inviteInvalid", ORG2_INVITE_REVOKED: "cloud.orgManagement.errors.inviteRevoked", ORG2_INVITE_EXPIRED: "cloud.orgManagement.errors.inviteExpired", diff --git a/src/features/Org2Cloud/useCloudOrgMembershipActions.ts b/src/features/Org2Cloud/useCloudOrgMembershipActions.ts index d6e22deb80..1e064afe0f 100644 --- a/src/features/Org2Cloud/useCloudOrgMembershipActions.ts +++ b/src/features/Org2Cloud/useCloudOrgMembershipActions.ts @@ -1,5 +1,6 @@ import { useAtom } from "jotai"; import { useCallback } from "react"; +import { ZodError } from "zod"; import { refreshOrg2CloudAuthForAction } from "./org2CloudAuthAction"; import { org2CloudAuthAtom } from "./org2CloudAuthAtom"; @@ -17,6 +18,7 @@ export type CloudOrgMembershipActionError = | "session_superseded" | "session_unavailable" | "invalid_invite" + | "unexpected_response" | "roster_not_converged"; export class CloudOrgMembershipActionFailure extends Error { @@ -83,7 +85,15 @@ export function useCloudOrgMembershipActions(): { throw new CloudOrgMembershipActionFailure("invalid_invite"); } const fresh = await withFreshAuth(); - const result = await acceptCloudInvite(fresh.accessToken, inviteCode); + let result: Awaited>; + try { + result = await acceptCloudInvite(fresh.accessToken, inviteCode); + } catch (error) { + if (error instanceof ZodError) { + throw new CloudOrgMembershipActionFailure("unexpected_response"); + } + throw error; + } const orgs = await refetchOrgs({ until: (items) => items.some((item) => item.orgId === result.orgId), }); diff --git a/src/hooks/platform/useDeepLinkHandler.test.ts b/src/hooks/platform/useDeepLinkHandler.test.ts index 028c8d3192..05d87bd2a3 100644 --- a/src/hooks/platform/useDeepLinkHandler.test.ts +++ b/src/hooks/platform/useDeepLinkHandler.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + isUnclaimedCloudDeepLink, reArmTrackedShareUrls, trackReArmableShareUrl, } from "./useDeepLinkHandler"; @@ -54,3 +55,23 @@ describe("share deep-link re-arm tracking", () => { expect(Array.from(reArmable)).toEqual([SHARE_A]); }); }); + +describe("malformed cloud deep-link containment", () => { + it("claims orgii://cloud urls the dedicated parsers rejected", () => { + expect(isUnclaimedCloudDeepLink("orgii://cloud/join")).toBe(true); + expect(isUnclaimedCloudDeepLink("orgii://cloud/join?invite=")).toBe(true); + expect(isUnclaimedCloudDeepLink("orgii://cloud/nonsense?x=1")).toBe(true); + expect(isUnclaimedCloudDeepLink("orgii://cloud/session?share=")).toBe(true); + expect(isUnclaimedCloudDeepLink(" ORGII://CLOUD/join ")).toBe(true); + }); + + it("leaves every non-cloud url to the generic route conversion", () => { + expect(isUnclaimedCloudDeepLink("orgii://collaboration/join")).toBe(false); + expect(isUnclaimedCloudDeepLink("orgii://marketplace/callback")).toBe( + false + ); + expect(isUnclaimedCloudDeepLink("yorgai://cloud/join")).toBe(false); + expect(isUnclaimedCloudDeepLink("https://cloud/join")).toBe(false); + expect(isUnclaimedCloudDeepLink("not a url")).toBe(false); + }); +}); diff --git a/src/hooks/platform/useDeepLinkHandler.ts b/src/hooks/platform/useDeepLinkHandler.ts index d25c203cbc..583b70e883 100644 --- a/src/hooks/platform/useDeepLinkHandler.ts +++ b/src/hooks/platform/useDeepLinkHandler.ts @@ -46,6 +46,7 @@ import { } from "@src/features/Org2Cloud/org2CloudAuthLoopback"; import { resetOrgEntitlementCoordinator } from "@src/features/Org2Cloud/org2CloudEntitlementCoordinator"; import { + CLOUD_INVITE_DEEP_LINK_HOST, type CloudInviteDeepLink, type CloudShareDeepLink, parseCloudInviteDeepLink, @@ -146,6 +147,22 @@ function parseDeepLink( } } +// Unclaimed orgii://cloud/… URLs must never reach the generic conversion: +// /orgii/cloud/… matches no route (404 error page), and getCurrent() would +// re-deliver the URL on every boot, resurrecting that page after restarts. +// Only correct when called AFTER the dedicated cloud parsers had their turn. +export function isUnclaimedCloudDeepLink(url: string): boolean { + const trimmed = url.trim(); + if (!trimmed.toLowerCase().startsWith("orgii://")) return false; + try { + return ( + new URL(trimmed).hostname.toLowerCase() === CLOUD_INVITE_DEEP_LINK_HOST + ); + } catch { + return false; + } +} + /** * Hook to handle deep link navigation * Should be mounted once at the app root level @@ -380,6 +397,15 @@ export function useDeepLinkHandler(): void { break; } + if (isUnclaimedCloudDeepLink(url)) { + processedDeepLinks.current.add(url); + logWarn( + "DeepLinkHandler", + "Ignoring malformed ORG2 Cloud deep link" + ); + continue; + } + const parsed = parseDeepLink(url); if (!parsed) { logWarn("DeepLinkHandler", "Could not parse deep link:", url); @@ -501,6 +527,15 @@ export function useDeepLinkHandler(): void { break; } + if (isUnclaimedCloudDeepLink(url)) { + processedDeepLinks.current.add(url); + logWarn( + "DeepLinkHandler", + "Ignoring malformed initial ORG2 Cloud deep link" + ); + continue; + } + const parsed = parseDeepLink(url); if (!parsed) { continue; diff --git a/src/i18n/locales/de/navigation.json b/src/i18n/locales/de/navigation.json index f425afe878..4571ada488 100644 --- a/src/i18n/locales/de/navigation.json +++ b/src/i18n/locales/de/navigation.json @@ -699,6 +699,7 @@ "inviteRevoked": "Diese Einladung wurde widerrufen.", "inviteExpired": "Diese Einladung ist abgelaufen.", "inviteExhausted": "Diese Einladung hat keine Verwendungen mehr.", + "alreadyMember": "Du bist bereits Mitglied dieser Organisation.", "network": "Verbindung zum Cloud-Dienst fehlgeschlagen. Prüfe das Netzwerk und versuche es erneut." } }, diff --git a/src/i18n/locales/en/navigation.json b/src/i18n/locales/en/navigation.json index 3d79c2fec5..676c35a6ba 100644 --- a/src/i18n/locales/en/navigation.json +++ b/src/i18n/locales/en/navigation.json @@ -725,6 +725,7 @@ "inviteRevoked": "This invite has been revoked.", "inviteExpired": "This invite has expired.", "inviteExhausted": "This invite has no uses left.", + "alreadyMember": "You're already a member of this organization.", "network": "Couldn't connect to the cloud service. Check your network and try again." } }, diff --git a/src/i18n/locales/es/navigation.json b/src/i18n/locales/es/navigation.json index c65f8812c6..246af56bb5 100644 --- a/src/i18n/locales/es/navigation.json +++ b/src/i18n/locales/es/navigation.json @@ -699,6 +699,7 @@ "inviteRevoked": "Esta invitación fue revocada.", "inviteExpired": "Esta invitación caducó.", "inviteExhausted": "Esta invitación no tiene usos restantes.", + "alreadyMember": "Ya eres miembro de esta organización.", "network": "No se pudo conectar al servicio en la nube. Comprueba la red e inténtalo de nuevo." } }, diff --git a/src/i18n/locales/fr/navigation.json b/src/i18n/locales/fr/navigation.json index c34d46cc35..0b729a6e2f 100644 --- a/src/i18n/locales/fr/navigation.json +++ b/src/i18n/locales/fr/navigation.json @@ -699,6 +699,7 @@ "inviteRevoked": "Cette invitation a été révoquée.", "inviteExpired": "Cette invitation a expiré.", "inviteExhausted": "Cette invitation n'a plus d'utilisations.", + "alreadyMember": "Vous êtes déjà membre de cette organisation.", "network": "Connexion au service cloud impossible. Vérifiez le réseau et réessayez." } }, diff --git a/src/i18n/locales/ja/navigation.json b/src/i18n/locales/ja/navigation.json index d61c40cd40..2e174f5e08 100644 --- a/src/i18n/locales/ja/navigation.json +++ b/src/i18n/locales/ja/navigation.json @@ -697,6 +697,7 @@ "inviteRevoked": "この招待は取り消されています。", "inviteExpired": "この招待は期限切れです。", "inviteExhausted": "この招待は使用回数を使い切りました。", + "alreadyMember": "すでにこの組織のメンバーです。", "network": "クラウドサービスに接続できません。ネットワークを確認して再試行してください。" } }, diff --git a/src/i18n/locales/ko/navigation.json b/src/i18n/locales/ko/navigation.json index bd5a41e2fb..9926f52ac5 100644 --- a/src/i18n/locales/ko/navigation.json +++ b/src/i18n/locales/ko/navigation.json @@ -697,6 +697,7 @@ "inviteRevoked": "철회된 초대입니다.", "inviteExpired": "만료된 초대입니다.", "inviteExhausted": "사용 횟수를 모두 소진한 초대입니다.", + "alreadyMember": "이미 이 조직의 멤버입니다.", "network": "클라우드 서비스에 연결할 수 없습니다. 네트워크를 확인하고 다시 시도하세요." } }, diff --git a/src/i18n/locales/pl/navigation.json b/src/i18n/locales/pl/navigation.json index b0f13ebb58..384449c879 100644 --- a/src/i18n/locales/pl/navigation.json +++ b/src/i18n/locales/pl/navigation.json @@ -697,6 +697,7 @@ "inviteRevoked": "To zaproszenie zostało odwołane.", "inviteExpired": "To zaproszenie wygasło.", "inviteExhausted": "To zaproszenie nie ma już dostępnych użyć.", + "alreadyMember": "Już należysz do tej organizacji.", "network": "Nie udało się połączyć z usługą chmurową. Sprawdź sieć i spróbuj ponownie." } }, diff --git a/src/i18n/locales/pt/navigation.json b/src/i18n/locales/pt/navigation.json index dae91983c4..f9b5325c2d 100644 --- a/src/i18n/locales/pt/navigation.json +++ b/src/i18n/locales/pt/navigation.json @@ -699,6 +699,7 @@ "inviteRevoked": "Este convite foi revogado.", "inviteExpired": "Este convite expirou.", "inviteExhausted": "Este convite não tem mais usos.", + "alreadyMember": "Você já é membro desta organização.", "network": "Não foi possível conectar ao serviço de nuvem. Verifique a rede e tente novamente." } }, diff --git a/src/i18n/locales/ru/navigation.json b/src/i18n/locales/ru/navigation.json index 8209ecbb2d..dc2cf7e056 100644 --- a/src/i18n/locales/ru/navigation.json +++ b/src/i18n/locales/ru/navigation.json @@ -697,6 +697,7 @@ "inviteRevoked": "Это приглашение отозвано.", "inviteExpired": "Срок действия приглашения истёк.", "inviteExhausted": "У этого приглашения не осталось использований.", + "alreadyMember": "Вы уже участник этой организации.", "network": "Не удалось подключиться к облачному сервису. Проверьте сеть и повторите попытку." } }, diff --git a/src/i18n/locales/tr/navigation.json b/src/i18n/locales/tr/navigation.json index ceca6b4ab1..9f4a8130ba 100644 --- a/src/i18n/locales/tr/navigation.json +++ b/src/i18n/locales/tr/navigation.json @@ -697,6 +697,7 @@ "inviteRevoked": "Bu davet iptal edilmiş.", "inviteExpired": "Bu davetin süresi dolmuş.", "inviteExhausted": "Bu davetin kullanım hakkı kalmamış.", + "alreadyMember": "Zaten bu organizasyonun üyesisin.", "network": "Bulut hizmetine bağlanılamadı. Ağınızı kontrol edip yeniden deneyin." } }, diff --git a/src/i18n/locales/vi/navigation.json b/src/i18n/locales/vi/navigation.json index 813a8037ca..be8b546427 100644 --- a/src/i18n/locales/vi/navigation.json +++ b/src/i18n/locales/vi/navigation.json @@ -697,6 +697,7 @@ "inviteRevoked": "Lời mời đã bị thu hồi.", "inviteExpired": "Lời mời đã hết hạn.", "inviteExhausted": "Lời mời đã hết số lần dùng.", + "alreadyMember": "Bạn đã là thành viên của tổ chức này.", "network": "Không thể kết nối dịch vụ đám mây. Hãy kiểm tra mạng và thử lại." } }, diff --git a/src/i18n/locales/zh-Hant/navigation.json b/src/i18n/locales/zh-Hant/navigation.json index 1031ca817e..82884dce3f 100644 --- a/src/i18n/locales/zh-Hant/navigation.json +++ b/src/i18n/locales/zh-Hant/navigation.json @@ -788,6 +788,7 @@ "inviteRevoked": "該邀請已被撤銷。", "inviteExpired": "該邀請已過期。", "inviteExhausted": "該邀請的使用次數已用完。", + "alreadyMember": "你已經是該組織的成員。", "network": "無法連線雲端服務。請檢查網路後重試。" } }, diff --git a/src/i18n/locales/zh/navigation.json b/src/i18n/locales/zh/navigation.json index 4e8c5bf3de..35a144e527 100644 --- a/src/i18n/locales/zh/navigation.json +++ b/src/i18n/locales/zh/navigation.json @@ -788,6 +788,7 @@ "inviteRevoked": "该邀请已被撤销。", "inviteExpired": "该邀请已过期。", "inviteExhausted": "该邀请的使用次数已用完。", + "alreadyMember": "你已经是该组织的成员。", "network": "无法连接云服务。请检查网络后重试。" } }, diff --git a/src/modules/SetupWalkthrough/useSetupWalkthroughController.ts b/src/modules/SetupWalkthrough/useSetupWalkthroughController.ts index a74d0ee611..4ebe78479a 100644 --- a/src/modules/SetupWalkthrough/useSetupWalkthroughController.ts +++ b/src/modules/SetupWalkthrough/useSetupWalkthroughController.ts @@ -115,6 +115,7 @@ export function useSetupWalkthroughController() { return t("readiness.errors.rosterNotConverged"); case "session_superseded": case "session_unavailable": + case "unexpected_response": return t("readiness.errors.cloudUnavailable"); } } diff --git a/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs b/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs index aabe8d5184..ac791e237d 100644 --- a/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs +++ b/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs @@ -52,6 +52,9 @@ import { waitForRenderedOn, } from "../../support/core/dualCloudHarness.mjs"; +// Rendered shape of buildCloudInviteLink (org2CloudOrgManagement.ts). +const CLOUD_INVITE_LINK_PREFIX = "https://invite.org2.dev/#invite="; + const TEAM_NAME = `Dual-instance Team ${RUN_ID}`; const RENAMED_TEAM_NAME = `Renamed dual team ${RUN_ID}`; let sessionId = `dual-instance-session-${RUN_ID}`; @@ -701,7 +704,7 @@ async function createInviteFromOwner(previousLink = "") { )) ?? "" ); return ( - link.startsWith("orgii://cloud/join?invite=") && link !== previousLink + link.startsWith(CLOUD_INVITE_LINK_PREFIX) && link !== previousLink ); }, { @@ -1239,8 +1242,8 @@ describe("Cloud collaboration with two independent rendered app instances", func inviteLink = await execJS(` return document.querySelector('[data-testid="cloud-org-invite-link"]')?.textContent?.trim() ?? ''; `); - if (!String(inviteLink).startsWith("orgii://cloud/join?invite=")) { - throw new Error("rendered team invite is not a valid orgii join link"); + if (!String(inviteLink).startsWith(CLOUD_INVITE_LINK_PREFIX)) { + throw new Error("rendered team invite is not a valid invite handoff link"); } unwrapOn(