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(