Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/11649-share-link-password-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/console': patch
---

The console's share-link landing page (`/s/TOKEN`) sends a link's password in a request header, and a link that needs sign-in shows the sign-in path (objectui#11649).

- **The password leaves the URL.** The page used to send the password as a `?password=` query parameter on `GET /api/v1/share-links/TOKEN/resolve`. It now sends it in the `X-Share-Password` request header, the name the server reads, and never in a request URL, so it no longer ends up in a server, proxy or CDN log that records request URLs. The conversation `/messages` request sends the same header. That route checks the password too, so a password-protected shared conversation used to show "This conversation has no messages yet." It now shows its messages.
- **A sign-in-required link shows the sign-in path.** The server refuses with `401` for three different reasons, and the page used to show a password prompt for all of them. It now reads the response's `error.code`. `NEEDS_PASSWORD` shows the prompt and `WRONG_PASSWORD` shows it with "Wrong password.". `SIGN_IN_REQUIRED`, a link shared with signed-in users only, shows "Sign in required" and a "Sign in" button that opens `/login?redirect=` back to the link, with no password field. A `401` with any other code shows the server's message.
- **A password a header cannot carry is not sent.** A header value cannot hold a character above U+00FF, such as a Chinese character or an emoji, and the browser refuses to send it. The page does not send such a password and does not fall back to the URL. The prompt says that the password cannot be sent and asks the visitor to get a different password from the link's owner. Until the server defines an encoding for this header, a link whose password has such a character cannot be opened from the console.

**Clause-②: no.** No export, prop, type member or i18n key is added or removed. The page's labels stay English literals.
234 changes: 234 additions & 0 deletions apps/console/src/pages/SharedRecordPage-11649.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11649 — the share-link landing page sends a link's password in the
* `X-Share-Password` request header, never in a request URL, and reads a `401`
* by its `error.code`.
*
* The stub answers the two public routes the way both producers do at the
* `@objectstack/*` release this console resolves: it reads the password from the
* `?password=` query parameter FIRST and from the header otherwise (so a page
* that still put it in the URL would be served — and caught by the URL pin, not
* by a refusal), and refuses with `401 { success: false, error: { code } }`:
* `NEEDS_PASSWORD` / `WRONG_PASSWORD` for a protected link, `SIGN_IN_REQUIRED`
* for a link shared with signed-in users only. `/messages` re-checks the
* password and answers `404` without it. Every visitor here is anonymous.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import SharedRecordPage from './SharedRecordPage';

const TOKEN = 'tok_abcdefgh';
const SECRET = 'correct-horse-77';

interface ShareFixture {
object_name: string;
password?: string;
audience?: 'anyone' | 'signed_in';
}

interface Call {
url: URL;
password: string | null;
answered: number;
}

const json = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });

const refuse = (status: number, code: string, message: string) =>
json(status, { success: false, error: { code, message } });

/** A server that answers `/share-links/:token/{resolve,messages}` like both producers. */
function shareServer(link: ShareFixture) {
const calls: Call[] = [];
const fetchStub = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const url = new URL(raw, 'http://localhost');
const headers = new Headers(init?.headers);
const presented = url.searchParams.get('password') ?? headers.get('x-share-password');
const answer = (res: Response) => {
calls.push({ url, password: headers.get('x-share-password'), answered: res.status });
return res;
};
const base = `/api/v1/share-links/${TOKEN}`;
const passes = !link.password || presented === link.password;

if (url.pathname === `${base}/resolve`) {
if (!passes) {
return answer(
presented
? refuse(401, 'WRONG_PASSWORD', 'Incorrect password')
: refuse(401, 'NEEDS_PASSWORD', 'This link requires a password'),
);
}
if (link.audience === 'signed_in') {
return answer(refuse(401, 'SIGN_IN_REQUIRED', 'Please sign in to view this link'));
}
return answer(
json(200, {
success: true,
data: {
record: { id: 'rec_1', name: 'Acme' },
link: {
id: 'sl_1',
token: TOKEN,
object_name: link.object_name,
record_id: 'rec_1',
permission: 'view',
audience: link.audience ?? 'anyone',
},
redactFields: [],
},
}),
);
}
if (url.pathname === `${base}/messages`) {
if (!passes) return answer(refuse(404, 'NOT_FOUND', 'Share link not found'));
return answer(json(200, { success: true, data: [] }));
}
return answer(new Response(null, { status: 404 }));
});
vi.stubGlobal('fetch', fetchStub);
return calls;
}

function renderLink() {
return render(
<MemoryRouter initialEntries={[`/s/${TOKEN}`]}>
<Routes>
<Route path="/s/:token" element={<SharedRecordPage />} />
</Routes>
</MemoryRouter>,
);
}

/** No request URL the page built carries the password, in any spelling. */
function expectNoUrlCarriesThePassword(calls: Call[]) {
expect(calls.length).toBeGreaterThan(0);
for (const { url } of calls) {
expect(url.searchParams.has('password')).toBe(false);
expect(url.href).not.toContain(SECRET);
expect(decodeURIComponent(url.href)).not.toContain(SECRET);
}
}

