diff --git a/backend/src/db/migrations/20250517002223_secret-share-to-specific-emails.ts b/backend/src/db/migrations/20250517002223_secret-share-to-specific-emails.ts
new file mode 100644
index 000000000..6a02ae4eb
--- /dev/null
+++ b/backend/src/db/migrations/20250517002223_secret-share-to-specific-emails.ts
@@ -0,0 +1,43 @@
+import { Knex } from "knex";
+
+import { TableName } from "../schemas";
+
+export async function up(knex: Knex): Promise {
+ if (await knex.schema.hasTable(TableName.SecretSharing)) {
+ const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt");
+ const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails");
+
+ if (!hasEncryptedSalt || !hasAuthorizedEmails) {
+ await knex.schema.alterTable(TableName.SecretSharing, (t) => {
+ // These two columns are only needed when secrets are shared with a specific list of emails
+
+ if (!hasEncryptedSalt) {
+ t.binary("encryptedSalt").nullable();
+ }
+
+ if (!hasAuthorizedEmails) {
+ t.json("authorizedEmails").nullable();
+ }
+ });
+ }
+ }
+}
+
+export async function down(knex: Knex): Promise {
+ if (await knex.schema.hasTable(TableName.SecretSharing)) {
+ const hasEncryptedSalt = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSalt");
+ const hasAuthorizedEmails = await knex.schema.hasColumn(TableName.SecretSharing, "authorizedEmails");
+
+ if (hasEncryptedSalt || hasAuthorizedEmails) {
+ await knex.schema.alterTable(TableName.SecretSharing, (t) => {
+ if (hasEncryptedSalt) {
+ t.dropColumn("encryptedSalt");
+ }
+
+ if (hasAuthorizedEmails) {
+ t.dropColumn("authorizedEmails");
+ }
+ });
+ }
+ }
+}
diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts
index 24ea26677..7de34708c 100644
--- a/backend/src/db/schemas/secret-sharing.ts
+++ b/backend/src/db/schemas/secret-sharing.ts
@@ -27,7 +27,9 @@ export const SecretSharingSchema = z.object({
password: z.string().nullable().optional(),
encryptedSecret: zodBuffer.nullable().optional(),
identifier: z.string().nullable().optional(),
- type: z.string().default("share")
+ type: z.string().default("share"),
+ encryptedSalt: zodBuffer.nullable().optional(),
+ authorizedEmails: z.unknown().nullable().optional()
});
export type TSecretSharing = z.infer;
diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts
index 37c8a052f..e712ee138 100644
--- a/backend/src/server/routes/v1/secret-sharing-router.ts
+++ b/backend/src/server/routes/v1/secret-sharing-router.ts
@@ -62,7 +62,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
}),
body: z.object({
hashedHex: z.string().min(1).optional(),
- password: z.string().optional()
+ password: z.string().optional(),
+ email: z.string().optional(),
+ hash: z.string().optional()
}),
response: {
200: z.object({
@@ -88,7 +90,9 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
sharedSecretId: req.params.id,
hashedHex: req.body.hashedHex,
password: req.body.password,
- orgId: req.permission?.orgId
+ orgId: req.permission?.orgId,
+ email: req.body.email,
+ hash: req.body.hash
});
if (sharedSecret.secret?.orgId) {
@@ -151,7 +155,8 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
secretValue: z.string(),
expiresAt: z.string(),
expiresAfterViews: z.number().min(1).optional(),
- accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization)
+ accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization),
+ emails: z.string().email().array().max(100).optional()
}),
response: {
200: z.object({
diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts
index 9649be722..e56b10e46 100644
--- a/backend/src/services/secret-sharing/secret-sharing-service.ts
+++ b/backend/src/services/secret-sharing/secret-sharing-service.ts
@@ -6,6 +6,7 @@ import { TSecretSharing } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
+import { logger } from "@app/lib/logger";
import { SecretSharingAccessType } from "@app/lib/types";
import { isUuidV4 } from "@app/lib/validator";
@@ -76,8 +77,11 @@ export const secretSharingServiceFactory = ({
password,
accessType,
expiresAt,
- expiresAfterViews
+ expiresAfterViews,
+ emails
}: TCreateSharedSecretDTO) => {
+ const appCfg = getConfig();
+
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
$validateSharedSecretExpiry(expiresAt);
@@ -94,6 +98,31 @@ export const secretSharingServiceFactory = ({
}
const encryptWithRoot = kmsService.encryptWithRootKey();
+
+ let salt: string | undefined;
+ let encryptedSalt: Buffer | undefined;
+ const orgEmails = [];
+
+ if (emails && emails.length > 0) {
+ const allOrgMembers = await orgDAL.findAllOrgMembers(orgId);
+
+ // Check to see that all emails are a part of the organization (if enforced) while also collecting a list of emails which are in the org
+ for (const email of emails) {
+ if (allOrgMembers.some((v) => v.user.email === email)) {
+ orgEmails.push(email);
+ // If the email is not part of the org, but access type / org settings require it
+ } else if (!org.allowSecretSharingOutsideOrganization || accessType === SecretSharingAccessType.Organization) {
+ throw new BadRequestError({
+ message: "Organization does not allow sharing secrets to members outside of this organization"
+ });
+ }
+ }
+
+ // Generate salt for signing email hashes (if emails are provided)
+ salt = crypto.randomBytes(32).toString("hex");
+ encryptedSalt = encryptWithRoot(Buffer.from(salt));
+ }
+
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));
const id = crypto.randomBytes(32).toString("hex");
@@ -112,11 +141,45 @@ export const secretSharingServiceFactory = ({
expiresAfterViews,
userId: actorId,
orgId,
- accessType
+ accessType,
+ authorizedEmails: emails && emails.length > 0 ? JSON.stringify(emails) : undefined,
+ encryptedSalt
});
const idToReturn = `${Buffer.from(newSharedSecret.identifier!, "hex").toString("base64url")}`;
+ // Loop through recipients and send out emails with unique access links
+ if (emails && salt) {
+ const user = await userDAL.findById(actorId);
+
+ if (!user) {
+ throw new NotFoundError({ message: `User with ID '${actorId}' not found` });
+ }
+
+ for await (const email of emails) {
+ try {
+ const hmac = crypto.createHmac("sha256", salt).update(email);
+ const hash = hmac.digest("hex");
+
+ // Only show the username to emails which are part of the organization
+ const respondentUsername = orgEmails.includes(email) ? user.username : undefined;
+
+ await smtpService.sendMail({
+ recipients: [email],
+ subjectLine: "A secret has been shared with you",
+ substitutions: {
+ name,
+ respondentUsername,
+ secretRequestUrl: `${appCfg.SITE_URL}/shared/secret/${idToReturn}?email=${encodeURIComponent(email)}&hash=${hash}`
+ },
+ template: SmtpTemplates.SecretRequestCompleted
+ });
+ } catch (e) {
+ logger.error(e, "Failed to send shared secret URL to a recipient's email.");
+ }
+ }
+ }
+
return { id: idToReturn };
};
@@ -390,8 +453,15 @@ export const secretSharingServiceFactory = ({
});
};
- /** Get's password-less secret. validates all secret's requested (must be fresh). */
- const getSharedSecretById = async ({ sharedSecretId, hashedHex, orgId, password }: TGetActiveSharedSecretByIdDTO) => {
+ /** Gets password-less secret. validates all secret's requested (must be fresh). */
+ const getSharedSecretById = async ({
+ sharedSecretId,
+ hashedHex,
+ orgId,
+ password,
+ email,
+ hash
+ }: TGetActiveSharedSecretByIdDTO) => {
const sharedSecret = isUuidV4(sharedSecretId)
? await secretSharingDAL.findOne({
id: sharedSecretId,
@@ -438,6 +508,32 @@ export const secretSharingServiceFactory = ({
});
}
+ const decryptWithRoot = kmsService.decryptWithRootKey();
+
+ if (sharedSecret.authorizedEmails && sharedSecret.encryptedSalt) {
+ // Verify both params were passed
+ if (!email || !hash) {
+ throw new BadRequestError({
+ message: "This secret is email protected. Parameters must include email and hash."
+ });
+
+ // Verify that email is authorized to view shared secret
+ } else if (!(sharedSecret.authorizedEmails as string[]).includes(email)) {
+ throw new UnauthorizedError({ message: "Email not authorized to view secret" });
+
+ // Verify that hash matches
+ } else {
+ const salt = decryptWithRoot(sharedSecret.encryptedSalt).toString();
+ const hmac = crypto.createHmac("sha256", salt).update(email);
+ const rebuiltHash = hmac.digest("hex");
+
+ if (rebuiltHash !== hash) {
+ throw new UnauthorizedError({ message: "Email not authorized to view secret" });
+ }
+ }
+ }
+
+ // Password checks
const isPasswordProtected = Boolean(sharedSecret.password);
const hasProvidedPassword = Boolean(password);
if (isPasswordProtected) {
@@ -452,7 +548,6 @@ export const secretSharingServiceFactory = ({
// If encryptedSecret is set, we know that this secret has been encrypted using KMS, and we can therefore do server-side decryption.
let decryptedSecretValue: Buffer | undefined;
if (sharedSecret.encryptedSecret) {
- const decryptWithRoot = kmsService.decryptWithRootKey();
decryptedSecretValue = decryptWithRoot(sharedSecret.encryptedSecret);
}
diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts
index 835d70eff..049dbb913 100644
--- a/backend/src/services/secret-sharing/secret-sharing-types.ts
+++ b/backend/src/services/secret-sharing/secret-sharing-types.ts
@@ -22,6 +22,7 @@ export type TSharedSecretPermission = {
accessType?: SecretSharingAccessType;
name?: string;
password?: string;
+ emails?: string[];
};
export type TCreatePublicSharedSecretDTO = {
@@ -37,6 +38,10 @@ export type TGetActiveSharedSecretByIdDTO = {
hashedHex?: string;
orgId?: string;
password?: string;
+
+ // For secrets shared with specific emails
+ email?: string;
+ hash?: string;
};
export type TValidateActiveSharedSecretDTO = TGetActiveSharedSecretByIdDTO & {
diff --git a/frontend/src/hooks/api/secretSharing/queries.ts b/frontend/src/hooks/api/secretSharing/queries.ts
index ace45526a..cfd505ff0 100644
--- a/frontend/src/hooks/api/secretSharing/queries.ts
+++ b/frontend/src/hooks/api/secretSharing/queries.ts
@@ -11,10 +11,13 @@ export const secretSharingKeys = {
allSecretRequests: () => ["secretRequests"] as const,
specificSecretRequests: ({ offset, limit }: { offset: number; limit: number }) =>
[...secretSharingKeys.allSecretRequests(), { offset, limit }] as const,
- getSecretById: (arg: { id: string; hashedHex: string | null; password?: string }) => [
- "shared-secret",
- arg
- ],
+ getSecretById: (arg: {
+ id: string;
+ hashedHex: string | null;
+ password?: string;
+ email?: string;
+ hash?: string;
+ }) => ["shared-secret", arg],
getSecretRequestById: (arg: { id: string }) => ["secret-request", arg] as const
};
@@ -70,20 +73,34 @@ export const useGetSecretRequests = ({
export const useGetActiveSharedSecretById = ({
sharedSecretId,
hashedHex,
- password
+ password,
+ email,
+ hash
}: {
sharedSecretId: string;
hashedHex: string | null;
password?: string;
+
+ // For secrets shared to specific emails (optional)
+ email?: string;
+ hash?: string;
}) => {
return useQuery({
- queryKey: secretSharingKeys.getSecretById({ id: sharedSecretId, hashedHex, password }),
+ queryKey: secretSharingKeys.getSecretById({
+ id: sharedSecretId,
+ hashedHex,
+ password,
+ email,
+ hash
+ }),
queryFn: async () => {
const { data } = await apiRequest.post(
`/api/v1/secret-sharing/shared/public/${sharedSecretId}`,
{
...(hashedHex && { hashedHex }),
- password
+ password,
+ email,
+ hash
}
);
diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts
index ab819cfb6..c35228fab 100644
--- a/frontend/src/hooks/api/secretSharing/types.ts
+++ b/frontend/src/hooks/api/secretSharing/types.ts
@@ -32,6 +32,7 @@ export type TCreateSharedSecretRequest = {
expiresAt: Date;
expiresAfterViews?: number;
accessType?: SecretSharingAccessType;
+ emails?: string[];
};
export type TCreateSecretRequestRequestDTO = {
diff --git a/frontend/src/pages/public/ShareSecretPage/ShareSecretPage.tsx b/frontend/src/pages/public/ShareSecretPage/ShareSecretPage.tsx
index 186949722..53cd09f65 100644
--- a/frontend/src/pages/public/ShareSecretPage/ShareSecretPage.tsx
+++ b/frontend/src/pages/public/ShareSecretPage/ShareSecretPage.tsx
@@ -91,7 +91,7 @@ export const ShareSecretPage = () => {
Infisical
- 156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
+ 235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
diff --git a/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx b/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx
index a2228e46b..0380cde0c 100644
--- a/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx
+++ b/frontend/src/pages/public/ShareSecretPage/components/ShareSecretForm.tsx
@@ -6,7 +6,19 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
-import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+ Button,
+ FormControl,
+ IconButton,
+ Input,
+ Select,
+ SelectItem,
+ Switch
+} from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api";
import { SecretSharingAccessType } from "@app/hooks/api/secretSharing";
@@ -33,7 +45,24 @@ const schema = z.object({
secret: z.string().min(1),
expiresIn: z.string(),
viewLimit: z.string(),
- accessType: z.nativeEnum(SecretSharingAccessType).optional()
+ accessType: z.nativeEnum(SecretSharingAccessType).optional(),
+ emails: z
+ .string()
+ .optional()
+ .refine(
+ (val) => {
+ if (!val) return true;
+ const emails = val
+ .split(",")
+ .map((email) => email.trim())
+ .filter((email) => email !== "");
+ if (emails.length > 100) return false;
+ return emails.every((email) => z.string().email().safeParse(email).success);
+ },
+ {
+ message: "Must be a comma-separated list of valid emails (max 100) or empty."
+ }
+ )
});
export type FormData = z.infer;
@@ -49,7 +78,7 @@ export const ShareSecretForm = ({
value,
allowSecretSharingOutsideOrganization = true
}: Props) => {
- const [secretLink, setSecretLink] = useState("");
+ const [secretLink, setSecretLink] = useState(null);
const [, isCopyingSecret, setCopyTextSecret] = useTimedReset({
initialState: "Copy to clipboard"
});
@@ -66,7 +95,9 @@ export const ShareSecretForm = ({
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
- secret: value || ""
+ secret: value || "",
+ viewLimit: "-1",
+ expiresIn: "3600000"
}
});
@@ -76,32 +107,45 @@ export const ShareSecretForm = ({
secret,
expiresIn,
viewLimit,
- accessType
+ accessType,
+ emails
}: FormData) => {
try {
const expiresAt = new Date(new Date().getTime() + Number(expiresIn));
+ const processedEmails = emails ? emails.split(",").map((e) => e.trim()) : undefined;
+
const { id } = await createSharedSecret.mutateAsync({
name,
password,
secretValue: secret,
expiresAt,
expiresAfterViews: viewLimit === "-1" ? undefined : Number(viewLimit),
- accessType
+ accessType,
+ emails: processedEmails
});
- const link = `${window.location.origin}/shared/secret/${id}`;
+ if (processedEmails && processedEmails.length > 0) {
+ setSecretLink("");
+ createNotification({
+ text: `Shared secret link emailed to ${processedEmails.length} user(s).`,
+ type: "success"
+ });
+ } else {
+ const link = `${window.location.origin}/shared/secret/${id}`;
+
+ setSecretLink(link);
+
+ navigator.clipboard.writeText(link);
+ setCopyTextSecret("secret");
+
+ createNotification({
+ text: "Shared secret link copied to clipboard.",
+ type: "success"
+ });
+ }
- setSecretLink(link);
reset();
-
- navigator.clipboard.writeText(link);
- setCopyTextSecret("secret");
-
- createNotification({
- text: "Shared secret link copied to clipboard.",
- type: "success"
- });
} catch (error) {
console.error(error);
createNotification({
@@ -111,152 +155,230 @@ export const ShareSecretForm = ({
}
};
- const hasSecretLink = Boolean(secretLink);
-
- return !hasSecretLink ? (
-
- ) : (
+
+ {!isPublic && (
+ (
+ Feature enforced by organization
+ )
+ }
+ errorText={error?.message}
+ isError={Boolean(error)}
+ >
+
+ onChange(
+ v ? SecretSharingAccessType.Organization : SecretSharingAccessType.Anyone
+ )
+ }
+ id="org-access-only"
+ >
+ Limit access to people within organization
+
+
+ )}
+ />
+ )}
+
+
+
+
+ Advanced Settings
+
+
+ (
+
+
+
+ )}
+ />
+ (
+
+
+
+ )}
+ />
+
+ {!isPublic && (
+ (
+
+
+
+ )}
+ />
+ )}
+
+
+
+
+
+
+
+
+ );
+
+ if (secretLink === "")
+ return (
+ <>
+
+
+ Shared secret link has been emailed to select users.
+
+
+ >
+ );
+
+ return (
<>
{secretLink}
@@ -265,7 +387,7 @@ export const ShareSecretForm = ({
colorSchema="secondary"
className="group relative ml-2"
onClick={() => {
- navigator.clipboard.writeText(secretLink);
+ navigator.clipboard.writeText(secretLink || "");
setCopyTextSecret("Copied");
}}
>
@@ -277,7 +399,7 @@ export const ShareSecretForm = ({
colorSchema="primary"
variant="outline_bg"
size="sm"
- onClick={() => setSecretLink("")}
+ onClick={() => setSecretLink(null)}
rightIcon={
}
>
Share Another Secret
diff --git a/frontend/src/pages/public/ViewSecretRequestByIDPage/ViewSecretRequestByIDPage.tsx b/frontend/src/pages/public/ViewSecretRequestByIDPage/ViewSecretRequestByIDPage.tsx
index bce742358..2bb5c7b0f 100644
--- a/frontend/src/pages/public/ViewSecretRequestByIDPage/ViewSecretRequestByIDPage.tsx
+++ b/frontend/src/pages/public/ViewSecretRequestByIDPage/ViewSecretRequestByIDPage.tsx
@@ -175,7 +175,7 @@ export const ViewSecretRequestByIDPage = () => {
Infisical
- 156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
+ 235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
diff --git a/frontend/src/pages/public/ViewSharedSecretByIDPage/ViewSharedSecretByIDPage.tsx b/frontend/src/pages/public/ViewSharedSecretByIDPage/ViewSharedSecretByIDPage.tsx
index 5112bd47e..389aee68f 100644
--- a/frontend/src/pages/public/ViewSharedSecretByIDPage/ViewSharedSecretByIDPage.tsx
+++ b/frontend/src/pages/public/ViewSharedSecretByIDPage/ViewSharedSecretByIDPage.tsx
@@ -38,6 +38,14 @@ export const ViewSharedSecretByIDPage = () => {
from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
select: (el) => el.key
});
+ const email = useSearch({
+ from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
+ select: (el) => el.email
+ });
+ const hash = useSearch({
+ from: ROUTE_PATHS.Public.ViewSharedSecretByIDPage.id,
+ select: (el) => el.hash
+ });
const [password, setPassword] = useState();
const { hashedHex, key } = extractDetailsFromUrl(urlEncodedKey);
@@ -49,7 +57,9 @@ export const ViewSharedSecretByIDPage = () => {
} = useGetActiveSharedSecretById({
sharedSecretId: id,
hashedHex,
- password
+ password,
+ email,
+ hash
});
const navigate = useNavigate();
@@ -57,15 +67,16 @@ export const ViewSharedSecretByIDPage = () => {
const isUnauthorized =
((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 401;
- const isForbidden =
- ((error as AxiosError)?.response?.data as { statusCode: number })?.statusCode === 403;
-
const isInvalidCredential =
((error as AxiosError)?.response?.data as { message: string })?.message ===
"Invalid credentials";
+ const isEmailUnauthorized =
+ ((error as AxiosError)?.response?.data as { message: string })?.message ===
+ "Email not authorized to view secret";
+
useEffect(() => {
- if (isUnauthorized && !isInvalidCredential) {
+ if (isUnauthorized && !isInvalidCredential && !isEmailUnauthorized) {
// persist current URL in session storage so that we can come back to this after successful login
sessionStorage.setItem(
SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL,
@@ -85,10 +96,10 @@ export const ViewSharedSecretByIDPage = () => {
});
}
- if (isForbidden) {
+ if (error) {
createNotification({
type: "error",
- text: "You do not have access to this shared secret."
+ text: ((error as AxiosError)?.response?.data as { message: string })?.message
});
}
}, [error]);
@@ -195,7 +206,7 @@ export const ViewSharedSecretByIDPage = () => {
Infisical
- 156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸
+ 235 2nd st, San Francisco, California, 94105, United States. 🇺🇸
diff --git a/frontend/src/pages/public/ViewSharedSecretByIDPage/route.tsx b/frontend/src/pages/public/ViewSharedSecretByIDPage/route.tsx
index 7b98dded3..7cbcb59a2 100644
--- a/frontend/src/pages/public/ViewSharedSecretByIDPage/route.tsx
+++ b/frontend/src/pages/public/ViewSharedSecretByIDPage/route.tsx
@@ -7,7 +7,9 @@ import { authKeys, fetchAuthToken } from "@app/hooks/api/auth/queries";
import { ViewSharedSecretByIDPage } from "./ViewSharedSecretByIDPage";
const SharedSecretByIDPageQuerySchema = z.object({
- key: z.string().catch("")
+ key: z.string().catch(""),
+ email: z.string().optional(),
+ hash: z.string().optional()
});
export const Route = createFileRoute("/shared/secret/$secretId")({