feat: update client data

This commit is contained in:
2026-01-06 11:08:55 +01:00
parent 37164b560f
commit 704d0d1821
50 changed files with 1215 additions and 471 deletions

11
.vscode/settings.json vendored
View File

@@ -8,6 +8,15 @@
"source.organizeImports.biome": "explicit", "source.organizeImports.biome": "explicit",
"source.fixAll.biome": "explicit" "source.fixAll.biome": "explicit"
}, },
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
},
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.exclude": { "files.exclude": {
"**/.git": true, "**/.git": true,
"**/.svn": true, "**/.svn": true,
@@ -15,5 +24,5 @@
"**/CVS": true, "**/CVS": true,
"**/.DS_Store": true, "**/.DS_Store": true,
"**/Thumbs.db": true "**/Thumbs.db": true
}, }
} }

View File

@@ -6,6 +6,6 @@
"dependencies": { "dependencies": {
"@module/payment": "workspace:*", "@module/payment": "workspace:*",
"@platform/config": "workspace:*", "@platform/config": "workspace:*",
"zod": "4.1.13" "zod": "4.3.5"
} }
} }

View File

@@ -34,7 +34,7 @@
"@tailwindcss/vite": "4.1.17", "@tailwindcss/vite": "4.1.17",
"@tanstack/react-router": "1.139.9", "@tanstack/react-router": "1.139.9",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@valkyr/db": "npm:@jsr/valkyr__db@2.0.0", "@valkyr/db": "npm:@jsr/valkyr__db@3.0.1",
"@zitadel/react": "1.1.0", "@zitadel/react": "1.1.0",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
"clsx": "2.1.1", "clsx": "2.1.1",
@@ -48,10 +48,12 @@
"tailwind-merge": "3.4.0", "tailwind-merge": "3.4.0",
"tailwindcss": "4.1.17", "tailwindcss": "4.1.17",
"vaul": "1.1.2", "vaul": "1.1.2",
"zod": "4.1.13" "zod": "4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.39.1", "@eslint/js": "9.39.1",
"@tanstack/react-router-devtools": "1.144.0",
"@tanstack/router-plugin": "1.145.2",
"@types/node": "24.10.1", "@types/node": "24.10.1",
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",

View File

@@ -1,26 +0,0 @@
import { useNavigate } from "@tanstack/react-router";
import { useEffect } from "react";
import { zitadel } from "../../services/zitadel.ts";
export function CallbackView() {
const navigate = useNavigate();
useEffect(() => {
async function handleCallback() {
try {
const user = await zitadel.userManager.signinRedirectCallback();
if (user) {
navigate({ to: "/", replace: true });
} else {
navigate({ to: "/", replace: true });
}
} catch (error) {
console.error("Callback error", error);
navigate({ to: "/", replace: true });
}
}
handleCallback();
}, [navigate]);
return null;
}

View File

@@ -1,61 +0,0 @@
import { GalleryVerticalEnd } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Field, FieldDescription, FieldGroup, FieldSeparator } from "@/components/ui/field";
import { cn } from "@/lib/utils";
import { zitadel } from "@/services/zitadel.ts";
export function LoginForm({ className, ...props }: React.ComponentProps<"div">) {
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<form
onSubmit={(e) => {
e.preventDefault();
zitadel.authorize();
}}
>
<FieldGroup>
<div className="flex flex-col items-center gap-2 text-center">
<a href="#" className="flex flex-col items-center gap-2 font-medium">
<div className="flex size-8 items-center justify-center rounded-md">
<GalleryVerticalEnd className="size-6" />
</div>
<span className="sr-only">Valkyr Sandbox</span>
</a>
<h1 className="text-xl font-bold">Welcome to Valkyr Sandbox</h1>
<FieldDescription>
Don&apos;t have an account? <a href="#">Sign up</a>
</FieldDescription>
</div>
<Field>
<Button type="submit">Login with Zitadel</Button>
</Field>
<FieldSeparator>Or</FieldSeparator>
<Field className="grid gap-4 sm:grid-cols-2">
<Button variant="outline" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
fill="currentColor"
/>
</svg>
Continue with Apple
</Button>
<Button variant="outline" type="button">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
fill="currentColor"
/>
</svg>
Continue with Google
</Button>
</Field>
</FieldGroup>
</form>
<FieldDescription className="px-6 text-center">
By clicking continue, you agree to our <a href="#">Terms of Service</a> and <a href="#">Privacy Policy</a>.
</FieldDescription>
</div>
);
}

View File

@@ -1,11 +0,0 @@
import { LoginForm } from "./components/login-form.tsx";
export function LoginView() {
return (
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div className="w-full max-w-sm">
<LoginForm />
</div>
</div>
);
}

View File

@@ -1,3 +0,0 @@
import { Controller } from "@/lib/controller.tsx";
export class DashboardController extends Controller {}

View File

@@ -1,7 +0,0 @@
import { makeControllerComponent } from "@/lib/controller.tsx";
import { DashboardController } from "./dashboard.controller.ts";
export const DashboardView = makeControllerComponent(DashboardController, () => {
return <div>Dashboard</div>;
});

View File

