diff --git a/examples/playground/src/playground/components/PeersPanel.tsx b/examples/playground/src/playground/components/PeersPanel.tsx
index 66d37e8a..6591ca18 100644
--- a/examples/playground/src/playground/components/PeersPanel.tsx
+++ b/examples/playground/src/playground/components/PeersPanel.tsx
@@ -95,7 +95,7 @@ export function PeersPanel({
title={
authEnabled && !(authCanIssue || authCanDelegate)
? "Verify-only tabs can’t mint invites. Open a minting peer (or import a grant with share permission)."
- : "New device (isolated): separate storage, auto-invite"
+ : "New device (isolated): separate storage, auto-grant"
}
>
@@ -133,4 +133,3 @@ export function PeersPanel({
);
}
-
diff --git a/examples/playground/src/playground/components/ShareSubtreeDialog.tsx b/examples/playground/src/playground/components/ShareSubtreeDialog.tsx
index d05d576d..e5a1fb20 100644
--- a/examples/playground/src/playground/components/ShareSubtreeDialog.tsx
+++ b/examples/playground/src/playground/components/ShareSubtreeDialog.tsx
@@ -209,7 +209,7 @@ export function ShareSubtreeDialog(props: ShareSubtreeDialogProps) {
disabled={authBusy || !authEnabled || !(authCanIssue || authCanDelegate)}
title={
authCanIssue || authCanDelegate
- ? "Open an isolated device tab and auto-import the invite"
+ ? "Open an isolated device tab and auto-grant access"
: "This tab can’t mint invites (verify-only)"
}
>
diff --git a/examples/playground/src/playground/components/TreePanel.tsx b/examples/playground/src/playground/components/TreePanel.tsx
index 335f12c8..202e59aa 100644
--- a/examples/playground/src/playground/components/TreePanel.tsx
+++ b/examples/playground/src/playground/components/TreePanel.tsx
@@ -249,7 +249,7 @@ export function TreePanel({
title={
authEnabled && !(authCanIssue || authCanDelegate)
? "Verify-only tabs can’t mint invites. Open a minting peer (or import a grant with share permission)."
- : "New device (isolated): separate storage (no shared keys/private-roots). Auto-invite; Alt+click opens join-only."
+ : "New device (isolated): separate storage (no shared keys/private-roots). Auto-grant; Alt+click opens join-only."
}
aria-label="New device (isolated)"
>
diff --git a/examples/playground/src/playground/hooks/usePlaygroundAuth.ts b/examples/playground/src/playground/hooks/usePlaygroundAuth.ts
index 72b7fdd7..aa1b03e3 100644
--- a/examples/playground/src/playground/hooks/usePlaygroundAuth.ts
+++ b/examples/playground/src/playground/hooks/usePlaygroundAuth.ts
@@ -38,7 +38,7 @@ import {
saveDocPayloadKeyB64,
type StoredAuthMaterial,
} from "../../auth";
-import { hexToBytes16, type AuthGrantMessageV1 } from "../../sync-v0";
+import { hexToBytes16, type AuthGrantMessageV1, type AuthInviteRequestMessageV1 } from "../../sync-v0";
import { ROOT_ID } from "../constants";
import { loadPrivateRoots, persistPrivateRoots } from "../persist";
import { prefixPlaygroundStorageKey } from "../storage";
@@ -200,10 +200,19 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut
const [authEnabled, setAuthEnabled] = useState(() => initialAuthEnabled());
const [revealIdentity, setRevealIdentity] = useState(() => initialRevealIdentity());
+ const inviteRequestId = useMemo(() => {
+ if (typeof window === "undefined") return null;
+ const raw = new URLSearchParams(window.location.search).get("invite_req");
+ if (!raw) return null;
+ const clean = raw.trim();
+ return clean.length > 0 ? clean : null;
+ }, []);
const [showAuthPanel, setShowAuthPanel] = useState(() => {
if (typeof window === "undefined") return false;
if (!joinMode) return false;
- return !new URLSearchParams(window.location.hash.slice(1)).has("invite");
+ const hasInvite = new URLSearchParams(window.location.hash.slice(1)).has("invite");
+ const hasInviteReq = new URLSearchParams(window.location.search).has("invite_req");
+ return !hasInvite && !hasInviteReq;
});
const [showShareDialog, setShowShareDialog] = useState(false);
const [showAuthAdvanced, setShowAuthAdvanced] = useState(false);
@@ -1063,105 +1072,165 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut
[docId, refreshAuthMaterial, refreshDocPayloadKey, rememberScopedPrivateRootsFromToken]
);
- const grantSubtreeToReplicaPubkey = async (sendGrant: (msg: AuthGrantMessageV1) => boolean) => {
+ useEffect(() => {
if (!authEnabled) return;
- if (typeof window === "undefined") return;
+ if (!joinMode) return;
+ if (!inviteRequestId) return;
+ if (!selfPeerId) return;
+ if (authMaterial.localTokensB64.length > 0) return;
+ if (typeof BroadcastChannel === "undefined") return;
+
+ const channel = new BroadcastChannel(`treecrdt-sync-v0:${docId}`);
+ let timer: ReturnType | null = null;
+
+ const handleMessage = (ev: MessageEvent) => {
+ const data = (ev as MessageEvent).data;
+ if (!data || typeof data !== "object") return;
+ const msg = data as Partial;
+ if (msg.t !== "auth_grant_v1") return;
+ if (msg.doc_id !== docId) return;
+ if (typeof msg.to_replica_pk_hex !== "string") return;
+ if (msg.to_replica_pk_hex.toLowerCase() !== selfPeerId.toLowerCase()) return;
+ if (typeof msg.issuer_pk_b64 !== "string") return;
+ if (typeof msg.token_b64 !== "string") return;
+
+ if (timer) clearInterval(timer);
+ channel.removeEventListener("message", handleMessage);
+ channel.close();
+ onAuthGrantMessage(msg as AuthGrantMessageV1);
+ };
- setAuthBusy(true);
- setAuthError(null);
- setAuthInfo(null);
+ channel.addEventListener("message", handleMessage);
- try {
- if (!selfPeerId) throw new Error("local replica public key is not ready yet");
+ const req: AuthInviteRequestMessageV1 = {
+ t: "auth_invite_request_v1",
+ doc_id: docId,
+ request_id: inviteRequestId,
+ from_replica_pk_hex: selfPeerId,
+ ts: Date.now(),
+ };
- const issuerPkB64 = authMaterial.issuerPkB64;
- const issuerSkB64 = authMaterial.issuerSkB64;
- if (!issuerPkB64) throw new Error("issuer public key is missing; import an invite link first");
+ const sendRequest = () => {
+ try {
+ channel.postMessage(req);
+ } catch {
+ // ignore
+ }
+ };
- const subjectPk = parseReplicaPublicKeyInput(grantRecipientKey);
- const rootNodeId = inviteRoot;
- const { actions, maxDepth, excludeNodeIds } = readInviteConfig(rootNodeId);
+ sendRequest();
+ timer = setInterval(sendRequest, 1_000);
- let tokenBytes: Uint8Array;
- if (issuerSkB64) {
- const issuerSk = base64urlDecode(issuerSkB64);
- tokenBytes = createCapabilityTokenV1({
- issuerPrivateKey: issuerSk,
- subjectPublicKey: subjectPk,
- docId,
- rootNodeId,
- actions,
- ...(maxDepth !== undefined ? { maxDepth } : {}),
- ...(excludeNodeIds.length > 0 ? { excludeNodeIds } : {}),
- });
- } else {
- const localSkB64 = authMaterial.localSkB64;
- const proofTokenB64 = authMaterial.localTokensB64[0] ?? null;
- if (!localSkB64 || !proofTokenB64) {
- throw new Error("cannot delegate grants without local keys/tokens; import an invite link first");
- }
+ return () => {
+ if (timer) clearInterval(timer);
+ channel.removeEventListener("message", handleMessage);
+ channel.close();
+ };
+ }, [authEnabled, authMaterial.localTokensB64.length, docId, inviteRequestId, joinMode, onAuthGrantMessage, selfPeerId]);
- const issuerPk = base64urlDecode(issuerPkB64);
- const proofTokenBytes = base64urlDecode(proofTokenB64);
- const scopeEvaluator = client ? createTreecrdtSqliteSubtreeScopeEvaluator(client.runner) : undefined;
- const proofDesc = await describeTreecrdtCapabilityTokenV1({
- tokenBytes: proofTokenBytes,
- issuerPublicKeys: [issuerPk],
- docId,
- scopeEvaluator,
- });
- const proofActions = new Set(proofDesc.caps.flatMap((c) => c.actions ?? []));
- if (!proofActions.has("grant")) {
- throw new Error("this tab cannot delegate grants (missing grant permission)");
- }
+ const buildGrantMessageV1 = async (opts2: { subjectPk: Uint8Array; rootNodeId: string }): Promise => {
+ if (!authEnabled) throw new Error("auth is disabled");
+ if (typeof window === "undefined") throw new Error("window is not available");
- const proofScope = proofDesc.caps?.[0]?.res ?? null;
- const proofRootId = (proofScope?.rootNodeId ?? ROOT_ID).toLowerCase();
- const proofDocWide =
- proofRootId === ROOT_ID && proofScope?.maxDepth === undefined && (proofScope?.excludeNodeIds?.length ?? 0) === 0;
- if (!proofDocWide && rootNodeId.toLowerCase() !== proofRootId) {
- if (proofScope?.maxDepth !== undefined) {
- throw new Error("this tab can only delegate grants for its current subtree scope (maxDepth)");
- }
- if (!scopeEvaluator) {
- throw new Error("this tab can only delegate grants for its current subtree scope");
- }
- const tri = await scopeEvaluator({
- docId,
- node: hexToBytes16(rootNodeId),
- scope: {
- root: hexToBytes16(proofRootId),
- ...(proofScope?.excludeNodeIds?.length
- ? { exclude: proofScope.excludeNodeIds.map((id) => hexToBytes16(id)) }
- : {}),
- },
- });
- if (tri === "deny") throw new Error("this tab can only delegate grants within its granted subtree scope");
- if (tri === "unknown") throw new Error("missing subtree context to delegate grants for that node");
- }
+ if (!selfPeerId) throw new Error("local replica public key is not ready yet");
- tokenBytes = issueTreecrdtDelegatedCapabilityTokenV1({
- delegatorPrivateKey: base64urlDecode(localSkB64),
- delegatorProofToken: proofTokenBytes,
- subjectPublicKey: subjectPk,
+ const issuerPkB64 = authMaterial.issuerPkB64;
+ const issuerSkB64 = authMaterial.issuerSkB64;
+ if (!issuerPkB64) throw new Error("issuer public key is missing; import an invite link first");
+
+ const { actions, maxDepth, excludeNodeIds } = readInviteConfig(opts2.rootNodeId);
+
+ let tokenBytes: Uint8Array;
+ if (issuerSkB64) {
+ const issuerSk = base64urlDecode(issuerSkB64);
+ tokenBytes = createCapabilityTokenV1({
+ issuerPrivateKey: issuerSk,
+ subjectPublicKey: opts2.subjectPk,
+ docId,
+ rootNodeId: opts2.rootNodeId,
+ actions,
+ ...(maxDepth !== undefined ? { maxDepth } : {}),
+ ...(excludeNodeIds.length > 0 ? { excludeNodeIds } : {}),
+ });
+ } else {
+ const localSkB64 = authMaterial.localSkB64;
+ const proofTokenB64 = authMaterial.localTokensB64[0] ?? null;
+ if (!localSkB64 || !proofTokenB64) {
+ throw new Error("cannot delegate grants without local keys/tokens; import an invite link first");
+ }
+
+ const issuerPk = base64urlDecode(issuerPkB64);
+ const proofTokenBytes = base64urlDecode(proofTokenB64);
+ const scopeEvaluator = client ? createTreecrdtSqliteSubtreeScopeEvaluator(client.runner) : undefined;
+ const proofDesc = await describeTreecrdtCapabilityTokenV1({
+ tokenBytes: proofTokenBytes,
+ issuerPublicKeys: [issuerPk],
+ docId,
+ scopeEvaluator,
+ });
+ const proofActions = new Set(proofDesc.caps.flatMap((c) => c.actions ?? []));
+ if (!proofActions.has("grant")) {
+ throw new Error("this tab cannot delegate grants (missing grant permission)");
+ }
+
+ const proofScope = proofDesc.caps?.[0]?.res ?? null;
+ const proofRootId = (proofScope?.rootNodeId ?? ROOT_ID).toLowerCase();
+ const proofDocWide =
+ proofRootId === ROOT_ID && proofScope?.maxDepth === undefined && (proofScope?.excludeNodeIds?.length ?? 0) === 0;
+ if (!proofDocWide && opts2.rootNodeId.toLowerCase() !== proofRootId) {
+ if (proofScope?.maxDepth !== undefined) {
+ throw new Error("this tab can only delegate grants for its current subtree scope (maxDepth)");
+ }
+ if (!scopeEvaluator) {
+ throw new Error("this tab can only delegate grants for its current subtree scope");
+ }
+ const tri = await scopeEvaluator({
docId,
- rootNodeId,
- actions,
- ...(maxDepth !== undefined ? { maxDepth } : {}),
- ...(excludeNodeIds.length > 0 ? { excludeNodeIds } : {}),
+ node: hexToBytes16(opts2.rootNodeId),
+ scope: {
+ root: hexToBytes16(proofRootId),
+ ...(proofScope?.excludeNodeIds?.length ? { exclude: proofScope.excludeNodeIds.map((id) => hexToBytes16(id)) } : {}),
+ },
});
+ if (tri === "deny") throw new Error("this tab can only delegate grants within its granted subtree scope");
+ if (tri === "unknown") throw new Error("missing subtree context to delegate grants for that node");
}
- const msg: AuthGrantMessageV1 = {
- t: "auth_grant_v1",
- doc_id: docId,
- to_replica_pk_hex: bytesToHex(subjectPk),
- issuer_pk_b64: issuerPkB64,
- token_b64: base64urlEncode(tokenBytes),
- payload_key_b64: await loadOrCreateDocPayloadKeyB64(docId),
- from_peer_id: selfPeerId,
- ts: Date.now(),
- };
+ tokenBytes = issueTreecrdtDelegatedCapabilityTokenV1({
+ delegatorPrivateKey: base64urlDecode(localSkB64),
+ delegatorProofToken: proofTokenBytes,
+ subjectPublicKey: opts2.subjectPk,
+ docId,
+ rootNodeId: opts2.rootNodeId,
+ actions,
+ ...(maxDepth !== undefined ? { maxDepth } : {}),
+ ...(excludeNodeIds.length > 0 ? { excludeNodeIds } : {}),
+ });
+ }
+
+ return {
+ t: "auth_grant_v1",
+ doc_id: docId,
+ to_replica_pk_hex: bytesToHex(opts2.subjectPk),
+ issuer_pk_b64: issuerPkB64,
+ token_b64: base64urlEncode(tokenBytes),
+ payload_key_b64: await loadOrCreateDocPayloadKeyB64(docId),
+ from_peer_id: selfPeerId,
+ ts: Date.now(),
+ };
+ };
+
+ const grantSubtreeToReplicaPubkey = async (sendGrant: (msg: AuthGrantMessageV1) => boolean) => {
+ if (!authEnabled) return;
+ if (typeof window === "undefined") return;
+
+ setAuthBusy(true);
+ setAuthError(null);
+ setAuthInfo(null);
+
+ try {
+ const subjectPk = parseReplicaPublicKeyInput(grantRecipientKey);
+ const msg = await buildGrantMessageV1({ subjectPk, rootNodeId: inviteRoot });
if (!sendGrant(msg)) throw new Error("sync channel is not ready yet");
setGrantRecipientKey("");
@@ -1173,7 +1242,12 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut
}
};
- const makeNewProfileId = () => `profile-${crypto.randomUUID().slice(0, 8)}`;
+ const shortRandomId = () => {
+ if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID().slice(0, 8);
+ return Math.random().toString(16).slice(2, 10);
+ };
+ const makeNewProfileId = () => `profile-${shortRandomId()}`;
+ const makeInviteRequestId = () => `invite-${shortRandomId()}`;
const openNewIsolatedPeerTab = async (opts2: { autoInvite: boolean; rootNodeId?: string }) => {
if (typeof window === "undefined") return;
@@ -1185,18 +1259,69 @@ export function usePlaygroundAuth(opts: UsePlaygroundAuthOptions): PlaygroundAut
url.searchParams.set("join", "1");
url.searchParams.set("auth", "1");
url.searchParams.delete("autosync");
+ url.searchParams.delete("invite_req");
url.hash = "";
if (opts2.autoInvite) {
+ const requestId = makeInviteRequestId();
+ url.searchParams.set("autosync", "1");
+ url.searchParams.set("invite_req", requestId);
+
+ setAuthBusy(true);
+ setAuthError(null);
+ setAuthInfo(null);
+
+ let bc: BroadcastChannel | null = null;
+ let onInviteRequest: ((ev: MessageEvent) => void) | null = null;
+ let waitTimeout: ReturnType | null = null;
+
try {
- // Auto-invite makes the common "simulate another device" flow 1 click.
- url.searchParams.set("autosync", "1");
- url.hash = `invite=${await buildInviteB64({ rootNodeId: opts2.rootNodeId ?? ROOT_ID })}`;
+ if (typeof BroadcastChannel === "undefined") {
+ throw new Error("BroadcastChannel is not available in this environment.");
+ }
+
+ const rootNodeId = opts2.rootNodeId ?? ROOT_ID;
+ const channel = new BroadcastChannel(`treecrdt-sync-v0:${docId}`);
+ bc = channel;
+
+ const waitForReplicaPkHex = new Promise((resolve, reject) => {
+ waitTimeout = setTimeout(() => reject(new Error("Timed out waiting for the new device to request access.")), 15_000);
+
+ const handleInviteRequest = (ev: MessageEvent) => {
+ const data = (ev as MessageEvent).data;
+ if (!data || typeof data !== "object") return;
+ const msg = data as Partial;
+ if (msg.t !== "auth_invite_request_v1") return;
+ if (msg.doc_id !== docId) return;
+ if (msg.request_id !== requestId) return;
+ if (typeof msg.from_replica_pk_hex !== "string") return;
+
+ if (waitTimeout) clearTimeout(waitTimeout);
+ channel.removeEventListener("message", handleInviteRequest);
+ resolve(msg.from_replica_pk_hex);
+ };
+
+ onInviteRequest = handleInviteRequest;
+ channel.addEventListener("message", handleInviteRequest);
+ });
+
+ // Open the tab before awaiting so browsers don't block the popup.
+ window.open(url.toString(), "_blank", "noopener,noreferrer");
+
+ const replicaPkHex = await waitForReplicaPkHex;
+ const grant = await buildGrantMessageV1({ subjectPk: parseReplicaPublicKeyInput(replicaPkHex), rootNodeId });
+ channel.postMessage(grant);
} catch (err) {
- // Fall back to a blank join-only tab and show the reason on the current tab.
setAuthError(err instanceof Error ? err.message : String(err));
setShowAuthPanel(true);
+ // Fall back: the opened tab can still import a copied invite link.
+ } finally {
+ if (waitTimeout) clearTimeout(waitTimeout);
+ if (bc && onInviteRequest) bc.removeEventListener("message", onInviteRequest);
+ bc?.close();
+ setAuthBusy(false);
}
+ return;
}
window.open(url.toString(), "_blank", "noopener,noreferrer");
diff --git a/examples/playground/src/sync-v0.ts b/examples/playground/src/sync-v0.ts
index c79e5acd..aa8ef635 100644
--- a/examples/playground/src/sync-v0.ts
+++ b/examples/playground/src/sync-v0.ts
@@ -11,6 +11,14 @@ export type AuthGrantMessageV1 = {
ts: number;
};
+export type AuthInviteRequestMessageV1 = {
+ t: "auth_invite_request_v1";
+ doc_id: string;
+ request_id: string;
+ from_replica_pk_hex: string;
+ ts: number;
+};
+
export function hexToBytes16(hex: string): Uint8Array {
return nodeIdToBytes16(hex);
}
diff --git a/examples/playground/tests/playground.spec.ts b/examples/playground/tests/playground.spec.ts
index fae78730..447cbc6d 100644
--- a/examples/playground/tests/playground.spec.ts
+++ b/examples/playground/tests/playground.spec.ts
@@ -661,6 +661,8 @@ test("open device auto-syncs so the scoped root label is visible", async ({ brow
await pageB.bringToFront();
await expect(pageB.getByText("Ready (memory)")).toBeVisible({ timeout: 60_000 });
+ await expect.poll(() => pageB.url()).toContain("invite_req=");
+ await expect.poll(() => pageB.url()).not.toContain("#invite=");
const rowB = treeRowByNodeId(pageB, nodeId);
await expect(rowB.getByRole("button", { name: "AASD" })).toBeVisible({ timeout: 60_000 });