Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions docs/architecture-audit-2026-07-28/CloudOrgInviteDeepLinks.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 32 additions & 5 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/features/Org2Cloud/org2CloudManagementClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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}`
);
});

Expand Down
2 changes: 1 addition & 1 deletion src/features/Org2Cloud/org2CloudManagementClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
28 changes: 23 additions & 5 deletions src/features/Org2Cloud/org2CloudOrgManagement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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();
});
});

Expand Down
50 changes: 39 additions & 11 deletions src/features/Org2Cloud/org2CloudOrgManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,25 +58,25 @@ export async function sha256Hex(value: string): Promise<string> {
}

// ---------------------------------------------------------------------------
// 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()}`;
}

/**
Expand Down Expand Up @@ -116,17 +116,45 @@ 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();
if (!trimmed) return 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;
}

Expand Down
Loading