async function submitPassword(value: string) {
const input = await screen.findByPlaceholderText('Enter password');
fireEvent.change(input, { target: { value } });
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
}

const consoleSpies: Array<ReturnType<typeof vi.spyOn>> = [];

beforeEach(() => {
for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) {
consoleSpies.push(vi.spyOn(console, method));
}
});

afterEach(() => {
// Nothing the page writes to the console carries the password.
for (const spy of consoleSpies) {
for (const args of spy.mock.calls as unknown[][]) {
expect(args.map((a) => String(a)).join(' ')).not.toContain(SECRET);
}
spy.mockRestore();
}
consoleSpies.length = 0;
cleanup();
vi.unstubAllGlobals();
});

describe('SharedRecordPage — the password travels in a header (objectui#11649)', () => {
it('a protected link prompts, and the password reaches the server in the header and in no URL', async () => {
const calls = shareServer({ object_name: 'crm_account', password: SECRET });
renderLink();

expect(await screen.findByText('Password required')).toBeInTheDocument();
expect(calls).toHaveLength(1);
expect(calls[0].password).toBeNull();

await submitPassword(SECRET);

expect(await screen.findByText('Shared crm_account')).toBeInTheDocument();
const resolves = calls.filter((c) => c.url.pathname.endsWith('/resolve'));
expect(resolves).toHaveLength(2);
expect(resolves[1].password).toBe(SECRET);
expect(resolves[1].answered).toBe(200);
expectNoUrlCarriesThePassword(calls);
});

it('a wrong password shows the error on the prompt', async () => {
const calls = shareServer({ object_name: 'crm_account', password: SECRET });
renderLink();

await submitPassword('not-it');

expect(await screen.findByText('Wrong password.')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter password')).toHaveAttribute('type', 'password');
const last = calls[calls.length - 1];
expect(last.answered).toBe(401);
expect(last.password).toBe('not-it');
expect(calls.every((c) => !c.url.searchParams.has('password'))).toBe(true);
});

it('an unlocked conversation sends the password to /messages in the header too', async () => {
const calls = shareServer({ object_name: 'ai_conversations', password: SECRET });
renderLink();

await submitPassword(SECRET);

expect(await screen.findByText('This conversation has no messages yet.')).toBeInTheDocument();
const messages = calls.filter((c) => c.url.pathname.endsWith('/messages'));
expect(messages).toHaveLength(1);
// Without the header the route answers 404 and the page reads that as an
// empty conversation — the text above is the same either way, so the pin is
// on the request and the server's answer to it.
expect(messages[0].password).toBe(SECRET);
expect(messages[0].answered).toBe(200);
expectNoUrlCarriesThePassword(calls);
});

it('a password the header cannot carry is never sent, and the prompt says why', async () => {
const calls = shareServer({ object_name: 'crm_account', password: SECRET });
renderLink();

await submitPassword('密码');

expect(await screen.findByText(/can't be sent/)).toBeInTheDocument();
expect(screen.getByPlaceholderText('Enter password')).toBeInTheDocument();
// Only the first, password-less resolve went out.
expect(calls).toHaveLength(1);
expect(calls[0].password).toBeNull();
});
});

describe('SharedRecordPage — a sign-in-required link shows the sign-in path (objectui#11649)', () => {
it('shows the sign-in path back to this link, and no password prompt', async () => {
shareServer({ object_name: 'crm_account', audience: 'signed_in' });
const { container } = renderLink();

const signIn = await screen.findByRole('link', { name: 'Sign in' });
expect(signIn).toHaveAttribute('href', `/login?redirect=${encodeURIComponent(`/s/${TOKEN}`)}`);
expect(screen.getByText('Sign in required')).toBeInTheDocument();
expect(screen.queryByText('Password required')).not.toBeInTheDocument();
expect(container.querySelector('input[type="password"]')).toBeNull();
});

it('a 401 with a code the page does not know shows the server message, not a prompt', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => refuse(401, 'UNAUTHENTICATED', 'Session expired')),
);
const { container } = renderLink();

expect(await screen.findByText('Session expired')).toBeInTheDocument();
expect(screen.getByText("Can't open this link")).toBeInTheDocument();
await waitFor(() => expect(container.querySelector('input[type="password"]')).toBeNull());
expect(screen.queryByRole('link', { name: 'Sign in' })).not.toBeInTheDocument();
});
});
86 changes: 73 additions & 13 deletions apps/console/src/pages/SharedRecordPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@
* chat uses (in `readOnly` mode — no composer), so assistant tool calls show
* as proper proposed-plan / draft cards instead of a raw tool-result JSON
* dump. Other object kinds fall back to a generic JSON preview.
*
* A link's password travels in the `X-Share-Password` request header, never in
* the request URL (objectui#11649) — see `SHARE_PASSWORD_HEADER`. A `401` is
* read by its `error.code`, not by its status alone: a link shared with
* signed-in users only answers `401` too, and asking that visitor for a password
* sent them to a prompt nothing could satisfy. It gets the sign-in path instead.
*/

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { AlertTriangle, Link2, Loader2, MessageSquare } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useParams } from 'react-router-dom';
import { AlertTriangle, Link2, Loader2, LogIn, MessageSquare } from 'lucide-react';