@@ -1,7 +1,7 @@
import { GalleryVerticalEnd } from "lucide-react"; import { GalleryVerticalEnd } from "lucide-react";
import { Controller } from "@/lib/controller.tsx"; import { Controller } from "@/lib/controller.tsx";
import { User } from "@/services/user.ts"; import { auth } from "@/services/auth.ts";
type Tenant = { type Tenant = {
name: string; name: string;
@@ -13,7 +13,7 @@ export class AppSiderbarController extends Controller<{
tenant: Tenant; tenant: Tenant;
}> { }> {
async onInit() { async onInit() {
const user = await User.resolve(); const user = auth.user;
if (user === undefined) { if (user === undefined) {
return { return {
tenant: { tenant: {

View File

@@ -12,20 +12,25 @@ import {
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { LoadingSwap } from "@/components/ui/loading-swap"; import { LoadingSwap } from "@/components/ui/loading-swap";
import { SidebarMenuSubButton } from "@/components/ui/sidebar.tsx"; import { SidebarMenuSubButton } from "@/components/ui/sidebar.tsx";
import { makeControllerComponent } from "../../lib/controller.tsx"; import { makeControllerComponent } from "../../lib/controller.tsx";
import { CreateBeneficiaryController } from "./create-beneficiary.controller.ts"; import { FieldDescription, FieldGroup, FieldLabel } from "../ui/field.tsx";
import { CreateBeneficiaryController } from "./create.controller.ts";
export const DialogCreateBeneficiary = makeControllerComponent( export const DialogCreateBeneficiary = makeControllerComponent(
CreateBeneficiaryController, CreateBeneficiaryController,
({ title, setLabel, submit, isSubmitting, ...props }) => { ({ title, label, setLabel, submit, isSubmitting, ...props }) => {
const labelId = useId(); const labelId = useId();
return ( return (
<Dialog> <Dialog>
<form> <form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<SidebarMenuSubButton className="cursor-pointer bg-secondary hover:bg-secondary/70"> <SidebarMenuSubButton className="cursor-pointer bg-secondary hover:bg-secondary/70">
<props.icon /> <span>{title}</span> <props.icon /> <span>{title}</span>
@@ -33,20 +38,25 @@ export const DialogCreateBeneficiary = makeControllerComponent(
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[425px]"> <DialogContent className="sm:max-w-[425px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Create beneficiary</DialogTitle> <DialogTitle>Create Beneficiary</DialogTitle>
<DialogDescription>Create a payment ledger and its supported currencies</DialogDescription> <DialogDescription>Create a payment ledger and its supported currencies</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-4"> <FieldGroup>
<div className="grid gap-3"> <FieldLabel htmlFor={labelId}>Label</FieldLabel>
<Label htmlFor={labelId}>Label</Label> <Input
<Input id={labelId} name="label" onChange={({ target: { value } }) => setLabel(value)} /> id={labelId}
</div> name="label"
</div> value={label}
onChange={({ target: { value } }) => setLabel(value)}
required
/>
<FieldDescription>Enter the identifying label for the Beneficiary</FieldDescription>
</FieldGroup>
<DialogFooter> <DialogFooter>
<DialogClose asChild> <DialogClose asChild>
<Button variant="outline">Cancel</Button> <Button variant="outline">Cancel</Button>
</DialogClose> </DialogClose>
<Button type="submit" onClick={submit}> <Button type="submit">
<LoadingSwap isLoading={isSubmitting}>Create</LoadingSwap> <LoadingSwap isLoading={isSubmitting}>Create</LoadingSwap>
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -3,32 +3,32 @@ import type { LucideIcon } from "lucide-react";
import { payment } from "@/database/payment.ts"; import { payment } from "@/database/payment.ts";
import { Controller } from "@/lib/controller.tsx"; import { Controller } from "@/lib/controller.tsx";
import { api, getSuccessResponse } from "@/services/api.ts"; import { api, getSuccessResponse } from "@/services/api.ts";
import { User } from "@/services/user.ts"; import { auth } from "@/services/auth.ts";
export class CreateBeneficiaryController extends Controller< export class CreateBeneficiaryController extends Controller<
{ {
isSubmitting: boolean; isSubmitting: boolean;
label: string;
}, },
{ title: string; icon: LucideIcon } { title: string; icon: LucideIcon }
> { > {
#label?: string;
async onInit() { async onInit() {
return { return {
isSubmitting: false, isSubmitting: false,
label: "",
}; };
} }
setLabel(label: string) { setLabel(label: string) {
this.#label = label; this.setState("label", label);
} }
async submit() { async submit() {
this.setState("isSubmitting", true); this.setState("isSubmitting", true);
const res = await api.payment.benficiaries.create({ const res = await api.payment.benficiaries.create({
body: { body: {
tenantId: await User.getTenantId(), tenantId: auth.user.tenant.id,
label: this.#label, label: this.state.label,
}, },
}); });
if ("data" in res) { if ("data" in res) {
@@ -36,6 +36,9 @@ export class CreateBeneficiaryController extends Controller<
.collection("beneficiary") .collection("beneficiary")
.insertOne(await getSuccessResponse(api.payment.benficiaries.id({ params: { id: res.data } }))); .insertOne(await getSuccessResponse(api.payment.benficiaries.id({ params: { id: res.data } })));
} }
this.setState("isSubmitting", false); this.setState({
isSubmitting: false,
label: "",
});
} }
} }

View File

@@ -0,0 +1,15 @@
import { makeControllerComponent } from "@/lib/controller.tsx";
import { ReadBeneficiaryController } from "./read.controller.ts";
export const BeneficiaryComponent = makeControllerComponent(ReadBeneficiaryController, ({ beneficiary }) => {
if (beneficiary === undefined) {
return (
<div className="w-full flex flex-col pt-20 items-center justify-center text-center">
<h1 className="text-2xl font-semibold tracking-tight">Beneficiary Not Found</h1>
<p className="text-muted-foreground mt-2 max-w-sm">Beneficiary with id "xxx" cannot be found.</p>
</div>
);
}
return <pre>{JSON.stringify(beneficiary, null, 2)}</pre>;
});

View File

@@ -0,0 +1,37 @@
import type { Beneficiary } from "@module/payment/client";
import { payment } from "@/database/payment.ts";
import { Controller } from "@/lib/controller.tsx";
import { auth } from "@/services/auth.ts";
export class ReadBeneficiaryController extends Controller<
{
beneficiary?: Beneficiary;
},
{
id: string;
}
> {
#subscription?: any;
async onInit() {
await this.#subscribe(this.props.id);
}
async onResolve() {
await this.#subscribe(this.props.id);
}
async onDestroy(): Promise<void> {
this.#subscription?.unsubscribe();
}
async #subscribe(_id: string) {
this.#subscription?.unsubscribe();
this.#subscription = await payment
.collection("beneficiary")
.subscribe({ _id, tenantId: auth.user.tenant.id }, { limit: 1 }, (beneficiary) => {
this.setState("beneficiary", beneficiary);
});
}
}

View File

@@ -2,11 +2,10 @@ import type { Beneficiary } from "@module/payment/client";
import { Book, CirclePlusIcon, type LucideIcon, SquareTerminal } from "lucide-react"; import { Book, CirclePlusIcon, type LucideIcon, SquareTerminal } from "lucide-react";
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import { DialogCreateBeneficiary } from "@/components/beneficiary/create.component.tsx";
import { loadBeneficiaries, payment } from "@/database/payment.ts"; import { loadBeneficiaries, payment } from "@/database/payment.ts";
import { Controller } from "@/lib/controller.tsx"; import { Controller } from "@/lib/controller.tsx";
import { User } from "@/services/user.ts"; import { auth } from "@/services/auth.ts";
import { DialogCreateBeneficiary } from "./payment/create-beneficiary.tsx";
export class NavPaymentController extends Controller<{ export class NavPaymentController extends Controller<{
items: MenuItem[]; items: MenuItem[];
@@ -19,7 +18,7 @@ export class NavPaymentController extends Controller<{
items: [ items: [
{ {
title: "Dashboard", title: "Dashboard",
url: "/payment", url: "/",
icon: SquareTerminal, icon: SquareTerminal,
}, },
], ],
@@ -31,7 +30,7 @@ export class NavPaymentController extends Controller<{
this.#subscriptions.push( this.#subscriptions.push(
await payment await payment
.collection("beneficiary") .collection("beneficiary")
.subscribe({ tenantId: await User.getTenantId() }, { sort: { label: 1 } }, (beneficiaries) => { .subscribe({ tenantId: auth.user.tenant.id }, { sort: { label: 1 } }, (beneficiaries) => {
this.setState("items", this.#getMenuItems(beneficiaries)); this.setState("items", this.#getMenuItems(beneficiaries));
}), }),
); );
@@ -41,7 +40,7 @@ export class NavPaymentController extends Controller<{
return [ return [
{ {
title: "Dashboard", title: "Dashboard",
url: "/payment", url: "/",
icon: SquareTerminal, icon: SquareTerminal,
}, },
{ {
@@ -57,7 +56,7 @@ export class NavPaymentController extends Controller<{
}, },
...beneficiaries.map((beneficiary) => ({ ...beneficiaries.map((beneficiary) => ({
title: beneficiary.label ?? "Unlabeled", title: beneficiary.label ?? "Unlabeled",
url: `/payment/${beneficiary._id}`, url: `/beneficiaries/${beneficiary._id}`,
})), })),
], ],
}, },

View File

@@ -39,7 +39,7 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="field-group" data-slot="field-group"
className={cn( className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4", "group/field-group @container/field-group flex w-full flex-col gap-3 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className, className,
)} )}
{...props} {...props}

View File

@@ -2,9 +2,9 @@ import type { Beneficiary } from "@module/payment/client";
import { loadBeneficiaries, payment } from "@/database/payment.ts"; import { loadBeneficiaries, payment } from "@/database/payment.ts";
import { Controller } from "@/lib/controller.tsx"; import { Controller } from "@/lib/controller.tsx";
import { User } from "@/services/user.ts"; import { auth } from "@/services/auth.ts";
export class PaymentDashboardController extends Controller<{ export class DashboardController extends Controller<{
beneficiaries: Beneficiary[]; beneficiaries: Beneficiary[];
}> { }> {
#subscriptions: any[] = []; #subscriptions: any[] = [];
@@ -28,7 +28,7 @@ export class PaymentDashboardController extends Controller<{
this.#subscriptions.push( this.#subscriptions.push(
await payment await payment
.collection("beneficiary") .collection("beneficiary")
.subscribe({ tenantId: await User.getTenantId() }, { sort: { label: 1 } }, (documents) => { .subscribe({ tenantId: auth.user.tenant.id }, { sort: { label: 1 } }, (documents) => {
this.setState("beneficiaries", documents); this.setState("beneficiaries", documents);
}), }),
); );

View File

@@ -1,43 +1,89 @@
import type { Account, Beneficiary, Ledger, Transaction, Wallet } from "@module/payment/client"; import {
import { IndexedDatabase } from "@valkyr/db"; AccountSchema,
BeneficiarySchema,
LedgerSchema,
TransactionSchema,
WalletSchema,
} from "@module/payment/client";
import { IndexedDB } from "@valkyr/db";
import { api } from "@/services/api.ts"; import { api } from "@/services/api.ts";
export const payment = new IndexedDatabase<{ export const payment = new IndexedDB({
account: Account;
beneficiary: Beneficiary;
ledger: Ledger;
transaction: Transaction;
wallet: Wallet;
}>({
name: "valkyr-sandbox:payment", name: "valkyr-sandbox:payment",
registrars: [ registrars: [
{ {
name: "account", name: "account",
indexes: [["_id", { unique: true }], ["walletId"]], schema: AccountSchema.shape,
indexes: [
{
field: "_id",
kind: "primary",
},
{
field: "walletId",
kind: "shared",
},
],
}, },
{ {
name: "beneficiary", name: "beneficiary",
indexes: [["_id", { unique: true }], ["tenantId"]], schema: BeneficiarySchema.shape,
indexes: [
{
field: "_id",
kind: "primary",
},
{
field: "tenantId",
kind: "shared",
},
],
}, },
{ {
name: "ledger", name: "ledger",
indexes: [["_id", { unique: true }], ["beneficiaryId"]], schema: LedgerSchema.shape,
indexes: [
{
field: "_id",
kind: "primary",
},
{
field: "beneficiaryId",
kind: "shared",
},
],
}, },
{ {
name: "transaction", name: "transaction",
indexes: [["_id", { unique: true }]], schema: TransactionSchema.shape,
indexes: [
{
field: "_id",
kind: "primary",
},
],
}, },
{ {
name: "wallet", name: "wallet",
indexes: [["_id", { unique: true }], ["ledgerId"]], schema: WalletSchema.shape,
indexes: [
{
field: "_id",
kind: "primary",
},
{
field: "ledgerId",
kind: "shared",
},
],
}, },
], ] as const,
}); });
export async function loadBeneficiaries(): Promise<void> { export async function loadBeneficiaries(): Promise<void> {
const result = await api.payment.benficiaries.list(); const result = await api.payment.benficiaries.list();
if ("data" in result) { if ("data" in result) {
payment.collection("beneficiary").insertMany(result.data); payment.collection("beneficiary").insert(result.data);
} }
} }

View File

@@ -50,7 +50,7 @@ export abstract class Controller<
return; return;
} }
const state = await this.onInit(); const state = await this.onInit();
if (this.#destroyed === false) { if (this.#destroyed === false && state !== undefined) {
this.setState(state); this.setState(state);
} }
this.#initiated = true; this.#initiated = true;
@@ -64,10 +64,7 @@ export abstract class Controller<
if (this.onResolve === undefined) { if (this.onResolve === undefined) {
return; return;
} }
const state: Partial<TState> = await this.onResolve(); await this.onResolve();
if (this.#destroyed === false) {
this.setState({ ...this.state, ...state });
}
} }
async $destroy(): Promise<void> { async $destroy(): Promise<void> {
@@ -84,16 +81,12 @@ export abstract class Controller<
/** /**
* Called every time props change (including first mount). * Called every time props change (including first mount).
*/ */
async onInit(): Promise<Partial<TState>> { async onInit(): Promise<Partial<TState> | void> {}
return {};
}
/** /**
* Called every time props change (including first mount). * Called every time props change (including first mount).
*/ */
async onResolve(): Promise<Partial<TState>> { async onResolve(): Promise<void> {}
return {};
}
/** /**
* Called when the controller is destroyed. * Called when the controller is destroyed.

28
app/src/lib/router.ts Normal file
View File

@@ -0,0 +1,28 @@
import { createRouter } from "@tanstack/react-router";
import { auth } from "@/services/auth.ts";
import { routeTree } from "../routeTree.gen.ts";
export const router = createRouter({
routeTree,
defaultPreload: "intent",
scrollRestoration: true,
context: {
auth,
},
});
export function getRouteParam(key: string): string {
return getRouteParams()[key] ?? "";
}
export function getRouteParams(): Record<string, string> {
return router.state.matches.at(-1)?.params ?? {};
}
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}

View File

@@ -1,57 +0,0 @@
import { createRootRoute, createRoute, createRouter, redirect } from "@tanstack/react-router";
import { AppView } from "@/app/app.view.tsx";
import { CallbackView } from "@/app/auth/callback.view.tsx";
import { LoginView } from "@/app/auth/login.view.tsx";
import { DashboardView } from "@/app/dashboard/dashboard.view.tsx";
import { zitadel } from "@/services/zitadel.ts";
import { PaymentDashboardView } from "../app/payment/dashboard/dashboard.view.tsx";
const root = createRootRoute();
const callback = createRoute({
getParentRoute: () => root,
path: "/auth/callback",
component: CallbackView,
});
const login = createRoute({
getParentRoute: () => root,
path: "/login",
component: LoginView,
});
const app = createRoute({
id: "app",
getParentRoute: () => root,
beforeLoad: async () => {
const user = await zitadel.userManager.getUser();
if (user === null) {
throw redirect({ to: "/login" });
}
if (user.expired === true) {
throw redirect({ to: "/login" });
}
},
component: AppView,
});
const dashboard = createRoute({
getParentRoute: () => app,
path: "/",
component: DashboardView,
});
const payment = [
createRoute({
getParentRoute: () => app,
path: "/payment",
component: PaymentDashboardView,
}),
];
root.addChildren([app, login, callback]);
app.addChildren([dashboard, ...payment]);
export const router = createRouter({ routeTree: root });

View File

@@ -3,14 +3,9 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { ThemeProvider } from "@/components/theme-provider.tsx"; import { ThemeProvider } from "@/components/theme-provider.tsx";
import { router } from "@/lib/router.tsx"; import { router } from "@/lib/router";
import "./index.css";
declare module "@tanstack/react-router" { import "./index.css";
interface Register {
router: typeof router;
}
}
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
if (rootElement === null) { if (rootElement === null) {

126
app/src/routeTree.gen.ts Normal file
View File

@@ -0,0 +1,126 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as CallbackRouteImport } from './routes/callback'
import { Route as AuthRouteRouteImport } from './routes/_auth/route'
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
import { Route as AuthBeneficiariesBeneficiaryIdRouteImport } from './routes/_auth/beneficiaries/$beneficiaryId'
const CallbackRoute = CallbackRouteImport.update({
id: '/callback',
path: '/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AuthRouteRoute = AuthRouteRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const AuthIndexRoute = AuthIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthRouteRoute,
} as any)
const AuthBeneficiariesBeneficiaryIdRoute =
AuthBeneficiariesBeneficiaryIdRouteImport.update({
id: '/beneficiaries/$beneficiaryId',
path: '/beneficiaries/$beneficiaryId',
getParentRoute: () => AuthRouteRoute,
} as any)
export interface FileRoutesByFullPath {
'/callback': typeof CallbackRoute
'/': typeof AuthIndexRoute
'/beneficiaries/$beneficiaryId': typeof AuthBeneficiariesBeneficiaryIdRoute
}
export interface FileRoutesByTo {
'/callback': typeof CallbackRoute
'/': typeof AuthIndexRoute
'/beneficiaries/$beneficiaryId': typeof AuthBeneficiariesBeneficiaryIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_auth': typeof AuthRouteRouteWithChildren
'/callback': typeof CallbackRoute
'/_auth/': typeof AuthIndexRoute
'/_auth/beneficiaries/$beneficiaryId': typeof AuthBeneficiariesBeneficiaryIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/callback' | '/' | '/beneficiaries/$beneficiaryId'
fileRoutesByTo: FileRoutesByTo
to: '/callback' | '/' | '/beneficiaries/$beneficiaryId'
id:
| '__root__'
| '/_auth'
| '/callback'
| '/_auth/'
| '/_auth/beneficiaries/$beneficiaryId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
AuthRouteRoute: typeof AuthRouteRouteWithChildren
CallbackRoute: typeof CallbackRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/callback': {
id: '/callback'
path: '/callback'
fullPath: '/callback'
preLoaderRoute: typeof CallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth': {
id: '/_auth'
path: ''
fullPath: ''
preLoaderRoute: typeof AuthRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/': {
id: '/_auth/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof AuthIndexRouteImport
parentRoute: typeof AuthRouteRoute
}
'/_auth/beneficiaries/$beneficiaryId': {
id: '/_auth/beneficiaries/$beneficiaryId'
path: '/beneficiaries/$beneficiaryId'
fullPath: '/beneficiaries/$beneficiaryId'
preLoaderRoute: typeof AuthBeneficiariesBeneficiaryIdRouteImport
parentRoute: typeof AuthRouteRoute
}
}
}
interface AuthRouteRouteChildren {
AuthIndexRoute: typeof AuthIndexRoute
AuthBeneficiariesBeneficiaryIdRoute: typeof AuthBeneficiariesBeneficiaryIdRoute
}
const AuthRouteRouteChildren: AuthRouteRouteChildren = {
AuthIndexRoute: AuthIndexRoute,
AuthBeneficiariesBeneficiaryIdRoute: AuthBeneficiariesBeneficiaryIdRoute,
}
const AuthRouteRouteWithChildren = AuthRouteRoute._addFileChildren(
AuthRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
AuthRouteRoute: AuthRouteRouteWithChildren,
CallbackRoute: CallbackRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

19
app/src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import type { AuthContext } from "@/services/auth.ts";
export const Route = createRootRouteWithContext<{
auth: AuthContext;
}>()({
component: RootComponent,
});
function RootComponent() {
return (
<>
<Outlet />
<TanStackRouterDevtools position="bottom-right" initialIsOpen={false} />
</>
);
}

View File

@@ -0,0 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
import { BeneficiaryComponent } from "@/components/beneficiary/read.component.tsx";
export const Route = createFileRoute("/_auth/beneficiaries/$beneficiaryId")({
component: BeneficiaryRoute,
});
function BeneficiaryRoute() {
const { beneficiaryId } = Route.useParams();
return <BeneficiaryComponent id={beneficiaryId} />;
}

View File

@@ -1,8 +1,9 @@
import { createFileRoute } from "@tanstack/react-router";
import { DashboardController } from "@/controllers/dashboard.controller.ts";
import { makeControllerComponent } from "@/lib/controller.tsx"; import { makeControllerComponent } from "@/lib/controller.tsx";
import { PaymentDashboardController } from "./dashboard.controller.ts"; const DashboardComponent = makeControllerComponent(DashboardController, ({ beneficiaries }) => {
export const PaymentDashboardView = makeControllerComponent(PaymentDashboardController, ({ beneficiaries }) => {
if (beneficiaries.length === 0) { if (beneficiaries.length === 0) {
return ( return (
<div className="w-full flex flex-col pt-20 items-center justify-center text-center"> <div className="w-full flex flex-col pt-20 items-center justify-center text-center">
@@ -20,3 +21,7 @@ export const PaymentDashboardView = makeControllerComponent(PaymentDashboardCont
</div> </div>
); );
}); });
export const Route = createFileRoute("/_auth/")({
component: DashboardComponent,
});

View File

@@ -1,10 +1,20 @@
import { Outlet } from "@tanstack/react-router"; import { createFileRoute, Outlet } from "@tanstack/react-router";
import { AppSidebar } from "@/components/app-sidebar"; import { AppSidebar } from "@/components/app-sidebar";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
export function AppView() { export const Route = createFileRoute("/_auth")({
beforeLoad: async ({ context: { auth } }) => {
await auth.resolve();
if (auth.isAuthenticated === false) {
throw auth.login();
}
},
component: AppLayout,
});
function AppLayout() {
return ( return (
<SidebarProvider <SidebarProvider
style={ style={

View File

@@ -0,0 +1,35 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import z from "zod";
import { zitadel } from "@/services/zitadel.ts";
export const Route = createFileRoute("/callback")({
validateSearch: z.object({
code: z.string(),
}),
component: AuthComponent,
});
function AuthComponent() {
const navigate = useNavigate();
const [error, setError] = useState();
useEffect(() => {
zitadel.userManager
.signinRedirectCallback()
.then(() => {
navigate({ to: "/", replace: true });
})
.catch((error) => {
console.error("Callback error", error);
setError(error);
});
}, [navigate]);
if (error) {
return <div>Error!</div>;
}
return null;
}

32
app/src/services/auth.ts Normal file
View File

@@ -0,0 +1,32 @@
import { User } from "@/services/user.ts";
import { zitadel } from "@/services/zitadel.ts";
export const auth: AuthContext = {
get isAuthenticated() {
return User.isAuthenticated;
},
get user() {
return User.instance;
},
async resolve() {
await User.resolve();
},
async login() {
await zitadel.authorize();
},
async logout() {
await zitadel.signout();
},
};
export type AuthContext = {
isAuthenticated: boolean;
user: User;
resolve: () => Promise<void>;
login: () => Promise<void>;
logout: () => Promise<void>;
};

View File

@@ -1,4 +1,3 @@
import { redirect } from "@tanstack/react-router";
import z from "zod"; import z from "zod";
import { type ZitadelUser, zitadel } from "./zitadel.ts"; import { type ZitadelUser, zitadel } from "./zitadel.ts";
@@ -35,20 +34,23 @@ export class User {
this.tenant = tenant; this.tenant = tenant;
} }
static async getTenantId() { static get isAuthenticated() {
return User.get().then((user) => user.tenant.id); return cached !== undefined;
} }
static async getTenantName() { static get instance() {
return User.get().then((user) => user.tenant.name); if (cached === undefined) {
} throw zitadel.authorize();
static async get() {
const user = await User.resolve();
if (user === undefined) {
throw redirect({ to: "/login" });
} }
return user; return cached;
}
static get tenantId() {
return User.instance.tenant.id;
}
static get tenantName() {
return User.instance.tenant.name;
} }
static async resolve() { static async resolve() {

View File

@@ -4,7 +4,7 @@ const config: ZitadelConfig = {
authority: "https://iam.valkyrjs.com", authority: "https://iam.valkyrjs.com",
project_resource_id: "348389288439709700", project_resource_id: "348389288439709700",
client_id: "348389308220112900", client_id: "348389308220112900",
redirect_uri: "http://localhost:5173/auth/callback", redirect_uri: "http://localhost:5173/callback",
post_logout_redirect_uri: "http://localhost:5173", post_logout_redirect_uri: "http://localhost:5173",
response_type: "code", response_type: "code",
scope: "openid profile email urn:zitadel:iam:user:metadata urn:zitadel:iam:org:id:348388915649970180", scope: "openid profile email urn:zitadel:iam:user:metadata urn:zitadel:iam:org:id:348388915649970180",

View File

@@ -1,12 +1,20 @@
import path from "node:path"; import path from "node:path";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [
tailwindcss(),
tanstackRouter({
target: "react",
autoCodeSplitting: true,
}),
react(),
],
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),

View File

@@ -37,7 +37,7 @@
"tailwindcss": "4.1.13", "tailwindcss": "4.1.13",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"zod": "4.1.13" "zod": "4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.35.0", "@eslint/js": "9.35.0",

View File

@@ -1,4 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"formatter": { "formatter": {
"enabled": true, "enabled": true,
"formatWithErrors": false, "formatWithErrors": false,
@@ -42,4 +43,4 @@
} }
} }
} }
} }

762
deno.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,34 +12,34 @@ services:
# -------------------------------------------------------------------------------- # --------------------------------------------------------------------------------
# Used by event store and read store for managing and reading application data. # Used by event store and read store for managing and reading application data.
mongo: # mongo:
restart: unless-stopped # restart: unless-stopped
image: mongo:8 # image: mongo:8
container_name: boilerplate_mongo # container_name: boilerplate_mongo
ports: # ports:
- 6017:27017 # - 6017:27017
environment: # environment:
MONGO_INITDB_ROOT_USERNAME: root # MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: password # MONGO_INITDB_ROOT_PASSWORD: password
volumes: # volumes:
- mongo:/data/db # - mongo:/data/db
networks: # networks:
- server # - server
# Cerbos # Cerbos
# -------------------------------------------------------------------------------- # --------------------------------------------------------------------------------
# Policy engine for application access control. # Policy engine for application access control.
cerbos: # cerbos:
restart: unless-stopped # restart: unless-stopped
image: ghcr.io/cerbos/cerbos:latest # image: ghcr.io/cerbos/cerbos:latest
container_name: boilerplate_cerbos # container_name: boilerplate_cerbos
command: ["server", "--config=/config.yaml"] # command: ["server", "--config=/config.yaml"]
ports: # ports:
- 6592:3592 # - 6592:3592
- 6593:3593 # - 6593:3593
- 6594:3594 # - 6594:3594
volumes: # volumes:
- ./cerbos.yaml:/config.yaml # - ./cerbos.yaml:/config.yaml
networks: # networks:
- server # - server

View File

@@ -9,6 +9,6 @@
}, },
"dependencies": { "dependencies": {
"@platform/relay": "workspace:*", "@platform/relay": "workspace:*",
"zod": "4.1.13" "zod": "4.3.5"
} }
} }

View File

@@ -12,6 +12,6 @@
"@platform/database": "workspace:*", "@platform/database": "workspace:*",
"@platform/parse": "workspace:*", "@platform/parse": "workspace:*",
"@platform/relay": "workspace:*", "@platform/relay": "workspace:*",
"zod": "4.1.13" "zod": "4.3.5"
} }
} }

View File

@@ -7,47 +7,72 @@ import { type Account, type AccountInsert, AccountInsertSchema, AccountSchema }
* Create a new account. * Create a new account.
* *
* @param values - Account values to insert. * @param values - Account values to insert.
*
* @returns Account _id
*/ */
export async function createAccount(values: AccountInsert): Promise<string> { export async function createAccount(values: AccountInsert): Promise<string> {
return db return db
.begin(async () => { .begin(async () => {
const _id = crypto.randomUUID(); const _id = crypto.randomUUID();
// Assert wallet exists // ### Assert Wallet
// Ensure that the wallet we are creating an account for exists.
await db.sql` await db.sql`
ASSERT EXISTS ( ASSERT EXISTS (
SELECT 1 SELECT
FROM payment.wallet w 1
WHERE w._id = ${db.text(values.walletId)} FROM
), 'missing_wallet'; payment.wallet wallet
WHERE
wallet._id = ${db.text(values.walletId)}
),
'missing_wallet';
`; `;
// Assert wallet → ledger relationship exists AND ledger exists // ### Assert Wallet → Ledger
// Ensure that the wallet is related to a existing ledger.
await db.sql` await db.sql`
ASSERT EXISTS ( ASSERT EXISTS (
SELECT 1 SELECT
FROM payment.wallet w 1
JOIN payment.ledger l ON l._id = w."ledgerId" FROM
WHERE w._id = ${db.text(values.walletId)} payment.wallet wallet
), 'missing_ledger'; JOIN
payment.ledger ledger ON ledger._id = wallet."ledgerId"
WHERE
wallet._id = ${db.text(values.walletId)}
),
'missing_ledger';
`; `;
// Assert ledger supports the currency // ### Assert Currency
// Ensure that the account currency is supported by the ledger.
await db.sql` await db.sql`
ASSERT EXISTS ( ASSERT EXISTS (
SELECT 1 SELECT
FROM payment.wallet w 1
JOIN payment.ledger l ON l._id = w."ledgerId" FROM
WHERE w._id = ${db.text(values.walletId)} payment.wallet wallet
AND ${db.text(values.currency)} IN ( JOIN
payment.ledger ledger ON ledger._id = wallet."ledgerId"
WHERE
wallet._id = ${db.text(values.walletId)}
AND
${db.text(values.currency)} IN (
SELECT SELECT
currency currency
FROM FROM
UNNEST(l.currencies) AS x(currency) UNNEST(ledger.currencies) AS x(currency)
) )
), 'unsupported_currency'; ),
'unsupported_currency';
`; `;
// ### Create Account
await db.sql`INSERT INTO payment.account RECORDS ${db.transit({ _id, ...AccountInsertSchema.parse(values) })}`; await db.sql`INSERT INTO payment.account RECORDS ${db.transit({ _id, ...AccountInsertSchema.parse(values) })}`;
return _id; return _id;
@@ -71,6 +96,13 @@ export async function createAccount(values: AccountInsert): Promise<string> {
}); });
} }
/**
* Get all accounts registered for a wallet.
*
* @param walletId - Wallet to fetch accounts from.
*
* @returns List of wallet accounts
*/
export async function getAccountsByWalletId(walletId: string): Promise<Account[]> { export async function getAccountsByWalletId(walletId: string): Promise<Account[]> {
return db.schema(AccountSchema).many` return db.schema(AccountSchema).many`
SELECT SELECT

View File

@@ -56,6 +56,8 @@ export async function getBeneficiaryByTenantId(tenantId: string): Promise<Benefi
* Get a beneficiary entity by provided id. * Get a beneficiary entity by provided id.
* *
* @param id - Identity of the beneficiary to retrieve. * @param id - Identity of the beneficiary to retrieve.
*
* @returns Beneficiary or undefined
*/ */
export async function getBeneficiaryById(id: string): Promise<Beneficiary | undefined> { export async function getBeneficiaryById(id: string): Promise<Beneficiary | undefined> {
return db.schema(BeneficiarySchema).one` return db.schema(BeneficiarySchema).one`

View File

@@ -7,12 +7,31 @@ import { type Ledger, type LedgerInsert, LedgerSchema } from "../schemas/ledger.
* Create a new ledger. * Create a new ledger.
* *
* @param values - Ledger values to insert. * @param values - Ledger values to insert.
*
* @returns Ledger _id
*/ */
export async function createLedger(values: LedgerInsert): Promise<string> { export async function createLedger(values: LedgerInsert): Promise<string> {
return db return db
.begin(async () => { .begin(async () => {
const _id = crypto.randomUUID(); const _id = crypto.randomUUID();
await db.sql`ASSERT EXISTS (SELECT 1 FROM payment.beneficiary WHERE _id = ${db.text(values.beneficiaryId)}), 'missing_beneficiary'`;
// ### Assert Beneficiary
// Ensure the beneficiary for the ledger exists.
await db.sql`
ASSERT EXISTS (
SELECT
1
FROM
payment.beneficiary
WHERE
_id = ${db.text(values.beneficiaryId)}
),
'missing_beneficiary'
`;
// ### Create Ledger
await db.sql` await db.sql`
INSERT INTO payment.ledger ( INSERT INTO payment.ledger (
_id, _id,
@@ -26,6 +45,7 @@ export async function createLedger(values: LedgerInsert): Promise<string> {
${values.label ? db.text(values.label) : null} ${values.label ? db.text(values.label) : null}
) )
`; `;
return _id; return _id;
}) })
.catch((error) => { .catch((error) => {

View File

@@ -7,13 +7,33 @@ import { type Wallet, type WalletInsert, WalletInsertSchema, WalletSchema } from
* Create a new wallet. * Create a new wallet.
* *
* @param values - Wallet values to insert. * @param values - Wallet values to insert.
*
* @returns Wallet _id
*/ */
export async function createWallet(values: WalletInsert): Promise<string> { export async function createWallet(values: WalletInsert): Promise<string> {
return db return db
.begin(async () => { .begin(async () => {
const _id = crypto.randomUUID(); const _id = crypto.randomUUID();
await db.sql`ASSERT EXISTS (SELECT 1 FROM payment.ledger WHERE _id = ${db.text(values.ledgerId)}), 'missing_ledger'`;
// ### Assert Ledger
// Ensure the ledger for the wallet exists.
await db.sql`
ASSERT EXISTS (
SELECT
1
FROM
payment.ledger
WHERE
_id = ${db.text(values.ledgerId)}
),
'missing_ledger'
`;
// ### Create Wallet
await db.sql`INSERT INTO payment.wallet RECORDS ${db.transit({ _id, ...WalletInsertSchema.parse(values) })}`; await db.sql`INSERT INTO payment.wallet RECORDS ${db.transit({ _id, ...WalletInsertSchema.parse(values) })}`;
return _id; return _id;
}) })
.catch((error) => { .catch((error) => {

View File

@@ -11,6 +11,6 @@
"@platform/database": "workspace:*", "@platform/database": "workspace:*",
"@platform/parse": "workspace:*", "@platform/parse": "workspace:*",
"@platform/relay": "workspace:*", "@platform/relay": "workspace:*",
"zod": "4.1.13" "zod": "4.3.5"
} }
} }

View File

@@ -9,6 +9,6 @@
}, },
"dependencies": { "dependencies": {
"@std/dotenv": "npm:@jsr/std__dotenv@0.225.5", "@std/dotenv": "npm:@jsr/std__dotenv@0.225.5",
"zod": "4.1.13" "zod": "4.3.5"
} }
} }

View File

@@ -13,6 +13,6 @@
"@types/transit-js": "0.8.3", "@types/transit-js": "0.8.3",
"postgres": "3.4.7", "postgres": "3.4.7",
"transit-js": "0.8.874", "transit-js": "0.8.874",
"zod": "4.1.13" "zod": "4.3.5"
} }
} }

View File

@@ -10,6 +10,6 @@
"dependencies": { "dependencies": {
"@platform/config": "workspace:*", "@platform/config": "workspace:*",
"@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1", "@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1",
"zod": "4.1.13" "zod": "4.3.5"
} }
} }

View File

@@ -8,6 +8,6 @@
".": "./mod.ts" ".": "./mod.ts"
}, },
"dependencies": { "dependencies": {
"zod": "4.1.13" "zod": "4.3.5"
} }
} }

View File

@@ -12,6 +12,6 @@
"@platform/socket": "workspace:*", "@platform/socket": "workspace:*",
"@platform/supertokens": "workspace:*", "@platform/supertokens": "workspace:*",
"path-to-regexp": "8", "path-to-regexp": "8",
"zod": "4.1.13" "zod": "4.3.5"
} }
} }

View File

@@ -5,6 +5,6 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@platform/relay": "workspace:*", "@platform/relay": "workspace:*",
"zod": "4.1.13" "zod": "4.3.5"
} }
} }

View File

@@ -11,6 +11,6 @@
"@platform/socket": "workspace:*", "@platform/socket": "workspace:*",
"@platform/storage": "workspace:*", "@platform/storage": "workspace:*",
"@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0", "@valkyr/json-rpc": "npm:@jsr/valkyr__json-rpc@1.1.0",
"zod": "4.1.13" "zod": "4.3.5"
} }
} }

View File

@@ -6,6 +6,6 @@
"dependencies": { "dependencies": {
"@platform/models": "workspace:*", "@platform/models": "workspace:*",
"@platform/relay": "workspace:*", "@platform/relay": "workspace:*",
"zod": "4.1.13" "zod": "4.3.5"
} }
} }