From 1c453077dc37ea131f2871d7c247a905aa0ad7a5 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 28 Jul 2026 21:58:14 +0800 Subject: [PATCH 1/3] feat(cloud): share org invites through HTTPS handoff --- .../CloudOrgPanelView/ManagementSections.tsx | 4 +- .../org2CloudManagementClient.test.ts | 4 +- .../Org2Cloud/org2CloudManagementClient.ts | 2 +- .../Org2Cloud/org2CloudOrgManagement.test.ts | 28 +++++++++-- .../Org2Cloud/org2CloudOrgManagement.ts | 50 +++++++++++++++---- 5 files changed, 67 insertions(+), 21 deletions(-) diff --git a/src/engines/ChatPanel/panels/CloudOrgPanelView/ManagementSections.tsx b/src/engines/ChatPanel/panels/CloudOrgPanelView/ManagementSections.tsx index e791d7cdb..5f888d4bb 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 ed7f3c5ba..87053db20 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 0f0e83d86..59827aa47 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 6f12bc380..17c5b924c 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", () => { @@ -74,12 +76,28 @@ describe("cloud invite deep link", () => { it("parseCloudInviteInput accepts raw codes and links alike", () => { expect(parseCloudInviteInput(" rawcode ")).toBe("rawcode"); expect(parseCloudInviteInput("orgii://cloud/join?invite=abc")).toBe("abc"); + expect( + parseCloudInviteInput(`${CLOUD_INVITE_WEB_BASE_URL}#invite=fragment`) + ).toBe("fragment"); + expect( + parseCloudInviteInput(`${CLOUD_INVITE_WEB_BASE_URL}?invite=legacy`) + ).toBe("legacy"); + expect( + parseCloudInviteInput( + `${CLOUD_INVITE_WEB_BASE_URL}?invite=query#invite=fragment` + ) + ).toBe("fragment"); + expect( + parseCloudInviteInput(`${CLOUD_INVITE_WEB_BASE_URL}?invite=query#invite=`) + ).toBe("query"); 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(); }); }); diff --git a/src/features/Org2Cloud/org2CloudOrgManagement.ts b/src/features/Org2Cloud/org2CloudOrgManagement.ts index fc93ff4d8..d9fe74ed9 100644 --- a/src/features/Org2Cloud/org2CloudOrgManagement.ts +++ b/src/features/Org2Cloud/org2CloudOrgManagement.ts @@ -58,25 +58,25 @@ 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"; +export const CLOUD_INVITE_WEB_BASE_URL = + "https://orgii-invite-link.atah2000.chatgpt.site/"; 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 +116,34 @@ 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; + return inviteCode ? { inviteCode } : null; + } 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 +151,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; } From a53be07f1f165a2eed0467d2c71cf7fea7125918 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 28 Jul 2026 21:58:16 +0800 Subject: [PATCH 2/3] fix(app): forward deep links to the running instance --- src-tauri/src/lib.rs | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 55ff73d94..8764a2f02 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()) From 43d6529b37628c0a20e90626c2e87b6b10934d54 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Tue, 28 Jul 2026 21:58:16 +0800 Subject: [PATCH 3/3] docs: record cloud invite deep-link audit --- .../CloudOrgInviteDeepLinks.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/architecture-audit-2026-07-28/CloudOrgInviteDeepLinks.md 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 000000000..608f56be3 --- /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.