From 15dcd8956e5773b445f4f1a79e6a30c40a01c7d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:42:46 +0000 Subject: [PATCH 1/2] fix(console): a share link's password travels in a header, and a sign-in-required link shows the sign-in path The share-link landing page sent a link's password as a `?password=` search param on the resolve request. It now sends it in the `X-Share-Password` request header, the name both producers read, and never in a request URL. The conversation `/messages` request carries the same header, so a protected conversation no longer reads as empty. A 401 from resolve is read by its `error.code`: `NEEDS_PASSWORD` and `WRONG_PASSWORD` show the password prompt, `SIGN_IN_REQUIRED` shows the console's sign-in path with `?redirect=` back to the link, and any other code shows the server's message. A password with a character above U+00FF, which a header cannot carry, is not sent; the prompt says why. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../src/pages/SharedRecordPage-11649.test.tsx | 234 ++++++++++++++++++ apps/console/src/pages/SharedRecordPage.tsx | 86 ++++++- .../src/pages/shared-record-shape.test.ts | 53 ++++ apps/console/src/pages/shared-record-shape.ts | 85 +++++++ 4 files changed, 445 insertions(+), 13 deletions(-) create mode 100644 apps/console/src/pages/SharedRecordPage-11649.test.tsx diff --git a/apps/console/src/pages/SharedRecordPage-11649.test.tsx b/apps/console/src/pages/SharedRecordPage-11649.test.tsx new file mode 100644 index 0000000000..fbdf0d4fa3 --- /dev/null +++ b/apps/console/src/pages/SharedRecordPage-11649.test.tsx @@ -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( + + + } /> + + , + ); +} + +/** 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> = []; + +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) { + 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(); + }); +}); diff --git a/apps/console/src/pages/SharedRecordPage.tsx b/apps/console/src/pages/SharedRecordPage.tsx index bbf0091672..5a0ea29292 100644 --- a/apps/console/src/pages/SharedRecordPage.tsx +++ b/apps/console/src/pages/SharedRecordPage.tsx @@ -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, @@ -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 ?? {}; @@ -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(null); const [error, setError] = useState(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(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(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; } @@ -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 { @@ -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([])); @@ -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 ( +
+ +

Sign in required

+

+ The owner shared this link with signed-in users only. +

+ +
+ ); + } + + if (gate === 'password') { return (
diff --git a/apps/console/src/pages/shared-record-shape.test.ts b/apps/console/src/pages/shared-record-shape.test.ts index 2d7d37868c..515f9aaa97 100644 --- a/apps/console/src/pages/shared-record-shape.test.ts +++ b/apps/console/src/pages/shared-record-shape.test.ts @@ -19,7 +19,11 @@ import { describe, expect, it } from 'vitest'; import { + canSendSharePassword, normalizeResolvedShare, + resolveGateOf, + SHARE_PASSWORD_HEADER, + shareRequestHeaders, type ResolveResponseBody, type ShareLink, } from './shared-record-shape'; @@ -78,3 +82,52 @@ describe('normalizeResolvedShare — both producers fold to one shape', () => { expect(normalizeResolvedShare(enveloped({ link: LINK })).record).toEqual({}); }); }); + +/** + * objectui#11649 — the request half of the same route. A link's password goes in + * the `X-Share-Password` header (never the URL), and a `401` is read by its + * `error.code`: both producers answer `NEEDS_PASSWORD`, `WRONG_PASSWORD` and + * `SIGN_IN_REQUIRED` with the same status, and only the code tells a password + * prompt from the sign-in path. + */ +describe('share-link request headers and 401 gates (objectui#11649)', () => { + it('names the header the producers read', () => { + expect(SHARE_PASSWORD_HEADER.toLowerCase()).toBe('x-share-password'); + }); + + it('sends the password in the header only when there is one', () => { + expect(shareRequestHeaders('s3cret')).toEqual({ + Accept: 'application/json', + [SHARE_PASSWORD_HEADER]: 's3cret', + }); + expect(shareRequestHeaders()).toEqual({ Accept: 'application/json' }); + expect(shareRequestHeaders('')).toEqual({ Accept: 'application/json' }); + }); + + it('reads each 401 code as its gate', () => { + const refusal = (code: string) => ({ success: false, error: { code, message: 'm' } }); + expect(resolveGateOf(refusal('NEEDS_PASSWORD'))).toBe('needs-password'); + expect(resolveGateOf(refusal('WRONG_PASSWORD'))).toBe('wrong-password'); + expect(resolveGateOf(refusal('SIGN_IN_REQUIRED'))).toBe('sign-in-required'); + }); + + it('names no gate for any other code, or for no body — the page then shows the message', () => { + expect(resolveGateOf({ error: { code: 'UNAUTHENTICATED', message: 'm' } })).toBeNull(); + // An inherited member is not a code. + expect(resolveGateOf({ error: { code: 'toString' } })).toBeNull(); + expect(resolveGateOf({ error: {} })).toBeNull(); + expect(resolveGateOf(null)).toBeNull(); + }); + + it('refuses a password with a character above U+00FF, which a header cannot carry', () => { + // The boundary is the Fetch standard's byte-string rule. This environment's + // `Headers` (happy-dom) does not enforce it, so the guard is pinned on its + // own: the last Latin-1 code point passes, the first one past it does not. + for (const ok of ['s3cret', 'café', 'pässwörd ÿ', 'ÿ']) { + expect(canSendSharePassword(ok)).toBe(true); + } + for (const bad of ['密码', 'pw😀', 'Ā']) { + expect(canSendSharePassword(bad)).toBe(false); + } + }); +}); diff --git a/apps/console/src/pages/shared-record-shape.ts b/apps/console/src/pages/shared-record-shape.ts index b419387e73..1fb5e5a945 100644 --- a/apps/console/src/pages/shared-record-shape.ts +++ b/apps/console/src/pages/shared-record-shape.ts @@ -9,6 +9,91 @@ * to load. */ +import type { RegisteredErrorCode } from '@objectstack/spec/api'; + +/** + * The request header a link's password travels in (objectui#11649). + * + * Both producers read it under this name on both public routes, `/resolve` and + * `/messages`: the sharing plugin through its `presentedPassword` helper, the + * runtime dispatcher's `/share-links` domain through `headerOf('x-share-password')`. + * Header names are case-insensitive, and the server's CORS allow-list spells it + * this way. Read once, from objectstack's source at the 17.7.0 release this + * console resolves; nothing in this repo re-derives it. + * + * ⛔ Never the `?password=` query parameter. A request URL ends up in browser + * history, in a referrer and in any access log that records URLs; a header does + * not. At that same release the server still reads the parameter, and reads it + * FIRST, for older clients — so a page that sent both would still be sending the + * password in the URL. This page sends the header alone. + */ +export const SHARE_PASSWORD_HEADER = 'X-Share-Password'; + +/** + * Whether `password` can travel in {@link SHARE_PASSWORD_HEADER} at all. + * + * A header value is a byte string: the Fetch standard's `Headers` refuses, with + * a `TypeError` and before any request leaves, a value with a character above + * U+00FF — a CJK or emoji password throws, `café` goes through. Measured once, + * in Chromium and in Node's own `Headers`, for objectui#11649; nothing in this + * repo re-derives it, because the test environment's `Headers` (happy-dom) does + * not enforce the rule — the shape test pins this guard, not the platform. The + * server reads the header as it arrives and declares no encoding for it, so the + * page cannot transcode such a password either — and ⛔ it may not fall back to + * the URL. The page says so instead of surfacing the `TypeError`. + */ +export function canSendSharePassword(password: string): boolean { + for (const ch of password) { + if ((ch.codePointAt(0) ?? 0) > 0xff) return false; + } + return true; +} + +/** + * The headers for a request to one of the public share-link routes. The password + * header is present only when the visitor has entered a password. + */ +export function shareRequestHeaders(password?: string): Record { + const headers: Record = { Accept: 'application/json' }; + if (password) headers[SHARE_PASSWORD_HEADER] = password; + return headers; +} + +/** + * What a `401` from resolve asks of the visitor. + * + * - `needs-password` — the link is protected and no password was sent. + * - `wrong-password` — a password was sent and it did not match. + * - `sign-in-required` — the link is shared with signed-in users only. A + * password prompt is the wrong answer here: no password opens it. + */ +export type ResolveGate = 'needs-password' | 'wrong-password' | 'sign-in-required'; + +/** + * The `error.code` of each gate, as both producers send it. Typed against the + * spec's error-code ledger, so a code the server never registered does not compile. + */ +const RESOLVE_GATE_BY_CODE = { + NEEDS_PASSWORD: 'needs-password', + WRONG_PASSWORD: 'wrong-password', + SIGN_IN_REQUIRED: 'sign-in-required', +} as const satisfies Partial>; + +/** The refusal envelope both producers answer with: `{ success: false, error: { code, message } }`. */ +export interface ResolveErrorBody { + error?: { code?: string; message?: string }; +} + +/** + * Read which gate a `401` from resolve names. `null` for any other code: the page + * then shows the server's message rather than guessing at a prompt. + */ +export function resolveGateOf(body: ResolveErrorBody | null | undefined): ResolveGate | null { + const code = body?.error?.code; + if (!code || !Object.prototype.hasOwnProperty.call(RESOLVE_GATE_BY_CODE, code)) return null; + return RESOLVE_GATE_BY_CODE[code as keyof typeof RESOLVE_GATE_BY_CODE]; +} + export interface ShareLink { id: string; token: string; From 126c0cbb70b63e17cb01b06e8f28016a0745bfdb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 02:46:14 +0000 Subject: [PATCH 2/2] chore(console): changeset for the share-link password header; type the console spy args Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .changeset/11649-share-link-password-header.md | 11 +++++++++++ .../console/src/pages/SharedRecordPage-11649.test.tsx | 2 +- apps/console/src/pages/shared-record-shape.ts | 6 +++--- 3 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 .changeset/11649-share-link-password-header.md diff --git a/.changeset/11649-share-link-password-header.md b/.changeset/11649-share-link-password-header.md new file mode 100644 index 0000000000..beaed72051 --- /dev/null +++ b/.changeset/11649-share-link-password-header.md @@ -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. diff --git a/apps/console/src/pages/SharedRecordPage-11649.test.tsx b/apps/console/src/pages/SharedRecordPage-11649.test.tsx index fbdf0d4fa3..70235b3e06 100644 --- a/apps/console/src/pages/SharedRecordPage-11649.test.tsx +++ b/apps/console/src/pages/SharedRecordPage-11649.test.tsx @@ -133,7 +133,7 @@ beforeEach(() => { afterEach(() => { // Nothing the page writes to the console carries the password. for (const spy of consoleSpies) { - for (const args of spy.mock.calls) { + for (const args of spy.mock.calls as unknown[][]) { expect(args.map((a) => String(a)).join(' ')).not.toContain(SECRET); } spy.mockRestore(); diff --git a/apps/console/src/pages/shared-record-shape.ts b/apps/console/src/pages/shared-record-shape.ts index 1fb5e5a945..4753fc201f 100644 --- a/apps/console/src/pages/shared-record-shape.ts +++ b/apps/console/src/pages/shared-record-shape.ts @@ -21,9 +21,9 @@ import type { RegisteredErrorCode } from '@objectstack/spec/api'; * this way. Read once, from objectstack's source at the 17.7.0 release this * console resolves; nothing in this repo re-derives it. * - * ⛔ Never the `?password=` query parameter. A request URL ends up in browser - * history, in a referrer and in any access log that records URLs; a header does - * not. At that same release the server still reads the parameter, and reads it + * ⛔ Never the `?password=` query parameter. A request URL ends up in every + * server, proxy and CDN log that records URLs, and in a captured network trace; + * a header does not. At that same release the server still reads the parameter, and reads it * FIRST, for older clients — so a page that sent both would still be sending the * password in the URL. This page sends the header alone. */