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
new file mode 100644
index 0000000000..70235b3e06
--- /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 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();
+ });
+});
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..4753fc201f 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 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.
+ */
+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;