import {
Button,
Expand All @@ -34,7 +40,14 @@ import {
} from '@object-ui/app-shell';
import { ChatbotEnhanced } from '@object-ui/plugin-chatbot';

import { normalizeResolvedShare, type ResolvedShare } from './shared-record-shape';
import {
canSendSharePassword,
normalizeResolvedShare,
resolveGateOf,
shareRequestHeaders,
type ResolvedShare,
type ResolveErrorBody,
} from './shared-record-shape';

function resolveServerUrl(): string {
const env = (import.meta as any).env ?? {};
Expand All @@ -46,30 +59,51 @@ function resolveServerUrl(): string {

export default function SharedRecordPage() {
const { token } = useParams<{ token: string }>();
const location = useLocation();
const serverUrl = useMemo(() => resolveServerUrl(), []);
const apiBase = `${serverUrl}/api/v1`;

const [data, setData] = useState<ResolvedShare | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [needsPassword, setNeedsPassword] = useState(false);
const [gate, setGate] = useState<'password' | 'sign-in' | null>(null);
const [password, setPassword] = useState('');
const [messages, setMessages] = useState<RawAiMessageRow[] | null>(null);
// The password that unlocked the link, for the conversation's `/messages`
// request: that route re-checks the password, so without it a protected
// conversation reads as having no messages.
const unlockedWith = useRef<string | undefined>(undefined);

const fetchResolve = useCallback(
async (pw?: string) => {
if (!token) return;
if (pw && !canSendSharePassword(pw)) {
// Stays on the prompt: the request is never built, so there is no
// answer to show — only why nothing was sent.
setError(
"This password has characters that can't be sent, such as Chinese characters or emoji. Ask the link's owner to set a password without them.",
);
return;
}
setLoading(true);
setError(null);
try {
const url = new URL(`${apiBase}/share-links/${encodeURIComponent(token)}/resolve`);
if (pw) url.searchParams.set('password', pw);
const res = await fetch(url.toString(), {
headers: { Accept: 'application/json' },
headers: shareRequestHeaders(pw),
});
if (res.status === 401) {
setNeedsPassword(true);
setError(pw ? 'Wrong password.' : null);
const body = (await res.json().catch(() => null)) as ResolveErrorBody | null;
const answer = resolveGateOf(body);
if (answer === 'sign-in-required') {
setGate('sign-in');
} else if (answer) {
setGate('password');
setError(answer === 'wrong-password' ? 'Wrong password.' : null);
} else {
setGate(null);
setError(body?.error?.message ?? `Failed to load (HTTP ${res.status}).`);
}
setLoading(false);
return;
}
Expand All @@ -90,8 +124,10 @@ export default function SharedRecordPage() {
return;
}
// Both envelopes fold to the same shape here — see `normalizeResolvedShare`.
setData(normalizeResolvedShare(await res.json()));
setNeedsPassword(false);
const resolved = normalizeResolvedShare(await res.json());
unlockedWith.current = pw;
setData(resolved);
setGate(null);
} catch (e: any) {
setError(e?.message ?? 'Failed to load shared content.');
} finally {
Expand All @@ -111,7 +147,7 @@ export default function SharedRecordPage() {
const url = `${apiBase}/share-links/${encodeURIComponent(
data.link.token,
)}/messages`;
fetch(url, { headers: { Accept: 'application/json' } })
fetch(url, { headers: shareRequestHeaders(unlockedWith.current) })
.then((r) => (r.ok ? r.json() : null))
.then((body) => setMessages(body?.data ?? []))
.catch(() => setMessages([]));
Expand Down Expand Up @@ -140,7 +176,31 @@ export default function SharedRecordPage() {
);
}

if (needsPassword) {
if (gate === 'sign-in') {
// The console's sign-in route, carrying `?redirect=` back to this link — the
// convention `LoginRedirect` uses: the target is the ROUTER's location, so a
// console mounted under a basename does not carry its mount into the value.
// A link rather than an automatic redirect: this page's requests send no
// bearer token, so they carry a session only as a same-origin cookie. Where
// that cookie does not reach the server, the visitor comes back signed in and
// still gets this answer — and an automatic redirect would then bounce
// between this page and the sign-in page without end.
const signInPath = `/login?redirect=${encodeURIComponent(location.pathname + location.search)}`;
return (
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-4 px-4 text-center">
<LogIn className="h-8 w-8 text-muted-foreground" />
<h1 className="text-lg font-semibold">Sign in required</h1>
<p className="text-sm text-muted-foreground">
The owner shared this link with signed-in users only.
</p>
<Button asChild size="sm">
<Link to={signInPath}>Sign in</Link>
</Button>
</div>
);
}

if (gate === 'password') {
return (
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-4 px-4 text-center">
<Link2 className="h-8 w-8 text-muted-foreground" />
Expand Down
Loading
Loading