diff --git a/.gitignore b/.gitignore index 05a79cfca..89a485382 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Files +.claude/ .vscode .idea .DS_Store @@ -23,6 +24,7 @@ logs *.tgz .npmrc +dist/** smoke/package.json smoke/pnpm-lock.yaml smoke/react/package.json diff --git a/.prettierignore b/.prettierignore index 032d2aeb6..0360df56a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,4 @@ scripts/ **/.esm-cache/** **/dist/** **/coverage/** +dist/ diff --git a/cli/build-cmd.ts b/cli/build-cmd.ts index d13a9dfc6..5a6a9dd95 100644 --- a/cli/build-cmd.ts +++ b/cli/build-cmd.ts @@ -560,11 +560,13 @@ export function buildCmd(sthis: SuperThis) { // await $`env | grep -e npm_config -e NPM_CONFIG -e PNPM_CONFIG`; } const tags = args.pubTags; + console.log("version ---- ", args.version); try { const semVer = new SemVer(args.version); - if (semVer.prerelease.find((i) => typeof i === "string" && i.includes("dev"))) { + if (semVer.prerelease.find((i) => typeof i === "string")) { tags.push("dev"); } + console.log("version ---- ", args.version, semVer, tags); } catch (e) { console.warn(`Warn parsing version ${args.version}:`, e); } diff --git a/cli/tsc-cmd.ts b/cli/tsc-cmd.ts index 38a93f25e..f9fcac69c 100644 --- a/cli/tsc-cmd.ts +++ b/cli/tsc-cmd.ts @@ -28,11 +28,13 @@ export async function handleTsc(args: string[], sthis: SuperThis) { $.quote = quotePowerShell; } - $.verbose = false; - const p = $({ stdio: ["inherit", "inherit", "inherit"] })`${cmd}`; - await p; - // $.verbose = true; - // await $`${cmd}` + try { + $.verbose = false; + const p = $({ stdio: ["inherit", "inherit", "inherit"] })`${cmd}`; + await p; + } catch (e) { + process.exit((e as { exitCode?: number }).exitCode ?? 42); + } } export function tscCmd(sthis: SuperThis) { diff --git a/cloud/3rd-party/package.json b/cloud/3rd-party/package.json index 2b571922c..c1b38e077 100644 --- a/cloud/3rd-party/package.json +++ b/cloud/3rd-party/package.json @@ -40,6 +40,8 @@ }, "dependencies": { "@adviser/cement": "^0.4.63", + "preact": "^10.27.2", + "preact-render-to-string": "^6.6.2", "react": "^19.2.0", "react-dom": "^19.2.0", "use-fireproof": "workspace:0.0.0" diff --git a/cloud/3rd-party/src/App.tsx b/cloud/3rd-party/src/App.tsx index eacd2f80f..2610b034e 100644 --- a/cloud/3rd-party/src/App.tsx +++ b/cloud/3rd-party/src/App.tsx @@ -1,22 +1,17 @@ -import { DocWithId, useFireproof, toCloud, RedirectStrategy } from "use-fireproof"; +import { DocWithId, useFireproof, toCloud, FPCloudConnectStrategy } from "use-fireproof"; import React, { useState, useEffect } from "react"; import "./App.css"; +// import { overlayHtml } from "./overlayHtml.js"; // import { URI } from "@adviser/cement"; function App() { const { database, attach } = useFireproof("fireproof-5-party", { attach: toCloud({ - strategy: new RedirectStrategy({ + strategy: FPCloudConnectStrategy({ // overlayCss: defaultOverlayCss, - overlayHtml: (url: string) => `
-
×
- Fireproof Dashboard
- Sign in to Fireproof Dashboard - Redirect to Fireproof -
`, + // overlayHtml, + // dashboardURI: "http://localhost:7370/fp/cloud/api/token", }), - // dashboardURI: "http://localhost:7370/fp/cloud/api/token", - // tokenApiURI: "http://localhost:7370/api", // urls: { base: "fpcloud://localhost:8787?protocol=ws" }, // tenant: "3rd-party", // ledger: "vibes", diff --git a/cloud/3rd-party/src/main.tsx b/cloud/3rd-party/src/main.tsx index 854ee1408..dfc003786 100644 --- a/cloud/3rd-party/src/main.tsx +++ b/cloud/3rd-party/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"; import "./index.css"; import App from "./App.jsx"; +console.log("i'm in main.tsx"); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion createRoot(document.getElementById("root")!).render( diff --git a/cloud/3rd-party/src/overlayHtml.tsx b/cloud/3rd-party/src/overlayHtml.tsx new file mode 100644 index 000000000..2c9b83bfc --- /dev/null +++ b/cloud/3rd-party/src/overlayHtml.tsx @@ -0,0 +1,22 @@ +import { jsx } from "use-fireproof"; + +const { renderToString, React } = jsx; + +// function jsxDEV(...args: unknown[]) { +// // eslint-disable-next-line @typescript-eslint/no-explicit-any +// return (React as any).call(React, ...args) +// } +export function overlayHtml(url: string) { + // return renderToString(h("div", {})) + return renderToString( +
+
×
+ Fireproof Dashboard +
+ Sign in to Fireproof Dashboard + + Redirect to Fireproof + +
, + ); +} diff --git a/cloud/3rd-party/tsconfig.json b/cloud/3rd-party/tsconfig.json index a7e31a70d..9f2759456 100644 --- a/cloud/3rd-party/tsconfig.json +++ b/cloud/3rd-party/tsconfig.json @@ -1,9 +1,6 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "jsx": "react", - "esModuleInterop": true, - "types": ["react"] + "outDir": "./dist" } } diff --git a/cloud/3rd-party/vite.config.ts b/cloud/3rd-party/vite.config.ts index 1355a1f23..1e6e56f09 100644 --- a/cloud/3rd-party/vite.config.ts +++ b/cloud/3rd-party/vite.config.ts @@ -2,7 +2,11 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ - plugins: [react()], + plugins: [ + react({ + jsxRuntime: "classic", // Use classic instead of automatic + }), + ], build: { sourcemap: true, target: "esnext", @@ -13,6 +17,10 @@ export default defineConfig({ external: [".dev.vars"], }, }, + resolve: { + extensions: [".ts", ".tsx", ".js", ".jsx"], + // This makes Vite try .ts if .js doesn't exist + }, server: { port: 3001, hmr: true, diff --git a/cloud/backend/base/ws-sockets.test.ts b/cloud/backend/base/ws-sockets.test.ts index 7f0d73b30..d2a092677 100644 --- a/cloud/backend/base/ws-sockets.test.ts +++ b/cloud/backend/base/ws-sockets.test.ts @@ -1,8 +1,7 @@ -import { sleep } from "@fireproof/core-runtime"; import * as ps from "@fireproof/core-types-protocols-cloud"; import { Msger } from "@fireproof/core-protocols-cloud"; import { testSuperThis } from "@fireproof/cloud-base"; -import { Future, URI } from "@adviser/cement"; +import { Future, URI, sleep } from "@adviser/cement"; import { describe, beforeAll, afterAll, it, expect, assert } from "vitest"; import { mockJWK, MockJWK } from "./test-helper.js"; diff --git a/cloud/connector/base/convert-to-token-and-claims.ts b/cloud/connector/base/convert-to-token-and-claims.ts new file mode 100644 index 000000000..98f0ed990 --- /dev/null +++ b/cloud/connector/base/convert-to-token-and-claims.ts @@ -0,0 +1,40 @@ +import { Logger, exception2Result, Result } from "@adviser/cement"; +import { FPCloudClaim, FPCloudClaimSchema } from "@fireproof/core-types-protocols-cloud"; +import { jwtVerify } from "jose"; +import { JWKPublic } from "@fireproof/core-types-base"; + +export interface TokenAndClaims { + readonly token: string; + readonly claims: FPCloudClaim; +} +export async function convertToTokenAndClaims( + dashApi: { + getClerkPublishableKey(): Promise<{ cloudPublicKeys: JWKPublic[] }>; + }, + logger: Logger, + token: string, +): Promise> { + for (const jwkPublic of await dashApi.getClerkPublishableKey().then((r) => r.cloudPublicKeys)) { + const rUnknownClaims = await exception2Result(() => jwtVerify(token, jwkPublic)); + if (rUnknownClaims.isErr() || !rUnknownClaims.Ok()?.payload) { + logger + .Warn() + .Err(rUnknownClaims) + .Any({ + kid: jwkPublic.kid, + }) + .Msg("Token failed"); + continue; + } + const rFPCloudClaim = FPCloudClaimSchema.safeParse(rUnknownClaims.Ok().payload); + if (!rFPCloudClaim.success) { + logger.Warn().Err(rFPCloudClaim.error).Any({ inObj: rUnknownClaims.Ok().payload }).Msg("Token claims validation failed"); + continue; + } + return Result.Ok({ + token, + claims: rFPCloudClaim.data, + }); + } + return Result.Err("No valid JWK found to verify token"); +} diff --git a/cloud/connector/base/fpcc-protocol.ts b/cloud/connector/base/fpcc-protocol.ts new file mode 100644 index 000000000..812b038e7 --- /dev/null +++ b/cloud/connector/base/fpcc-protocol.ts @@ -0,0 +1,225 @@ +import { + FPCCError, + FPCCEvtApp, + FPCCEvtConnectorReady, + FPCCEvtNeedsLogin, + FPCCMessage, + FPCCMsgBase, + FPCCPing, + FPCCPong, + FPCCReqRegisterLocalDbName, + FPCCReqWaitConnectorReady, + FPCCSendMessage, + isFPCCError, + isFPCCEvtApp, + isFPCCEvtConnectorReady, + isFPCCEvtNeedsLogin, + isFPCCPing, + isFPCCPong, + isFPCCReqRegisterLocalDbName, + isFPCCReqWaitConnectorReady, + validateFPCCMessage, +} from "./protocol-fp-cloud-conn.js"; +import { Logger, OnFunc, Result } from "@adviser/cement"; +import { ensureLogger } from "@fireproof/core-runtime"; +import { SuperThis } from "@fireproof/core-types-base"; + +export interface JustReady { + readonly type: "ready"; +} + +export interface PeerReady { + readonly type: "peer"; + readonly peer: string; +} + +export function isJustReady(obj: unknown): obj is JustReady { + return typeof obj === "object" && obj !== null && (obj as JustReady).type === "ready"; +} + +export function isPeerReady(obj: unknown): obj is PeerReady { + return ( + typeof obj === "object" && obj !== null && (obj as PeerReady).type === "peer" && typeof (obj as PeerReady).peer === "string" + ); +} + +export type Ready = JustReady | PeerReady; + +export interface FPCCProtocol { + // handle must be this bound method + hash: () => string; + + sendMessage(event: FPCCSendMessage, srcEvent: MessageEvent): void; + handleError: (error: unknown) => void; + injectSend(send: (evt: FPCCMessage, srcEvent: MessageEvent | string) => FPCCMessage): void; + ready(): Promise>; + stop(): void; +} + +export class FPCCProtocolBase implements FPCCProtocol { + protected readonly sthis: SuperThis; + protected readonly logger: Logger; + readonly onStartFns: (() => void)[] = []; + #sendFn: ((msg: FPCCMessage, srcEvent: MessageEvent | string) => FPCCMessage) | undefined = undefined; + + readonly onMessage = OnFunc<(event: MessageEvent) => void>(); + readonly onFPCCMessage = OnFunc<(msg: FPCCMessage, srcEvent: MessageEvent) => void>(); + + readonly onFPCCEvtNeedsLogin = OnFunc<(msg: FPCCEvtNeedsLogin, srcEvent: MessageEvent) => void>(); + readonly onFPCCError = OnFunc<(msg: FPCCError, srcEvent: MessageEvent) => void>(); + readonly onFPCCReqRegisterLocalDbName = OnFunc<(msg: FPCCReqRegisterLocalDbName, srcEvent: MessageEvent) => void>(); + readonly onFPCCEvtApp = OnFunc<(msg: FPCCEvtApp, srcEvent: MessageEvent) => void>(); + readonly onFPCCPing = OnFunc<(msg: FPCCPing, srcEvent: MessageEvent) => void>(); + readonly onFPCCPong = OnFunc<(msg: FPCCPong, srcEvent: MessageEvent) => void>(); + readonly onFPCCEvtConnectorReady = OnFunc<(msg: FPCCEvtConnectorReady, srcEvent: MessageEvent) => void>(); + readonly onFPCCReqWaitConnectorReady = OnFunc<(msg: FPCCReqWaitConnectorReady, srcEvent: MessageEvent) => void>(); + + constructor(sthis: SuperThis, logger?: Logger) { + this.sthis = sthis; + this.logger = logger || ensureLogger(sthis, "FPCCProtocolBase"); + this.onMessage((event) => { + this.handleMessage(event); + }); + this.onFPCCMessage((msg, srcEvent) => { + this.#handleFPCCMessage(msg, srcEvent); + }); + this.onFPCCPing((msg, srcEvent) => { + this.sendMessage( + { + src: msg.dst, + dst: msg.src, + pingTid: msg.tid, + type: "FPCCPong", + }, + srcEvent, + ); + }); + } + + hash(): string { + throw new Error("should be implemented by subclass"); + } + + handleMessage = (event: MessageEvent) => { + if ((event.data as { type: string })?.type === "EXTENSION_VERSION") { + // ignore extension version messages + return; + } + const fpCCmsg = validateFPCCMessage(event.data); + // console.log("IframeFPCCProtocol handleMessage called", event.data, fpCCmsg.success); + if (fpCCmsg.success) { + this.onFPCCMessage.invoke(fpCCmsg.data, event); + } else { + this.logger.Warn().Err(fpCCmsg.error).Any("event", event).Msg("Received non-FPCC message"); + } + }; + + #handleFPCCMessage(event: FPCCMessage, srcEvent: MessageEvent) { + this.logger.Debug().Any("event", event).Msg("Handling FPCC message"); + switch (true) { + case isFPCCEvtNeedsLogin(event): { + this.onFPCCEvtNeedsLogin.invoke(event, srcEvent); + break; + } + + case isFPCCError(event): { + this.onFPCCError.invoke(event, srcEvent); + break; + } + + case isFPCCReqRegisterLocalDbName(event): { + this.onFPCCReqRegisterLocalDbName.invoke(event, srcEvent); + break; + } + + case isFPCCEvtApp(event): { + this.onFPCCEvtApp.invoke(event, srcEvent); + break; + } + + case isFPCCPing(event): { + this.onFPCCPing.invoke(event, srcEvent); + break; + } + + case isFPCCPong(event): { + this.onFPCCPong.invoke(event, srcEvent); + break; + } + + case isFPCCEvtConnectorReady(event): { + this.onFPCCEvtConnectorReady.invoke(event, srcEvent); + break; + } + + case isFPCCReqWaitConnectorReady(event): { + this.onFPCCReqWaitConnectorReady.invoke(event, srcEvent); + break; + } + } + } + + handleError = (_error: unknown) => { + throw new Error("Method not implemented."); + }; + + ready(): Promise> { + return Promise.resolve( + Result.Ok({ + type: "ready" as const, + }), + ); + } + + injectSend(sendFn: (msg: FPCCMessage, srcEvent: MessageEvent | string) => FPCCMessage): void { + this.#sendFn = sendFn; + } + + stop(): void { + this.#sendFn = undefined; + this.onFPCCMessage.clear(); + this.onStartFns.splice(0, this.onStartFns.length); + } + + sendMessage(msg: FPCCSendMessage, srcEvent: MessageEvent | string): T { + if (!this.#sendFn) { + throw new Error("Protocol not started. Call start() before sending messages."); + } + return this.#sendFn( + { + ...msg, + src: msg.src, + tid: msg.tid ?? this.sthis.nextId().str, + } as FPCCMessage, + srcEvent, + ) as T; + } +} + +// this.listener = (event: MessageEvent) => { +// try { +// // Check origin if whitelist is provided +// if (this.config.allowedOrigins && this.config.allowedOrigins.length > 0) { +// if (!this.config.allowedOrigins.includes(event.origin)) { +// // eslint-disable-next-line no-console +// console.warn(`Message from unauthorized origin: ${event.origin}`); +// return; +// } +// } + +// if (!this.isMessageEvent(event)) { +// throw this.logger.Error().Any({ data: event.data }).Msg("Received message with invalid data structure(T)"); +// } + +// // Call the message handler +// this.config.onMessage(event.data, event); +// } catch (error) { +// const err = error instanceof Error ? error : new Error(String(error)); +// if (this.config.onError) { +// this.config.onError(err, event); +// } else { +// // eslint-disable-next-line no-console +// console.error("Error handling message:", err); +// } +// } +// }; diff --git a/cloud/connector/base/index.ts b/cloud/connector/base/index.ts new file mode 100644 index 000000000..91ee4064a --- /dev/null +++ b/cloud/connector/base/index.ts @@ -0,0 +1,32 @@ +import { hashObjectSync } from "@fireproof/core-runtime"; +import { FPCCReqRegisterLocalDbName } from "./protocol-fp-cloud-conn.js"; + +export * from "./convert-to-token-and-claims.js"; +export * from "./fpcc-protocol.js"; +export * from "./post-messager.js"; +export * from "./protocol-fp-cloud-conn.js"; + +// export interface DbKey { +// readonly appId: string; +// readonly dbName: string; +// } + +export function dbAppKey(o: FPCCReqRegisterLocalDbName): string { + return hashObjectSync(o); + //o.appId + ":" + o.dbName; +} + +export function isInIframe( + win: { + readonly self: Window | null; + readonly top: Window | null; + } = window, +): boolean { + try { + return win.self !== win.top; + } catch (e) { + // If we can't access window.top due to cross-origin restrictions, + // we're definitely in an iframe + return true; + } +} diff --git a/cloud/connector/base/package.json b/cloud/connector/base/package.json new file mode 100644 index 000000000..03ef72486 --- /dev/null +++ b/cloud/connector/base/package.json @@ -0,0 +1,41 @@ +{ + "name": "@fireproof/cloud-connector-base", + "version": "0.0.0", + "description": "cloud connector shared", + "type": "module", + "scripts": { + "build": "core-cli tsc", + "pack": "core-cli build --doPack", + "publish": "core-cli build" + }, + "keywords": [ + "ledger", + "JSON", + "document", + "IPLD", + "CID", + "IPFS" + ], + "contributors": [ + "Meno Abels" + ], + "author": "Meno Abels", + "license": "AFL-2.0", + "homepage": "https://use-fireproof.com", + "repository": { + "type": "git", + "url": "git+https://github.com/fireproof-storage/fireproof.git" + }, + "bugs": { + "url": "https://github.com/fireproof-storage/fireproof/issues" + }, + "dependencies": { + "@adviser/cement": "^0.4.63", + "@fireproof/core-runtime": "workspace:*", + "@fireproof/core-types-base": "workspace:*", + "@fireproof/core-types-protocols-cloud": "workspace:*", + "jose": "^6.0.12", + "ts-essentials": "^10.1.1", + "zod": "^4.1.12" + } +} diff --git a/cloud/connector/base/post-messager.ts b/cloud/connector/base/post-messager.ts new file mode 100644 index 000000000..c2a9ea6a8 --- /dev/null +++ b/cloud/connector/base/post-messager.ts @@ -0,0 +1,135 @@ +/** + * MessageReceiver component for handling postMessage events + */ + +import { Logger } from "@adviser/cement"; +import { ensureLogger } from "@fireproof/core-runtime"; +import { SuperThis } from "@fireproof/core-types-base"; +import { Writable } from "ts-essentials"; + +export interface MessageEvent { + data: T; + origin: string; + source: WindowProxy | MessagePort | ServiceWorker | null; +} + +export interface PostMessagerConfig { + /** + * Optional origin whitelist for security. If provided, only messages from these origins will be processed. + */ + readonly allowedOrigins?: string[]; + + /** + * Handler function called when a valid message is received + */ + onMessage(data: T, event: MessageEvent): void; + + /** + * Optional error handler + */ + onError?(error: Error, event: MessageEvent): void; + + /** + * Optional validator function to validate message data structure + */ + validator(data: unknown): data is T; + + /** + * Target window to listen on (defaults to current window) + */ + targetWindow?: Window; +} + +export class PostMessager { + private config: Omit, "allowedOrigins"> & Pick>, "allowedOrigins">; + + private listener?: (this: Window, event: MessageEvent) => void; + private targetWindow: Window; + readonly logger: Logger; + readonly id: string; + + constructor(sthis: SuperThis, config: PostMessagerConfig) { + this.config = { allowedOrigins: [], ...config }; + this.targetWindow = config.targetWindow || window; + this.id = sthis.nextId().str; + this.logger = ensureLogger(sthis, "PostMessager").With().Str("Id", this.id).Logger(); + } + + private isMessageEvent(event: MessageEvent): event is MessageEvent { + return this.config.validator && !this.config.validator(event.data); + } + + /** + * Start listening for postMessage events + */ + start(): void { + if (this.listener) { + // eslint-disable-next-line no-console + console.warn("MessageReceiver is already listening"); + return; + } + + this.listener = (event: MessageEvent) => { + try { + // Check origin if whitelist is provided + if (this.config.allowedOrigins && this.config.allowedOrigins.length > 0) { + if (!this.config.allowedOrigins.includes(event.origin)) { + // eslint-disable-next-line no-console + console.warn(`Message from unauthorized origin: ${event.origin}`); + return; + } + } + + if (!this.isMessageEvent(event)) { + throw this.logger.Error().Any({ data: event.data }).Msg("Received message with invalid data structure(T)"); + } + + // Call the message handler + this.config.onMessage(event.data, event); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + if (this.config.onError) { + this.config.onError(err, event); + } else { + // eslint-disable-next-line no-console + console.error("Error handling message:", err); + } + } + }; + + this.targetWindow.addEventListener("message", this.listener); + } + + /** + * Stop listening for postMessage events + */ + stop(): void { + if (this.listener) { + this.targetWindow.removeEventListener("message", this.listener); + this.listener = undefined; + } + } + + /** + * Update the allowed origins list + */ + updateAllowedOrigins(origins: string[]): void { + this.config.allowedOrigins = origins; + } + + /** + * Check if the receiver is currently listening + */ + isListening(): boolean { + return this.listener !== null; + } +} + +/** + * Helper function to create and start a message receiver + */ +export function createPostMessager(sthis: SuperThis, config: PostMessagerConfig): PostMessager { + const receiver = new PostMessager(sthis, config); + receiver.start(); + return receiver; +} diff --git a/cloud/connector/base/protocol-fp-cloud-conn.ts b/cloud/connector/base/protocol-fp-cloud-conn.ts new file mode 100644 index 000000000..6a17f6f00 --- /dev/null +++ b/cloud/connector/base/protocol-fp-cloud-conn.ts @@ -0,0 +1,191 @@ +import { z } from "zod"; + +// Base message schema (without readonly for extension) +const FPCCMsgBaseSchemaBase = z.object({ + tid: z.string(), + type: z.string(), + src: z.string(), + dst: z.string(), +}); + +export const FPCCMsgBaseSchema = FPCCMsgBaseSchemaBase.readonly(); +export type FPCCMsgBase = z.infer; + +// FPCCEvtNeedsLogin schema +export const FPCCEvtNeedsLoginSchema = FPCCMsgBaseSchemaBase.extend({ + type: z.literal("FPCCEvtNeedsLogin"), + devId: z.string(), + loginURL: z.string(), + loginTID: z.string(), + loadDbNames: z.array( + z + .object({ + appId: z.string(), + dbName: z.string(), + tenantId: z.string().optional(), + ledgerId: z.string().optional(), + }) + .readonly(), + ), + reason: z.enum(["BindCloud", "ConsumeAIToken", "FreeAITokenEnd"]), +}).readonly(); + +export type FPCCEvtNeedsLogin = z.infer; + +// FPCCError schema +export const FPCCErrorSchema = FPCCMsgBaseSchemaBase.extend({ + type: z.literal("FPCCError"), + message: z.string(), + cause: z.string().optional(), + stack: z.string().optional(), +}).readonly(); + +export type FPCCError = z.infer; + +// FPCCReqRegisterLocalDbName schema +export const FPCCReqRegisterLocalDbNameSchema = FPCCMsgBaseSchemaBase.extend({ + type: z.literal("FPCCReqRegisterLocalDbName"), + appURL: z.string(), + appId: z.string(), + dbName: z.string(), // localDbName + ledger: z.string().optional(), + tenant: z.string().optional(), +}).readonly(); + +export type FPCCReqRegisterLocalDbName = z.infer; + +// FPCCEvtApp schema +export const FPCCEvtAppSchema = FPCCMsgBaseSchemaBase.extend({ + type: z.literal("FPCCEvtApp"), + appId: z.string(), + appFavIcon: z + .object({ + defURL: z.string(), + // room for more types and sizes + }) + .readonly(), + devId: z.string(), + user: z + .object({ + name: z.string(), + email: z.string(), + provider: z.enum(["google", "github", "unknown"]), + iconURL: z.string(), + }) + .readonly(), + localDb: z + .object({ + dbName: z.string(), + tenantId: z.string(), + ledgerId: z.string(), + accessToken: z.string(), + }) + .readonly(), + env: z.record(z.string(), z.record(z.string(), z.string())), +}).readonly(); + +export type FPCCEvtApp = z.infer; + +// FPCCPing schema +export const FPCCPingSchema = FPCCMsgBaseSchemaBase.extend({ + type: z.literal("FPCCPing"), + timestamp: z.number().optional(), +}).readonly(); + +export type FPCCPing = z.infer; + +// FPCCPong schema +export const FPCCPongSchema = FPCCMsgBaseSchemaBase.extend({ + type: z.literal("FPCCPong"), + timestamp: z.number().optional(), + pingTid: z.string(), // Reference to the ping message tid +}).readonly(); + +export type FPCCPong = z.infer; + +// FPCCEvtConnectorReady schema +export const FPCCEvtConnectorReadySchema = FPCCMsgBaseSchemaBase.extend({ + type: z.literal("FPCCEvtConnectorReady"), + timestamp: z.number(), + seq: z.number(), + devId: z.string(), +}).readonly(); + +export type FPCCEvtConnectorReady = z.infer; + +// FPCCReqWaitConnectorReady schema +export const FPCCReqWaitConnectorReadySchema = FPCCMsgBaseSchemaBase.extend({ + type: z.literal("FPCCReqWaitConnectorReady"), + timestamp: z.number(), + appId: z.string(), + seq: z.number(), +}).readonly(); + +export type FPCCReqWaitConnectorReady = z.infer; + +// Union schema for all message types +export const FPCCMessageSchema = z.discriminatedUnion("type", [ + FPCCEvtNeedsLoginSchema, + FPCCErrorSchema, + FPCCReqRegisterLocalDbNameSchema, + FPCCEvtAppSchema, + FPCCPingSchema, + FPCCPongSchema, + FPCCEvtConnectorReadySchema, + FPCCReqWaitConnectorReadySchema, +]); + +export type FPCCMessage = z.infer; + +export type FPCCSendMessage = Omit, "src"> & { + src?: T["src"] | undefined; + tid?: T["tid"] | undefined; +}; + +// // Send message type - makes src and tid optional for convenience +// export type FPCCSendMessage = { +// [K in keyof FPCCMessage]: K extends 'src' | 'tid' +// ? FPCCMessage[K] | undefined +// : FPCCMessage[K] +// }; + +// Type guard functions + +/** + * Validates if unknown data is a valid FPCC message using Zod safeParse + */ +export function validateFPCCMessage(data: unknown) { + return FPCCMessageSchema.safeParse(data); +} + +export function isFPCCEvtNeedsLogin(msg: FPCCMessage): msg is FPCCEvtNeedsLogin { + return msg.type === "FPCCEvtNeedsLogin"; +} + +export function isFPCCError(msg: FPCCMessage): msg is FPCCError { + return msg.type === "FPCCError"; +} + +export function isFPCCReqRegisterLocalDbName(msg: FPCCMessage): msg is FPCCReqRegisterLocalDbName { + return msg.type === "FPCCReqRegisterLocalDbName"; +} + +export function isFPCCEvtApp(msg: FPCCMessage): msg is FPCCEvtApp { + return msg.type === "FPCCEvtApp"; +} + +export function isFPCCPing(msg: FPCCMessage): msg is FPCCPing { + return msg.type === "FPCCPing"; +} + +export function isFPCCPong(msg: FPCCMessage): msg is FPCCPong { + return msg.type === "FPCCPong"; +} + +export function isFPCCEvtConnectorReady(msg: FPCCMessage): msg is FPCCEvtConnectorReady { + return msg.type === "FPCCEvtConnectorReady"; +} + +export function isFPCCReqWaitConnectorReady(msg: FPCCMessage): msg is FPCCReqWaitConnectorReady { + return msg.type === "FPCCReqWaitConnectorReady"; +} diff --git a/cloud/connector/base/tsconfig.json b/cloud/connector/base/tsconfig.json new file mode 100644 index 000000000..a7db7c68a --- /dev/null +++ b/cloud/connector/base/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + } +} diff --git a/cloud/connector/iframe/injected-iframe.html b/cloud/connector/iframe/injected-iframe.html new file mode 100644 index 000000000..0b0688262 --- /dev/null +++ b/cloud/connector/iframe/injected-iframe.html @@ -0,0 +1,12 @@ + + + Fireproof Cloud Connector + + + I'm the Fireproof Cloud Connector + + + diff --git a/cloud/connector/iframe/package.json b/cloud/connector/iframe/package.json new file mode 100644 index 000000000..1262fb116 --- /dev/null +++ b/cloud/connector/iframe/package.json @@ -0,0 +1,42 @@ +{ + "name": "@fireproof/cloud-connector-iframe", + "version": "0.0.0", + "description": "cloud connector shared", + "type": "module", + "main": "./index.js", + "scripts": { + "build": "core-cli tsc", + "pack": "core-cli build --doPack", + "publish": "core-cli build" + }, + "keywords": [ + "ledger", + "JSON", + "document", + "IPLD", + "CID", + "IPFS" + ], + "contributors": [ + "Meno Abels" + ], + "author": "Meno Abels", + "license": "AFL-2.0", + "homepage": "https://use-fireproof.com", + "repository": { + "type": "git", + "url": "git+https://github.com/fireproof-storage/fireproof.git" + }, + "bugs": { + "url": "https://github.com/fireproof-storage/fireproof/issues" + }, + "dependencies": { + "@adviser/cement": "^0.4.63", + "@clerk/clerk-js": "^5.102.0", + "@fireproof/cloud-connector-base": "workspace:*", + "@fireproof/core-protocols-dashboard": "workspace:*", + "@fireproof/core-runtime": "workspace:*", + "@fireproof/core-types-base": "workspace:*", + "@fireproof/core-types-protocols-cloud": "workspace:*" + } +} diff --git a/cloud/connector/iframe/tsconfig.json b/cloud/connector/iframe/tsconfig.json new file mode 100644 index 000000000..a7db7c68a --- /dev/null +++ b/cloud/connector/iframe/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + } +} diff --git a/cloud/connector/page/index.ts b/cloud/connector/page/index.ts new file mode 100644 index 000000000..ecd22de23 --- /dev/null +++ b/cloud/connector/page/index.ts @@ -0,0 +1,2 @@ +export * from "./page-fpcc-protocol.js"; +export * from "./page-handler.js"; diff --git a/cloud/connector/page/package.json b/cloud/connector/page/package.json new file mode 100644 index 000000000..f84d0a92d --- /dev/null +++ b/cloud/connector/page/package.json @@ -0,0 +1,40 @@ +{ + "name": "@fireproof/cloud-connector-page", + "version": "0.0.0", + "description": "cloud connector shared", + "type": "module", + "main": "./index.js", + "scripts": { + "build": "core-cli tsc", + "pack": "core-cli build --doPack", + "publish": "core-cli build" + }, + "keywords": [ + "ledger", + "JSON", + "document", + "IPLD", + "CID", + "IPFS" + ], + "contributors": [ + "Meno Abels" + ], + "author": "Meno Abels", + "license": "AFL-2.0", + "homepage": "https://use-fireproof.com", + "repository": { + "type": "git", + "url": "git+https://github.com/fireproof-storage/fireproof.git" + }, + "bugs": { + "url": "https://github.com/fireproof-storage/fireproof/issues" + }, + "dependencies": { + "@adviser/cement": "^0.4.63", + "@fireproof/cloud-connector-base": "workspace:*", + "@fireproof/core-runtime": "workspace:*", + "@fireproof/core-types-base": "workspace:*", + "ts-essentials": "^10.1.1" + } +} diff --git a/cloud/connector/page/page-fpcc-protocol.ts b/cloud/connector/page/page-fpcc-protocol.ts new file mode 100644 index 000000000..3edbabcb1 --- /dev/null +++ b/cloud/connector/page/page-fpcc-protocol.ts @@ -0,0 +1,315 @@ +import { ensureLogger, ensureSuperThis, hashObjectSync } from "@fireproof/core-runtime"; +import { SuperThis } from "@fireproof/core-types-base"; +import { Future, KeyedResolvOnce, Lazy, Logger, poller, ResolveOnce, Result, timeouted } from "@adviser/cement"; +import { + FPCCProtocol, + FPCCProtocolBase, + FPCCEvtApp, + FPCCMessage, + FPCCMsgBase, + FPCCReqRegisterLocalDbName, + FPCCReqWaitConnectorReady, + FPCCSendMessage, + dbAppKey, + FPCCEvtNeedsLogin, + Ready, + isPeerReady, +} from "@fireproof/cloud-connector-base"; +import { initializeIframe } from "./page-handler.js"; + +export interface FPCloudFrontend { + hash(): string; + openLogin(msg: FPCCEvtNeedsLogin): void; + stop(): void; +} + +export interface ResultActivePeerTimeout { + readonly state: "timeout"; +} + +export interface ResultActivePeerConnected { + readonly state: "connected"; + readonly peer: string; // url +} + +export interface ResultActivePeerError { + readonly state: "error"; + readonly error: Error; +} + +export type ResultActivePeer = ResultActivePeerTimeout | ResultActivePeerConnected | ResultActivePeerError; + +export interface ConnectorReadyTimeouts { + readonly localMs: number; // 300, + readonly remoteMs: number; //1000, +} +export interface PageFPCCProtocolOpts { + readonly iframeHref: string; + readonly fpCloudFrontend: FPCloudFrontend; + readonly loginWaitTime?: number; + readonly registerWaitTime?: number; + readonly intervalMs?: number; + readonly sthis?: SuperThis; + readonly maxConnectRetries?: number; + readonly connectorReadyTimeouts?: Partial; +} + +interface WaitForFPCCEvtApp { + readonly register: FPCCReqRegisterLocalDbName; + readonly fpccEvtApp: FPCCEvtApp; +} + +class PageFPCCProtocol implements FPCCProtocol { + // readonly sthis: SuperThis; + readonly logger: Logger; + readonly fpccProtocol: FPCCProtocolBase; + readonly maxConnectRetries: number; + readonly iFrameHref: string; + + // readonly futureConnected = new Future(); + readonly registerFPCCEvtApp = new KeyedResolvOnce(); + // readonly waitforFPCCEvtAppFutures = new Map>(); + readonly loginWaitTime: number; + readonly registerWaitTime: number; + readonly starter = new ResolveOnce(); + readonly sthis: SuperThis; + + readonly connectorReadyTimeouts: ConnectorReadyTimeouts; + readonly fpCloudFrontend: FPCloudFrontend; + + readonly hash = Lazy((val?: string) => val || ""); // setup in constructor + + constructor(opts: Required & { hash: string }) { + this.sthis = opts.sthis; + this.iFrameHref = opts.iframeHref; + this.logger = ensureLogger(opts.sthis, "PageFPCCProtocol", { + iFrameHref: this.iFrameHref, + }); + this.fpccProtocol = new FPCCProtocolBase(opts.sthis, this.logger); + this.maxConnectRetries = opts.maxConnectRetries; + this.loginWaitTime = opts.loginWaitTime; + this.fpCloudFrontend = opts.fpCloudFrontend; + this.registerWaitTime = opts.registerWaitTime; + this.connectorReadyTimeouts = { + localMs: 300, + remoteMs: 1000, + ...opts.connectorReadyTimeouts, + }; + this.hash(opts.hash); + } + + stop(): void { + this.fpccProtocol.stop(); + // this.waitforFPCCEvtAppFutures.clear(); + this.registerFPCCEvtApp.reset(); + this.starter.reset(); + } + + getAppId(): string { + // setup in ready + return "we-need-to-implement-app-id-this"; + } + + readonly handleError = (_error: unknown): void => { + throw new Error("Method not implemented."); + }; + + async registerDatabase(dbName: string, ireg: Partial = {}): Promise> { + return this.ready().then((peer) => { + if (!isPeerReady(peer)) { + return Result.Err(new Error("FPCC Protocol not ready - cannot register database")); + } + const sreg = { + ...ireg, + type: "FPCCReqRegisterLocalDbName", + appId: ireg.appId ?? this.getAppId(), + appURL: ireg.appURL ?? window.location.href, + dbName, + dst: ireg.dst ?? peer.peer, + } satisfies FPCCSendMessage; + const key = dbAppKey(sreg); + return this.registerFPCCEvtApp + .get(key) + .once(async () => { + const tid = this.sthis.nextId(12).str; + const fpccEvtAppFuture = new Future(); + this.fpccProtocol.onFPCCEvtApp((evt, _srcEvent) => { + if (evt.tid === tid) { + fpccEvtAppFuture.resolve(evt); + } + }); + const reg = this.sendMessage( + { + ...sreg, + tid, + src: `page-${window.location.href}-${this.hash()}-${key}`, + dst: peer.peer, + }, + peer.peer, + ); + const rTimeoutFPCCEvtApp = await timeouted( + fpccEvtAppFuture + .asPromise() + .then((evt) => Result.Ok(evt)) + .catch((error) => Result.Err(error)), + { + timeout: this.registerWaitTime, + }, + ); + if (rTimeoutFPCCEvtApp.state !== "success") { + throw new Error(`Timeout waiting for FPCCEvtApp for db "${dbName}"`); + } + const rFPCCEvtApp = rTimeoutFPCCEvtApp.value; + if (rFPCCEvtApp.isErr()) { + throw Result.Err(rFPCCEvtApp); + } + return Result.Ok({ + register: reg, + fpccEvtApp: rFPCCEvtApp.unwrap(), + } satisfies WaitForFPCCEvtApp); + }) + .then((rWaitForFPCCEvtApp) => { + if (rWaitForFPCCEvtApp.isErr()) { + return Result.Err(rWaitForFPCCEvtApp); + } + return Result.Ok(rWaitForFPCCEvtApp.unwrap().fpccEvtApp); + }); + }); + } + + injectSend(send: (evt: FPCCMessage, srcEvent: MessageEvent | string) => FPCCMessage): void { + this.fpccProtocol.injectSend(send); + } + + sendMessage(event: FPCCSendMessage, srcEvent: MessageEvent | string): T { + return this.fpccProtocol.sendMessage(event, srcEvent); + } + + async tryRegisterApp(dst: string, waitTimeMs: number): Promise { + const tid = this.sthis.nextId(12).str; + let seq = 0; + const isReady = new Future(); + this.fpccProtocol.onFPCCEvtConnectorReady((msg: FPCCMessage) => { + if (msg.tid !== tid) { + return; + } + isReady.resolve(); + }); + + const abortController = new AbortController(); + const polling = poller( + async () => { + this.sendMessage( + { + type: "FPCCReqWaitConnectorReady", + tid, + src: `page-${window.location.href}-${this.hash()}`, + dst, + appId: this.getAppId(), + timestamp: Date.now(), + seq: seq++, + }, + dst, + ); + return { + state: "waiting", + abortSignal: abortController.signal, + }; + }, + { + timeoutMs: waitTimeMs, + intervalMs: ~~(waitTimeMs / 5) + 1, + }, + ); + return Promise.race([ + polling.then((res) => { + if (res.state === "error") { + return { + state: "error" as const, + error: res.error, + }; + } + return { + state: "timeout" as const, + }; + }), + isReady.asPromise().then(() => { + abortController.abort(); + return { + state: "connected" as const, + peer: dst, + }; + }), + ]); + } + + readonly ready = Lazy(async (): Promise> => { + return this.starter.once(async () => { + const rReady = await this.fpccProtocol.ready(); + if (rReady.isErr()) { + return Result.Err(rReady); + } + window.addEventListener("message", this.fpccProtocol.handleMessage); + // try my self + const tryRegisterApps: Promise[] = [ + this.tryRegisterApp(window.location.href, this.connectorReadyTimeouts.localMs), + ]; + if (window.parent !== window) { + tryRegisterApps.push( + this.tryRegisterApp(window.parent.location.href, this.connectorReadyTimeouts.remoteMs).then((res) => { + switch (res.state) { + case "connected": + return res; + case "error": + return res; + case "timeout": { + return initializeIframe(this.fpccProtocol, this.iFrameHref).then((iframe) => + this.tryRegisterApp(iframe.src, this.connectorReadyTimeouts.remoteMs), + ); + } + } + }), + ); + } else { + const dstUrl = await initializeIframe(this.fpccProtocol, this.iFrameHref); + tryRegisterApps.push(this.tryRegisterApp(dstUrl.src, this.connectorReadyTimeouts.remoteMs)); + } + const result = await Promise.race(tryRegisterApps); + if (result.state === "error") { + return Result.Err(result.error); + } + if (result.state === "timeout") { + return Result.Err("Could not connect to FPCC Svc - timeout"); + } + + this.fpccProtocol.onFPCCEvtNeedsLogin((msg) => { + // this could be called multiple times - let the frontend handle it + this.fpCloudFrontend.openLogin(msg); + // we might start polling for connection here + }); + return Result.Ok({ + type: "peer" as const, + peer: result.peer, + }); + }); + }); +} + +const keyedPageFPCCProtocols = new KeyedResolvOnce(); +export const pageFPCCProtocol = Lazy((iopts: PageFPCCProtocolOpts) => { + const opts = { + maxConnectRetries: 20, + loginWaitTime: 30000, + ...iopts, + connectorReadyTimeouts: { + localMs: iopts.connectorReadyTimeouts?.localMs ?? 300, + remoteMs: iopts.connectorReadyTimeouts?.remoteMs ?? 1000, + }, + sthis: iopts.sthis ?? ensureSuperThis(), + } as Required; + const hash = hashObjectSync({ + ...opts, // need some more love + fpCloudFrontendHash: opts.fpCloudFrontend.hash(), + }); + return keyedPageFPCCProtocols.get(hash).once(() => new PageFPCCProtocol({ ...opts, hash })); +}); diff --git a/cloud/connector/page/page-handler.ts b/cloud/connector/page/page-handler.ts new file mode 100644 index 000000000..3c76c38af --- /dev/null +++ b/cloud/connector/page/page-handler.ts @@ -0,0 +1,71 @@ +/** + * Consumer program that creates and inserts an iframe with in-iframe.ts + */ + +import { Future } from "@adviser/cement"; +import { Writable } from "ts-essentials"; +import { FPCCMessage, FPCCProtocolBase } from "@fireproof/cloud-connector-base"; + +/** + * Creates an iframe element with the specified source + */ +function createIframe(src: string): HTMLIFrameElement { + const iframe = document.createElement("iframe"); + iframe.src = src; + iframe.id = "fireproof-connector-iframe"; + + // Set iframe attributes - make it invisible + iframe.style.display = "none"; + + return iframe; +} + +/** + * Inserts the iframe as the last element in the document body + */ +function insertIframeAsLastElement(iframe: HTMLIFrameElement): void { + // Wait for DOM to be ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + document.body.appendChild(iframe); + }); + } else { + // DOM is already ready + document.body.appendChild(iframe); + } +} + +/** + * Main function to set up the iframe + */ +export function initializeIframe(pageProtocol: FPCCProtocolBase, iframeSrc: string): Promise { + (globalThis as Record)[Symbol.for("FP_PRESET_ENV")] = { + FP_DEBUG: "*", + }; + + const iframe = createIframe(iframeSrc); + const waitForLoad = new Future(); + // Add load event listener + // console.log("Initializing FPCC iframe with src:", iframeHref.toString()); + iframe.addEventListener("load", () => { + pageProtocol.injectSend((event: Writable) => { + // console.log("Sending PageFPCCProtocol", event, iframe.src); + event.dst = iframe.src; + event.src = window.location.href; + iframe.contentWindow?.postMessage(event, iframe.src); + return event; + }); + pageProtocol.ready().then(() => { + waitForLoad.resolve(); + }); + }); + // Add error event listener + iframe.addEventListener("error", pageProtocol.handleError); + insertIframeAsLastElement(iframe); + return waitForLoad.asPromise().then(() => iframe); +} + +// Initialize when script loads +// initializeIframe(); + +// export { createIframe, insertIframeAsLastElement, initializeIframe }; diff --git a/cloud/connector/page/tsconfig.json b/cloud/connector/page/tsconfig.json new file mode 100644 index 000000000..a7db7c68a --- /dev/null +++ b/cloud/connector/page/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + } +} diff --git a/cloud/connector/svc/clerk-fpcc-evt-entity.ts b/cloud/connector/svc/clerk-fpcc-evt-entity.ts new file mode 100644 index 000000000..5ee34b1ae --- /dev/null +++ b/cloud/connector/svc/clerk-fpcc-evt-entity.ts @@ -0,0 +1,215 @@ +import { Lazy, Logger, poller, Result } from "@adviser/cement"; +import { SuperThis } from "@fireproof/core-types-base"; +import { DashApi, AuthType, isResCloudDbTokenBound } from "@fireproof/core-protocols-dashboard"; +import { BackendFPCC, BackendState, GetCloudDbTokenResult } from "../svc/svc-fpcc-protocol.js"; +import { ensureLogger, exceptionWrapper } from "@fireproof/core-runtime"; +import { TokenAndSelectedTenantAndLedger } from "@fireproof/core-types-protocols-cloud"; +import { Clerk } from "@clerk/clerk-js/headless"; +import { FPCCEvtApp, convertToTokenAndClaims } from "@fireproof/cloud-connector-base"; + +export const clerkSvc = Lazy(async (dashApi: DashApi) => { + const clerkPubKey = await dashApi.getClerkPublishableKey({}); + // console.log("clerkSvc got publishable key", rClerkPubKey); + const clerk = new Clerk(clerkPubKey.publishableKey); + await clerk.load(); + clerk.addListener((session) => { + if (session.user) { + dashApi.logger + .Info() + .Any({ + user: session.user, + windowLocation: window.location.href, + clerkPubKey, + }) + .Msg("Svc Clerk-User signed in"); + } else { + dashApi.logger + .Info() + .Any({ + windowLocation: window.location.href, + clerkPubKey, + }) + .Msg("Svc Clerk-User signed out"); + } + }); + + return clerk; +}); + +// const clerkFPCCEvtEntities = new KeyedResolvOnce(); + +export class ClerkFPCCEvtEntity implements BackendFPCC { + // readonly appId: string; + // readonly dbName: string; + // readonly deviceId: string; + readonly sthis: SuperThis; + readonly logger: Logger; + readonly dashApi: DashApi; + state: "needs-login" | "waiting" | "ready" = "needs-login"; + constructor(sthis: SuperThis, dashApi: DashApi/*, dbKey: DbKey, deviceId: string*/) { + this.logger = ensureLogger(sthis, `ClerkFPCCEvtEntity`) + // , { + // appId: dbKey.appId, + // dbName: dbKey.dbName, + // deviceId: deviceId, + // }); + // this.appId = dbKey.appId; + // this.dbName = dbKey.dbName; + // this.deviceId = deviceId; + this.sthis = sthis; + this.dashApi = dashApi; + } + + async getCloudDbToken(auth: AuthType, bkey: BackendState): Promise> { + const rRes = await this.dashApi.getCloudDbToken({ + auth, + appId: bkey.appId, + localDbName: bkey.dbName, + deviceId: bkey.deviceId, + }); + if (rRes.isErr()) { + return Result.Err(rRes); + } + const res = rRes.Ok(); + if (!isResCloudDbTokenBound(res)) { + return Result.Ok({ res }); + } + const rTandC = await convertToTokenAndClaims(this.dashApi, this.logger, res.token); + if (rTandC.isErr()) { + return Result.Err(rTandC); + } + return Result.Ok({ res, claims: rTandC.Ok().claims }); + } + + isUserLoggedIn(): Promise { + return clerkSvc(this.dashApi).then((clerk) => { + return !!clerk.user; + }); + } + + async getDashApiToken(): Promise> { + return exceptionWrapper(async () => { + const clerk = await clerkSvc(this.dashApi); + if (!clerk.user) { + return Result.Err(new Error("User not logged in")); + } + const token = await clerk.session?.getToken({ template: "with-email" }); + if (!token) { + return Result.Err(new Error("No session token available")); + } + return Result.Ok({ + type: "clerk", + token, + }); + }); + } + + isFPCCEvtAppReady(): boolean { + // need to implement a check which looks into the token if it is expired or not + return this.state === "ready"; + } + + fpccEvtApp?: FPCCEvtApp; + getFPCCEvtApp(): Promise> { + return Promise.resolve(this.fpccEvtApp ? Result.Ok(this.fpccEvtApp) : Result.Err(new Error("No FPCCEvtApp registered"))); + } + + setFPCCEvtApp(app: FPCCEvtApp): Promise { + this.fpccEvtApp = app; + return Promise.resolve(); + } + getState(): "needs-login" | "waiting" | "ready" { + // For testing purposes, we always return "needs-login" + return this.state; + } + + setState(state: "needs-login" | "waiting" | "ready"): "needs-login" | "waiting" | "ready" { + const prev = this.state; + this.state = state; + return prev; + } + + // + async waitForAuthToken(resultId: string): Promise> { + return poller(async () => { + const clerk = await clerkSvc(this.dashApi); + if (!clerk.user) { + return { + state: "waiting", + }; + } + // console.log("clerk user is logged in:", clerk.user); + + const rWaitForToken = await this.dashApi.waitForToken({ resultId }, this.logger); + if (rWaitForToken.isErr()) { + return { + state: "error", + error: rWaitForToken.Err(), + }; + } + const waitedTokenByResultId = rWaitForToken.unwrap(); + if (waitedTokenByResultId.status === "found" && waitedTokenByResultId.token) { + const token = waitedTokenByResultId.token; + if (!token) { + return { + state: "error", + error: new Error("No token received"), + }; + } + const rTokenClaims = await convertToTokenAndClaims(this.dashApi, this.logger, token); + if (rTokenClaims.isErr()) { + return { + state: "error", + error: rTokenClaims.Err(), + }; + } + return { + state: "success", + result: rTokenClaims.Ok(), + }; + } + return { state: "waiting" }; + }).then((res) => { + switch (res.state) { + case "success": + return Result.Ok(res.result); + case "error": + return Result.Err(res.error); + default: + return Result.Err("should not happen"); + } + }); + } + + // async getTokenForDb( + // dbInfo: DbKey, + // authToken: TokenAndSelectedTenantAndLedger, + // originEvt: Partial, + // ): Promise { + // await sleep(50); + // return { + // ...dbInfo, + // tid: originEvt.tid ?? this.sthis.nextId(12).str, + // type: "FPCCEvtApp", + // src: "fp-cloud-connector", + // dst: originEvt.src ?? "iframe", + // appFavIcon: { + // defURL: "https://example.com/favicon.ico", + // }, + // devId: this.deviceId, + // user: { + // name: "Test User", + // email: "test@example.com", + // provider: "google", + // iconURL: "https://example.com/icon.png", + // }, + // localDb: { + // dbName: dbInfo.dbName, + // tenantId: "tenant-for-" + dbInfo.appId, + // ledgerId: "ledger-for-" + dbInfo.appId, + // accessToken: `auth-token-for-${dbInfo.appId}-${dbInfo.dbName}-with-${authToken}`, + // }, + // env: {}, + // }; + // } +} diff --git a/cloud/connector/svc/fp-cloud-connector.ts b/cloud/connector/svc/fp-cloud-connector.ts new file mode 100644 index 000000000..a826610a1 --- /dev/null +++ b/cloud/connector/svc/fp-cloud-connector.ts @@ -0,0 +1,40 @@ +import { ensureSuperThis } from "@fireproof/core-runtime"; +import { BuildURI, Lazy, URI } from "@adviser/cement"; +import { FPCCMessage } from "@fireproof/cloud-connector-base"; +import { SvcFPCCProtocol, SvcFPCCProtocolOpts } from "./svc-fpcc-protocol.js"; + +export function defaultFPCloudConnectorOpts( + opts: Partial< + SvcFPCCProtocolOpts & { + readonly loadUrlStr: string; + } + >, +): SvcFPCCProtocolOpts { + const loadUrl = URI.from(opts.loadUrlStr ?? window.location.href); + const dashboardURI = opts.dashboardURI ?? loadUrl.getParam("dashboard_uri"); + let cloudApiURI = opts.cloudApiURI ?? loadUrl.getParam("cloud_api_uri"); + if (dashboardURI && !cloudApiURI) { + cloudApiURI = BuildURI.from(dashboardURI).pathname("/api").toString(); + } + return { + dashboardURI: dashboardURI ?? "https://dev.connect.fireproof.direct/fp/cloud", + cloudApiURI: cloudApiURI ?? "https://dev.connect.fireproof.direct/api", + }; +} + +export const fpCloudConnector = Lazy((opts: SvcFPCCProtocolOpts) => { + const sthis = opts.sthis ?? ensureSuperThis(); + const protocol = new SvcFPCCProtocol(sthis, opts); + window.addEventListener("message", protocol.handleMessage); + protocol.injectSend((event: FPCCMessage, srcEvent: MessageEvent | string) => { + (event as { src: string }).src = event.src ?? window.location.href; + if (typeof srcEvent === "string") { + window.postMessage(event, srcEvent); + return event; + } else { + srcEvent.source?.postMessage(event, { targetOrigin: srcEvent.origin }); + } + return event; + }); + return protocol; +}); diff --git a/cloud/connector/svc/index.ts b/cloud/connector/svc/index.ts new file mode 100644 index 000000000..228104b26 --- /dev/null +++ b/cloud/connector/svc/index.ts @@ -0,0 +1,3 @@ +export * from "./clerk-fpcc-evt-entity.js"; +export * from "./fp-cloud-connector.js"; +export * from "./svc-fpcc-protocol.js"; diff --git a/cloud/connector/svc/package.json b/cloud/connector/svc/package.json new file mode 100644 index 000000000..25a96723f --- /dev/null +++ b/cloud/connector/svc/package.json @@ -0,0 +1,43 @@ +{ + "name": "@fireproof/cloud-connector-svc", + "version": "0.0.0", + "description": "cloud connector shared", + "type": "module", + "main": "./index.js", + "scripts": { + "build": "core-cli tsc", + "pack": "core-cli build --doPack", + "publish": "core-cli build" + }, + "keywords": [ + "ledger", + "JSON", + "document", + "IPLD", + "CID", + "IPFS" + ], + "contributors": [ + "Meno Abels" + ], + "author": "Meno Abels", + "license": "AFL-2.0", + "homepage": "https://use-fireproof.com", + "repository": { + "type": "git", + "url": "git+https://github.com/fireproof-storage/fireproof.git" + }, + "bugs": { + "url": "https://github.com/fireproof-storage/fireproof/issues" + }, + "dependencies": { + "@adviser/cement": "^0.4.63", + "@clerk/clerk-js": "^5.102.1", + "@fireproof/cloud-connector-base": "workspace:*", + "@fireproof/core-protocols-dashboard": "workspace:*", + "@fireproof/core-runtime": "workspace:*", + "@fireproof/core-types-base": "workspace:*", + "@fireproof/core-types-protocols-cloud": "workspace:*", + "ts-essentials": "^10.1.1" + } +} diff --git a/cloud/connector/svc/svc-fpcc-protocol.ts b/cloud/connector/svc/svc-fpcc-protocol.ts new file mode 100644 index 000000000..179a3ca4d --- /dev/null +++ b/cloud/connector/svc/svc-fpcc-protocol.ts @@ -0,0 +1,529 @@ +import { ensureLogger, hashObjectSync } from "@fireproof/core-runtime"; +import { + convertToTokenAndClaims, + FPCCEvtApp, + FPCCEvtConnectorReady, + FPCCEvtNeedsLogin, + FPCCMessage, + FPCCMsgBase, + FPCCReqRegisterLocalDbName, + FPCCSendMessage, + isFPCCReqRegisterLocalDbName, + FPCCProtocol, + FPCCProtocolBase, + dbAppKey, + Ready, +} from "@fireproof/cloud-connector-base"; +import { SuperThis } from "@fireproof/core-types-base"; +import { BuildURI, Keyed, KeyedResolvOnce, KeyedResolvSeq, Lazy, Logger, Result, sleep } from "@adviser/cement"; +import { + DashApi, + AuthType, + ResCloudDbTokenBound, + ResCloudDbTokenNotBound, + isResCloudDbTokenBound, +} from "@fireproof/core-protocols-dashboard"; +import { TokenAndSelectedTenantAndLedger } from "@fireproof/core-types-protocols-cloud"; +import { ClerkFPCCEvtEntity, clerkSvc } from "./clerk-fpcc-evt-entity.js"; + +export interface SvcFPCCProtocolOpts { + readonly dashboardURI: string; + readonly cloudApiURI: string; + readonly sthis?: SuperThis; + readonly backend?: BackendFPCC; +} + +export type GetCloudDbTokenResult = + | { + readonly res: ResCloudDbTokenBound; + readonly claims: TokenAndSelectedTenantAndLedger["claims"]; + } + | { + readonly res: ResCloudDbTokenNotBound; + }; + +export interface BackendKey { + readonly appURL: string; + readonly appId: string; + readonly dbName: string; + readonly ledger?: string | undefined; + readonly tenant?: string | undefined; +} + +export type BackendStates = "needs-login" | "waiting" | "ready"; +export interface BackendState extends BackendKey { + getState(): BackendStates; + setState(state: BackendStates): BackendStates; + reset(): void; +} + + +export interface BackendFPCC { + // readonly appId: string; + // readonly dbName: string; + // readonly deviceId: string; + isFPCCEvtAppReady(): boolean; + waitForAuthToken(resultId: string): Promise>; + getFPCCEvtApp(): Promise>; + setFPCCEvtApp(app: FPCCEvtApp): Promise; + isUserLoggedIn(): Promise; + getDashApiToken(): Promise>; + // listRegisteredDbNames(): Promise; + getCloudDbToken(auth: AuthType, bkey: BackendKey): Promise>; +} + + +// function getBackendFromRegisterLocalDbName(sthis: SuperThis, dashApi: Api, req: DbKey, deviceId: string): BackendFPCC { +// return ClerkFPCCEvtEntity.fromRegisterLocalDbName(sthis, dashApi, req, deviceId); +// } + +// const registeredDbs = new Map(); + +// static fromRegisterLocalDbName(sthis: SuperThis, dashApi: Api, req: DbKey, deviceId: string): ClerkFPCCEvtEntity { +// const key = dbAppKey(req); +// return clerkFPCCEvtEntities.get(key).once(() => new ClerkFPCCEvtEntity(sthis, dashApi, req, deviceId)); +// } + +const backendPerDashUri = new KeyedResolvOnce(); + +const backendStatePerDb = new Keyed(({key}: { key: BackendKey}) => { + let state: BackendStates = "needs-login"; + const hash = hashObjectSync({ + appURL: key.appURL, + appId: key.appId, + dbName: key.dbName, + ledger: key.ledger, + tenant: key.tenant, + }); + return { + ...key, + getState: () => state, + setState: (newState: BackendStates) => { state = newState; return state }, + key: hash, + reset: () => { + throw new Error("Not implemented yet"); + } + } +}, { +}) + +export class SvcFPCCProtocol implements FPCCProtocol { + readonly sthis: SuperThis; + readonly logger: Logger; + readonly fpccProtocol: FPCCProtocolBase; + readonly dashboardURI: string; + readonly dashApiURI: string; + readonly dashApi: DashApi; + readonly hash: () => string; + readonly backendFPCC: BackendFPCC; + + constructor(sthis: SuperThis, opts: SvcFPCCProtocolOpts) { + this.sthis = sthis; + this.logger = ensureLogger(sthis, "SvcFPCCProtocol"); + this.fpccProtocol = new FPCCProtocolBase(sthis, this.logger); + this.hash = Lazy(() => hashObjectSync(opts)); + this.dashboardURI = opts.dashboardURI ?? "https://dev.connect.fireproof.direct/fp/cloud"; + this.dashApiURI = opts.cloudApiURI ?? "https://dev.connect.fireproof.direct/api"; + // console.log("IframeFPCCProtocol constructed with", opts); + this.dashApi = new DashApi(this.sthis, this.dashApiURI); + this.backendFPCC = opts.backend ?? backendPerDashUri.get(this.dashboardURI).once(() => { + return new ClerkFPCCEvtEntity(this.sthis, this.dashApi); + }) + } + + // readonly activeIframes = new KeyedResolvOnce>(); + // serveIframe(iframe: HTMLIFrameElement): Promise> { + // return this.ready().then(() => { + // return this.activeIframes.get(iframe.src).once(async () => { + // let seq = 0; + + // const tid = this.sthis.nextId(16).str; + // const gotReady = new Future(); + + // this.fpccProtocol.onFPCCEvtConnectorReady((msg) => { + // if (msg.tid !== tid) { + // return; + // } + // gotReady.resolve(); + // this.logger.Info().Any(msg).Msg("Svc-Received connector ready event from iframe"); + // }); + // const abc = new AbortController(); + // const result = await Promise.race([ + // poller( + // () => { + // this.fpccProtocol.sendMessage( + // { + // tid, + // type: "FPCCEvtConnectorReady", + // timestamp: Date.now(), + // seq: seq++, + // devId: "svc-device-id", + // dst: `iframe:${iframe.src}`, + // }, + // iframe.src, + // ); + // return Promise.resolve({ state: "waiting" }); + // }, + // { + // intervalMs: 100, + // exponentialBackoff: true, + // timeoutMs: 10000, + // abortSignal: abc.signal, + // }, + // ).then(() => Promise.resolve(Result.Err("Timeout waiting for iframe to be ready"))), + // gotReady.asPromise().then(() => Result.Ok(abc.abort())), + // ]); + // if (result.isErr()) { + // return this.logger.Warn().Err(result).Msg("Failed to serve iframe").ResultError(); + // } + // return Result.Ok(iframe); + // }); + // }); + // } + + // buildBackendKey(key: DbKey): BackendState { + // return { + // ...key, + // deviceId: this.getDeviceId(), + // } + // // const mapKey = dbAppKey(key); + // // const existing = registeredDbs.get(mapKey); + // // if (existing) { + // // return existing; + // // } + // // const newEntity = new ClerkFPCCEvtEntity(this.sthis, this.dashApi, key, this.getDeviceId()); + // // registeredDbs.set(mapKey, newEntity); + // // return newEntity; + // } + + getDeviceId(): string { + return "we-need-to-implement-device-id"; + } + + async requestPageToDoLogin(bkey: BackendKey, event: FPCCReqRegisterLocalDbName, srcEvent: MessageEvent): Promise { + const loginTID = this.sthis.nextId(16).str; + const url = BuildURI.from(this.dashboardURI) + .setParam("back_url", "wait-for-token") // dummy back_url since we don't return to the app here + .setParam("result_id", loginTID) + .setParam("app_id", event.appId) + .setParam("local_ledger_name", event.dbName); + if (event.ledger) { + url.setParam("ledger", event.ledger); + } + if (event.tenant) { + url.setParam("tenant", event.tenant); + } + const fpccEvtNeedsLogin: FPCCSendMessage = { + tid: event.tid, + type: "FPCCEvtNeedsLogin", + dst: event.src, + devId: this.getDeviceId(), + loginURL: url.toString(), + loginTID, + loadDbNames: [event], + reason: "BindCloud", + }; + + this.fpccProtocol.sendMessage(fpccEvtNeedsLogin, srcEvent); + this.backendFPCC.waitForAuthToken(loginTID).then((rAuthToken) => { + if (rAuthToken.isErr()) { + this.logger.Error().Err(rAuthToken).Msg("Failed to obtain auth token after login"); + return; + } + const bstate = backendStatePerDb.get(event).once(() => { + let state: BackendStates= "needs-login"; + return { + appId: event.appId, + dbName: event.dbName, + deviceId: this.getDeviceId(), + getState: () => state, + setState: (newState: BackendStates) => { + state = newState; + } + } + }); + return this.backendFPCC + .getCloudDbToken({ + type: "clerk", + token: rAuthToken.Ok().token, + }, bstate) + .then((rCloudToken) => { + if (rCloudToken.isErr()) { + throw this.logger + .Error() + .Err(rCloudToken) + .Any({...bstate}) + .Msg("Failed to obtain DB token after login") + .AsError(); + } + const cloudToken = rCloudToken.Ok(); + switch (cloudToken.res.status) { + case "not-bound": + throw this.logger + .Error() + .Str("status", cloudToken.res.status) + .Any({ + ...bstate + }) + .Msg("DB is still not bound after login") + .AsError(); + } + return cloudToken.res.token; + }) + .then((cloudToken) => convertToTokenAndClaims(this.dashApi, this.logger, cloudToken)) + .then((rTanc) => { + if (rTanc.isErr()) { + throw this.logger + .Error() + .Err(rTanc) + .Any({ + ...bstate + }) + .Msg("Failed to convert DB token to token and claims after login"); + } + const { claims, token } = rTanc.Ok(); + const fpccEvtApp = { + tid: event.tid, + dst: event.src, + type: "FPCCEvtApp", + appId: bstate.appId, + appFavIcon: { + defURL: "https://fireproof.direct/favicon.ico", + }, + devId: "", + user: { + name: claims.nickname ?? claims.userId, + email: claims.email, + provider: claims.provider ?? "unknown", + iconURL: "https://fireproof.direct/favicon.ico", + }, + localDb: { + dbName: bstate.dbName, + tenantId: claims.selected.tenant, + ledgerId: claims.selected.ledger, + accessToken: token, + }, + env: {}, // future env vars + } satisfies FPCCSendMessage; + bstate.setState("ready"); + this.backendFPCC.setFPCCEvtApp(this.fpccProtocol.sendMessage(fpccEvtApp, srcEvent)); + // this.logger.Info().Any(fpccEvtApp).Msg("Successfully obtained token for DB after login"); + }); + }); + } + + readonly stateSeq = new KeyedResolvSeq(); + runStateMachine(event: FPCCReqRegisterLocalDbName, srcEvent: MessageEvent): Promise { + // const bkey: BackendKey = { + // appId: event.appId, + // dbName: event.localDb.dbName, + // deviceId: this.getDeviceId(), + // }; + const key = dbAppKey(event); + return this.stateSeq.get(key).add(() => backendStatePerDb.get(key).this.atomicRunStateMachine(event, event, srcEvent)); + } + + listRegisteredDbs(): BackendKey[] { + return Array.from(backendStatePerDb.values()).filter((bstate) => bstate.value.Ok().getState() === "ready").map((state) => { + const bstate = state.value.Ok(); + return { + appId: bstate.appId, + dbName: bstate.dbName, + deviceId: bstate.deviceId, + }; + }) + } + + async atomicRunStateMachine(state: BackendState, event: FPCCMessage, srcEvent: MessageEvent): Promise { + const bstate = state.getState(); + switch (true) { + case bstate === "ready" && isFPCCReqRegisterLocalDbName(event): + this.logger.Debug().Msg("Backend is ready, sending FPCCEvtApp"); + return this.backendFPCC.getFPCCEvtApp() + .then((rFpccEvtApp) => { + if (rFpccEvtApp.isOk()) { + this.sendMessage(rFpccEvtApp.Ok(), srcEvent); + } else { + this.logger.Error().Err(rFpccEvtApp).Msg("Failed to get FPCCEvtApp in ready state"); + } + }) + .then(() => Promise.resolve()); + case bstate === "waiting": + { + this.logger.Debug().Msg("Backend is waiting"); + // this.logger.Info().Str("appID", event.appID).Msg("Backend is waiting"); + throw new Error("Backend is in waiting state; not implemented yet."); + } + break; + case bstate === "needs-login" && isFPCCReqRegisterLocalDbName(event): + { + this.logger.Debug().Msg("Backend needs login", state.appId, state.dbName); + const rAuthToken = await this.backendFPCC.getDashApiToken(); + if (rAuthToken.isErr()) { + this.logger + .Warn() + .Err(rAuthToken) + .Any({ appId: state.appId, dbName: state.dbName }) + .Msg("User not logged in, requesting login"); + // make all dbs go to waiting state + state.setState("waiting"); + return this.requestPageToDoLogin(state, event, srcEvent); + } else { + // const backend = this.registeredDb(event); + + if (this.backendFPCC.isFPCCEvtAppReady()) { + const rFpccEvtApp = await this.backendFPCC.getFPCCEvtApp(); + if (rFpccEvtApp.isErr()) { + this.logger + .Warn() + .Err(rFpccEvtApp) + .Any({ appId: state.appId, dbName: state.dbName }) + .Msg("Backend reports error"); + } + if (rFpccEvtApp.isOk()) { + this.logger + .Debug() + .Any({ appId: state.appId, dbName: state.dbName, fpccEvtApp: rFpccEvtApp.Ok() }) + .Msg("Sending existing FPCCEvtApp"); + this.sendMessage(rFpccEvtApp.Ok(), srcEvent); + return; + } + } else { + const rDbToken = await this.dashApi.getCloudDbToken({ + auth: rAuthToken.Ok(), + appId: state.appId, + localDbName: state.dbName, + deviceId: state.deviceId, + }); + if (rDbToken.isErr()) { + this.logger + .Error() + .Err(rDbToken) + .Any({ appId: state.appId, dbName: state.dbName }) + .Msg("Unexpected error obtaining DB token"); + state.setState("waiting"); + await sleep(60000); + this.stateSeq.get(dbAppKey(state)).add(() => this.atomicRunStateMachine(state, event, srcEvent)); + return; + } + if (rDbToken.Ok().status === "not-bound") { + this.logger.Debug().Any({ appId: state.appId, dbName: state.dbName }).Msg("DB is not bound, requesting login"); + // make all dbs go to waiting state + state.setState("waiting"); + return this.requestPageToDoLogin(state, event, srcEvent); + } else { + const rCloudToken = await this.backendFPCC.getCloudDbToken(rAuthToken.Ok(), state); + if (rCloudToken.isErr()) { + this.logger.Warn().Err(rCloudToken).Msg("Failed to obtain DB token, re-running state machine after delay"); + await sleep(1000); + this.stateSeq.get(dbAppKey(state)).add(() => this.atomicRunStateMachine(state, event, srcEvent)); + return; + } + const res = rCloudToken.Ok().res; + if (!isResCloudDbTokenBound(res)) { + return this.requestPageToDoLogin(state, event, srcEvent); + } + const rTandC = await convertToTokenAndClaims(this.dashApi, this.logger, res.token); + if (rTandC.isErr()) { + this.logger + .Warn() + .Err(rTandC) + .Msg("Failed to convert DB token to token and claims, re-running state machine after delay"); + await sleep(1000); + this.stateSeq.get(dbAppKey(state)).add(() => this.atomicRunStateMachine(state, event, srcEvent)); + return; + } + const { token, claims } = rTandC.Ok(); + const fpccEvtApp: FPCCEvtApp = { + tid: event.tid, + type: "FPCCEvtApp", + src: "iframe", + dst: event.src, + appId: state.appId, + appFavIcon: { + defURL: "https://fireproof.direct/favicon.ico", + }, + devId: state.deviceId, + user: { + name: claims.nickname ?? claims.userId, + email: claims.email, + provider: claims.provider ?? "unknown", + iconURL: "https://fireproof.direct/favicon.ico", + }, + localDb: { + dbName: state.dbName, + tenantId: claims.selected.tenant, + ledgerId: claims.selected.ledger, + accessToken: token, + }, + env: {}, + }; + await this.backendFPCC.setFPCCEvtApp(fpccEvtApp); + state.setState("ready"); + this.logger + .Debug() + .Any({ appId: state.appId, dbName: state.dbName }) + .Msg("Sent FPCCEvtApp after obtaining DB token"); + this.sendMessage(fpccEvtApp, srcEvent); + return; + } + } + } + } + break; + + default: + throw this.logger.Error().Str("state", bstate).Msg("Unknown backend state").AsError(); + } + } + + readonly handleError = (_error: unknown): void => { + throw new Error("Method not implemented."); + }; + + readonly handleMessage = (event: MessageEvent): void => { + this.fpccProtocol.handleMessage(event); + } + + stop(): void { + this.logger.Debug().Msg("SvcFPCCProtocol stop called"); + this.fpccProtocol.stop(); + } + + injectSend(sendFn: (evt: FPCCMessage, srcEvent: MessageEvent | string) => FPCCMessage): void { + this.fpccProtocol.injectSend(sendFn); + } + + readonly ready = Lazy(async (): Promise> => { + await clerkSvc(this.dashApi); + await this.fpccProtocol.ready(); + + this.fpccProtocol.onFPCCReqWaitConnectorReady(async (event, srcEvent: MessageEvent) => { + this.logger.Info().Str("appID", event.appId).Msg("Received request to wait for connector ready"); + // Here you would implement logic to handle the wait for connector ready request + const readyEvent: FPCCSendMessage = { + tid: event.tid, + type: "FPCCEvtConnectorReady", + timestamp: Date.now(), + seq: event.seq, + devId: this.getDeviceId(), + dst: event.src, + }; + this.sendMessage(readyEvent, srcEvent); + }) + + this.fpccProtocol.onFPCCReqRegisterLocalDbName(async (event, srcEvent: MessageEvent) => { + return this.runStateMachine(event, srcEvent); + }); + + return Result.Ok({ + type: "ready" + }); + }); + + sendMessage(message: FPCCSendMessage, srcEvent: MessageEvent | string): T { + // message.src = window.location.href; + // console.log("IframeFPCCProtocol sendMessage called", message); + return this.fpccProtocol.sendMessage(message, srcEvent); + } +} diff --git a/cloud/connector/svc/tsconfig.json b/cloud/connector/svc/tsconfig.json new file mode 100644 index 000000000..a7db7c68a --- /dev/null +++ b/cloud/connector/svc/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + } +} diff --git a/cloud/connector/test/package.json b/cloud/connector/test/package.json new file mode 100644 index 000000000..6d8d99437 --- /dev/null +++ b/cloud/connector/test/package.json @@ -0,0 +1,45 @@ +{ + "name": "@fireproof/cloud-connector-test", + "version": "0.0.0", + "description": "cloud connector shared test", + "type": "module", + "private": "true", + "scripts": { + "build": "core-cli tsc", + "pack": "echo skip", + "publish": "echo skip", + "test": "vitest --run" + }, + "keywords": [ + "ledger", + "JSON", + "document", + "IPLD", + "CID", + "IPFS" + ], + "contributors": [ + "Meno Abels" + ], + "author": "Meno Abels", + "license": "AFL-2.0", + "homepage": "https://use-fireproof.com", + "repository": { + "type": "git", + "url": "git+https://github.com/fireproof-storage/fireproof.git" + }, + "bugs": { + "url": "https://github.com/fireproof-storage/fireproof/issues" + }, + "dependencies": { + "@fireproof/cloud-connector-base": "workspace:*", + "@fireproof/cloud-connector-iframe": "workspace:*", + "@fireproof/cloud-connector-svc": "workspace:*", + "@fireproof/cloud-connector-page": "workspace:*", + "@fireproof/core-runtime": "workspace:*", + "@vitest/browser": "^4.0.8", + "@vitest/browser-playwright": "^4.0.8", + "ts-essentials": "^10.1.1", + "vitest": "^4.0.8" + } +} diff --git a/cloud/connector/test/page-fpcc-protocol.test.ts b/cloud/connector/test/page-fpcc-protocol.test.ts new file mode 100644 index 000000000..f9914fb4e --- /dev/null +++ b/cloud/connector/test/page-fpcc-protocol.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import { FPCloudFrontend, pageFPCCProtocol } from "@fireproof/cloud-connector-page"; +import { SvcFPCCProtocol } from "@fireproof/cloud-connector-svc"; +import { FPCCEvtNeedsLogin, FPCCMessage, FPCCPing } from "@fireproof/cloud-connector-base"; +import { ensureSuperThis } from "@fireproof/core-runtime"; +import { Writable } from "ts-essentials"; + +export class TestFrontend implements FPCloudFrontend { + hash(): string { + return "test-frontend-hash"; + } + openLogin(_msg: FPCCEvtNeedsLogin): void { + // no-op + } + stop(): void { + // no-op + } +} + +describe("FPCC Protocol", () => { + const sthis = ensureSuperThis(); + const pageProtocol = pageFPCCProtocol({ + sthis, + iframeHref: "https://example.com/iframe", + loginWaitTime: 1000, + fpCloudFrontend: new TestFrontend(), + }); + const iframeProtocol = new SvcFPCCProtocol(sthis, { + dashboardURI: "https://example.com/dashboard", + cloudApiURI: "https://example.com/wait-for-token", + }); + + iframeProtocol.injectSend((evt: Writable) => { + evt.src = evt.src ?? "iframe"; + // console.log("IframeFPCCProtocol sending message", evt); + pageProtocol.fpccProtocol.handleMessage({ data: evt, origin: "iframe" } as MessageEvent); + return evt; + }); + + function protocolStart() { + return iframeProtocol.ready().then(() => { + pageProtocol.injectSend((evt: Writable) => { + evt.src = evt.src ?? "page"; + iframeProtocol.handleMessage({ data: evt, origin: "page" } as MessageEvent); + return evt; + }); + return pageProtocol.ready(); + }); + } + + it("ping-pong", async () => { + const pingMessage: FPCCPing = { + tid: "test-ping-1", + type: "FPCCPing", + src: "page", + dst: "iframe", + timestamp: Date.now(), + }; + const fpccFn = vi.fn(); + pageProtocol.fpccProtocol.onFPCCMessage(fpccFn); + await protocolStart(); + pageProtocol.fpccProtocol.sendMessage(pingMessage, "iframe"); + expect(fpccFn.mock.calls[fpccFn.mock.calls.length - 1]).toEqual([ + { + dst: "page", + pingTid: "test-ping-1", + src: "iframe", + tid: expect.any(String), + timestamp: expect.any(Number), + type: "FPCCPong", + }, + { + data: { + dst: "page", + pingTid: "test-ping-1", + src: "iframe", + tid: expect.any(String), + timestamp: expect.any(Number), + type: "FPCCPong", + }, + origin: "iframe", + }, + ]); + pageProtocol.stop(); + }); + + // it("registerApp", async () => { + // await protocolStart(); + // const fpccEvtApp = await pageProtocol.registerDatabase("wurst", { + // tid: "tid-test-app-1", + // appId: "test-app-1", + // }); + // expect(fpccEvtApp.Ok()).toEqual({ + // tid: "tid-test-app-1", + // type: "FPCCEvtApp", + // src: "fp-cloud-connector", + // dst: "page", + // devId: "we-need-to-implement-device-id", + // appId: "test-app-1", + // appFavIcon: { + // defURL: "https://example.com/favicon.ico", + // }, + // env: {}, + // localDb: { + // accessToken: expect.any(String), + // // "auth-token-for-test-app-1-wurst-with-fake-auth-token:zMKseTNm6BhLCJNxy6AtXEe:https://dev.connect.fireproof.direct/api", + // ledgerId: "ledger-for-test-app-1", + // dbName: "wurst", + // tenantId: "tenant-for-test-app-1", + // }, + // user: { + // email: "test@example.com", + // iconURL: "https://example.com/icon.png", + // name: "Test User", + // provider: "google", + // }, + // }); + // pageProtocol.stop(); + // }); +}); diff --git a/cloud/connector/test/tsconfig.json b/cloud/connector/test/tsconfig.json new file mode 100644 index 000000000..a7db7c68a --- /dev/null +++ b/cloud/connector/test/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + } +} diff --git a/cloud/connector/test/vitest.config.ts b/cloud/connector/test/vitest.config.ts new file mode 100644 index 000000000..1d1b73653 --- /dev/null +++ b/cloud/connector/test/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config"; +import { playwright } from "@vitest/browser-playwright"; + +export default defineConfig({ + test: { + name: "cloud:connector:test", + exclude: ["dist/**", "node_modules/**", "examples/**", "gateway/file"], + include: ["**/*test.?(c|m)[jt]s?(x)"], + browser: { + enabled: true, + headless: true, + provider: playwright({ + // ...custom playwright options + }), + instances: [ + { + browser: "chromium", + }, + ], + screenshotFailures: false, + }, + }, +}); diff --git a/core/device-id/device-id-protocol.ts b/core/device-id/device-id-protocol.ts index 3379e4cd5..7ee7edb8b 100644 --- a/core/device-id/device-id-protocol.ts +++ b/core/device-id/device-id-protocol.ts @@ -1,14 +1,15 @@ -import { IssueCertificateResult, JWKPrivateSchema, SuperThis } from "@fireproof/core-types-base"; -import { CAActions, DeviceIdCA } from "./device-id-CA.js"; +import { IssueCertificateResult, JWKPrivateSchema, JWKPublic, SuperThis } from "@fireproof/core-types-base"; +import { DeviceIdCA } from "./device-id-CA.js"; import { param, Result } from "@adviser/cement"; import { DeviceIdKey } from "./device-id-key.js"; import { base58btc } from "multiformats/bases/base58"; import { DeviceIdVerifyMsg, VerifyWithCertificateResult } from "./device-id-verify-msg.js"; +import { hashObjectAsync } from "@fireproof/core-runtime"; -async function ensureCA(sthis: SuperThis, actions: CAActions): Promise> { +async function ensureCA(sthis: SuperThis, opts: DeviceIdProtocolSrvOpts): Promise> { const rEnv = sthis.env.gets({ - DEVICE_ID_CA_KEY: param.REQUIRED, - DEVICE_ID_CA_COMMON_NAME: param.OPTIONAL, + DEVICE_ID_CA_KEY: opts.env?.DEVICE_ID_CA_KEY ?? param.REQUIRED, + DEVICE_ID_CA_COMMON_NAME: opts.env?.DEVICE_ID_CA_COMMON_NAME ?? param.OPTIONAL, }); if (rEnv.isErr()) { throw rEnv.Err(); @@ -29,7 +30,13 @@ async function ensureCA(sthis: SuperThis, actions: CAActions): Promise { + // const now = Date.now(); + const hash = await hashObjectAsync(pub); + return hash; + }, + }, }), ); } @@ -40,14 +47,19 @@ export interface DeviceIdProtocol { } export interface DeviceIdProtocolSrvOpts { - readonly actions: CAActions; + // usally from ENV + readonly env?: { + readonly DEVICE_ID_CA_KEY: string; + readonly DEVICE_ID_CA_COMMON_NAME?: string; + }; + // readonly actions: CAActions; } export class DeviceIdProtocolSrv implements DeviceIdProtocol { readonly #ca: DeviceIdCA; readonly #verifyMsg: DeviceIdVerifyMsg; static async create(sthis: SuperThis, opts: DeviceIdProtocolSrvOpts): Promise> { - const rCa = await ensureCA(sthis, opts.actions); + const rCa = await ensureCA(sthis, opts); if (rCa.isErr()) { return Result.Err(rCa); } diff --git a/core/gateways/base/uri-interceptor.ts b/core/gateways/base/uri-interceptor.ts index 13c880333..663c8e2f2 100644 --- a/core/gateways/base/uri-interceptor.ts +++ b/core/gateways/base/uri-interceptor.ts @@ -14,7 +14,7 @@ import { SerdeGatewaySubscribeReturn, } from "@fireproof/core-types-blockstore"; -export type URIMapper = (uri: URI) => Promisable; +export type URIMapper = (uri: URI) => Promisable>; export class URIInterceptor extends PassThroughGateway { static withMapper(mapper: URIMapper): URIInterceptor { @@ -28,40 +28,71 @@ export class URIInterceptor extends PassThroughGateway { return this; } - async #map(uri: URI): Promise { - let ret = uri; + async #map(uri: URI): Promise> { + let ret = Result.Ok(uri); for (const mapper of this.#uriMapper) { - ret = await mapper(ret); + ret = await mapper(ret.Ok()); + if (ret.isErr()) { + return ret; + } } return ret; } async buildUrl(ctx: SerdeGatewayCtx, url: URI, key: string): Promise> { - const ret = await super.buildUrl(ctx, await this.#map(url), key); + const mappedUrl = await this.#map(url); + if (mappedUrl.isErr()) { + return Result.Err(mappedUrl); + } + const ret = await super.buildUrl(ctx, mappedUrl.Ok(), key); return ret; } async start(ctx: SerdeGatewayCtx, url: URI): Promise> { - const ret = await super.start(ctx, await this.#map(url)); + const mappedUrl = await this.#map(url); + if (mappedUrl.isErr()) { + return Result.Err(mappedUrl); + } + const ret = await super.start(ctx, mappedUrl.Ok()); return ret; } async close(ctx: SerdeGatewayCtx, url: URI): Promise> { - const ret = await super.close(ctx, await this.#map(url)); + const mappedUrl = await this.#map(url); + if (mappedUrl.isErr()) { + return Result.Err(mappedUrl); + } + const ret = await super.close(ctx, mappedUrl.Ok()); return ret; } async delete(ctx: SerdeGatewayCtx, url: URI): Promise> { - const ret = await super.delete(ctx, await this.#map(url)); + const mappedUrl = await this.#map(url); + if (mappedUrl.isErr()) { + return Result.Err(mappedUrl); + } + const ret = await super.delete(ctx, mappedUrl.Ok()); return ret; } async destroy(ctx: SerdeGatewayCtx, url: URI): Promise> { - const ret = await super.destroy(ctx, await this.#map(url)); + const mappedUrl = await this.#map(url); + if (mappedUrl.isErr()) { + return Result.Err(mappedUrl); + } + const ret = await super.destroy(ctx, mappedUrl.Ok()); return ret; } async put(ctx: SerdeGatewayCtx, url: URI, body: FPEnvelope): Promise>> { - const ret = await super.put(ctx, await this.#map(url), body); + const mappedUrl = await this.#map(url); + if (mappedUrl.isErr()) { + return Result.Err(mappedUrl); + } + const ret = await super.put(ctx, mappedUrl.Ok(), body); return ret; } async get(ctx: SerdeGatewayCtx, url: URI): Promise>> { - const ret = await super.get(ctx, await this.#map(url)); + const mappedUrl = await this.#map(url); + if (mappedUrl.isErr()) { + return Result.Err(mappedUrl); + } + const ret = await super.get(ctx, mappedUrl.Ok()); return ret; } async subscribe( @@ -69,7 +100,11 @@ export class URIInterceptor extends PassThroughGateway { url: URI, callback: (meta: FPEnvelopeMeta) => Promise, ): Promise> { - const ret = await super.subscribe(ctx, await this.#map(url), callback); + const mappedUrl = await this.#map(url); + if (mappedUrl.isErr()) { + return Result.Err(mappedUrl); + } + const ret = await super.subscribe(ctx, mappedUrl.Ok(), callback); return ret; } } diff --git a/core/gateways/cloud/to-cloud.ts b/core/gateways/cloud/to-cloud.ts index 60adb3db2..56cf488e5 100644 --- a/core/gateways/cloud/to-cloud.ts +++ b/core/gateways/cloud/to-cloud.ts @@ -1,8 +1,8 @@ -import { BuildURI, CoerceURI, Logger, ResolveOnce, URI, AppContext, KeyedResolvOnce, Lazy } from "@adviser/cement"; +import { BuildURI, CoerceURI, URI, AppContext, KeyedResolvOnce, Lazy, Result } from "@adviser/cement"; import { Ledger } from "@fireproof/core-types-base"; import { FPCloudClaim, - FPCloudClaimParseSchema, + FPCloudClaimSchema, FPCloudUri, hashableFPCloudRef, ToCloudAttachable, @@ -11,7 +11,7 @@ import { ToCloudOptionalOpts, ToCloudOpts, ToCloudRequiredOpts, - TokenAndClaims, + TokenAndSelectedTenantAndLedger, TokenStrategie, } from "@fireproof/core-types-protocols-cloud"; import { ensureLogger, ensureSuperThis, hashObjectSync } from "@fireproof/core-runtime"; @@ -31,12 +31,13 @@ function addTenantAndLedger(opts: ToCloudOptionalOpts, uri: CoerceURI): URI { } export class SimpleTokenStrategy implements TokenStrategie { - private tc: TokenAndClaims; + readonly waitState: "started" | "stopped" = "stopped"; + private tc: TokenAndSelectedTenantAndLedger; constructor(jwk: string) { let claims: FPCloudClaim; try { const rawClaims = decodeJwt(jwk); - const rParse = FPCloudClaimParseSchema.safeParse(rawClaims); + const rParse = FPCloudClaimSchema.safeParse(rawClaims); if (rParse.success) { claims = rParse.data; } else { @@ -70,13 +71,13 @@ export class SimpleTokenStrategy implements TokenStrategie { // console.log("SimpleTokenStrategy open"); return; } - async tryToken(): Promise { - // console.log("SimpleTokenStrategy gatherToken"); - return this.tc; - } - async waitForToken(): Promise { + // async tryToken(): Promise { + // // console.log("SimpleTokenStrategy gatherToken"); + // return this.tc; + // } + async waitForToken(): Promise> { // console.log("SimpleTokenStrategy waitForToken"); - return this.tc; + return Result.Ok(this.tc); } } @@ -121,79 +122,79 @@ function defaultOpts(opts: ToCloudOptionalOpts): ToCloudOpts { // }; // } -function definedExp(exp?: number): number { - if (typeof exp === "number") { - return exp; - } - return new Date().getTime() / 1000; -} - -class TokenObserver { - private readonly opts: ToCloudOpts; - - currentTokenAndClaim?: TokenAndClaims; - - constructor(opts: ToCloudOpts) { - this.opts = opts; - } - - async start() { - return; - } - - async stop() { - // clear pending refresh token - return; - } - - async refreshToken(logger: Logger, ledger: Ledger) { - let token = await this.opts.strategy.tryToken(ledger.sthis, logger, this.opts); - // console.log("refreshToken", token); - if (this.isExpired(token)) { - logger.Debug().Msg("waiting for token"); - this.opts.strategy.open(ledger.sthis, logger, ledger.name, this.opts); - token = await this.opts.strategy.waitForToken(ledger.sthis, logger, ledger.name, this.opts); - if (!token) { - throw new Error("Token not found"); - } - } - return token; - } - - isExpired(token?: TokenAndClaims): boolean { - const now = ~~(new Date().getTime() / 1000); // current time in seconds - return !token || definedExp(token.claims?.exp) - this.opts.refreshTokenPresetSec < now; - } - - readonly _token = new ResolveOnce(); - async getToken(logger: Logger, ledger: Ledger): Promise { - let activeTokenAndClaim = this.currentTokenAndClaim; - if (this.isExpired(activeTokenAndClaim)) { - // console.log("refreshing token", this.currentTokenAndClaim?.claims.exp); - await this.opts.events?.changed(undefined); - logger - .Debug() - .Any({ claims: this.currentTokenAndClaim?.claims, exp: definedExp(this.currentTokenAndClaim?.claims?.exp) }) - .Msg("refresh token"); - activeTokenAndClaim = await this.refreshToken(logger, ledger); - } - - if (activeTokenAndClaim && activeTokenAndClaim.token !== this.currentTokenAndClaim?.token) { - this.currentTokenAndClaim = activeTokenAndClaim; - await this.opts.events?.changed(activeTokenAndClaim); - } - if (this.currentTokenAndClaim) { - return this.currentTokenAndClaim; - } - throw logger.Error().Msg("Token not found").AsError(); - } +// function definedExp(exp?: number): number { +// if (typeof exp === "number") { +// return exp; +// } +// return new Date().getTime() / 1000; +// } - async reset() { - this.currentTokenAndClaim = undefined; - await this.opts.events?.changed(undefined); - return; - } -} +// class TokenObserver { +// private readonly opts: ToCloudOpts; + +// currentTokenAndClaim?: TokenAndClaims; + +// constructor(opts: ToCloudOpts) { +// this.opts = opts; +// } + +// async start() { +// return; +// } + +// async stop() { +// // clear pending refresh token +// return; +// } + +// // async refreshToken(logger: Logger, ledger: Ledger) { +// // let token = await this.opts.strategy.tryToken(ledger.sthis, logger, this.opts); +// // // console.log("refreshToken", token); +// // if (this.isExpired(token)) { +// // logger.Debug().Msg("waiting for token"); +// // this.opts.strategy.open(ledger.sthis, logger, ledger.name, this.opts); +// // token = await this.opts.strategy.waitForToken(ledger.sthis, logger, ledger.name, this.opts); +// // if (!token) { +// // throw new Error("Token not found"); +// // } +// // } +// // return token; +// // } + +// isExpired(token?: TokenAndClaims): boolean { +// const now = ~~(new Date().getTime() / 1000); // current time in seconds +// return !token || definedExp(token.claims?.exp) - this.opts.refreshTokenPresetSec < now; +// } + +// readonly _token = new ResolveOnce(); +// async getToken(logger: Logger, ledger: Ledger): Promise { +// let activeTokenAndClaim = this.currentTokenAndClaim; +// if (this.isExpired(activeTokenAndClaim)) { +// // console.log("refreshing token", this.currentTokenAndClaim?.claims.exp); +// await this.opts.events?.changed(undefined); +// logger +// .Debug() +// .Any({ claims: this.currentTokenAndClaim?.claims, exp: definedExp(this.currentTokenAndClaim?.claims?.exp) }) +// .Msg("refresh token"); +// activeTokenAndClaim = await this.refreshToken(logger, ledger); +// } + +// if (activeTokenAndClaim && activeTokenAndClaim.token !== this.currentTokenAndClaim?.token) { +// this.currentTokenAndClaim = activeTokenAndClaim; +// await this.opts.events?.changed(activeTokenAndClaim); +// } +// if (this.currentTokenAndClaim) { +// return this.currentTokenAndClaim; +// } +// throw logger.Error().Msg("Token not found").AsError(); +// } + +// async reset() { +// this.currentTokenAndClaim = undefined; +// await this.opts.events?.changed(undefined); +// return; +// } +// } class ToCloud implements ToCloudAttachable { readonly opts: ToCloudOpts; @@ -208,7 +209,7 @@ class ToCloud implements ToCloudAttachable { return this.opts.name; } - private _tokenObserver!: TokenObserver; + // private _tokenObserver!: TokenObserver; configHash(db?: Ledger) { const hash = hashObjectSync({ @@ -240,13 +241,18 @@ class ToCloud implements ToCloudAttachable { const logger = ensureLogger(ledger.sthis, "ToCloud"); // .SetDebug("ToCloud"); // console.log("ToCloud prepare", this.opts); - this._tokenObserver = new TokenObserver(this.opts); - await this._tokenObserver.start(); + // this._tokenObserver = new TokenObserver(this.opts); + // await this._tokenObserver.start(); // console.log("prepare"); const gatewayInterceptor = URIInterceptor.withMapper(async (uri) => { // wait for the token - const token = await this._tokenObserver.getToken(logger, ledger); + // const token = await this._tokenObserver.getToken(logger, ledger); + const rToken = await this.opts.strategy.waitForToken(ledger.sthis, logger, ledger.name, this.opts); + if (!rToken.isErr) { + return Result.Err(rToken); + } + const token = rToken.unwrap(); // console.log("getToken", token) const buri = BuildURI.from(uri).setParam("authJWK", token.token); @@ -265,14 +271,14 @@ class ToCloud implements ToCloudAttachable { buri.setParam("ledger", this.opts.ledger); } - return buri.URI(); + return Result.Ok(buri.URI()); }); return { car: { url: this.opts.urls.car, gatewayInterceptor }, file: { url: this.opts.urls.file, gatewayInterceptor }, meta: { url: this.opts.urls.meta, gatewayInterceptor }, teardown: () => { - this._tokenObserver.stop(); + // this._tokenObserver.stop(); }, ctx: this.opts.context, }; diff --git a/core/gateways/file-node/node-filesystem.ts b/core/gateways/file-node/node-filesystem.ts index 411c213d8..c44b6026d 100644 --- a/core/gateways/file-node/node-filesystem.ts +++ b/core/gateways/file-node/node-filesystem.ts @@ -16,7 +16,8 @@ export class NodeFileSystem implements SysFileSystem { }; async start(): Promise { - this.fs = await import("node:fs/promises"); + const fsPromise = "node:fs/promises"; + this.fs = await import(/* @vite-ignore */ fsPromise); return this; } async mkdir(path: PathLike, options?: { recursive: boolean }): Promise { @@ -45,38 +46,3 @@ export class NodeFileSystem implements SysFileSystem { return this.fs?.writeFile(path, data); } } - -// import { type NodeMap, join } from "../../sys-container.js"; -// import type { ObjectEncodingOptions, PathLike } from "fs"; -// import * as fs from "fs/promises"; -// import * as path from "path"; -// import * as os from "os"; -// import * as url from "url"; -// import { toArrayBuffer } from "./utils.js"; - -// export async function createNodeSysContainer(): Promise { -// // const nodePath = "node:path"; -// // const nodeOS = "node:os"; -// // const nodeURL = "node:url"; -// // const nodeFS = "node:fs"; -// // const fs = (await import("node:fs")).promises; -// // const assert = "assert"; -// // const path = await import("node:path"); -// return { -// state: "node", -// ...path, -// // ...(await import("node:os")), -// // ...(await import("node:url")), -// ...os, -// ...url, -// ...fs, -// join, -// stat: fs.stat as NodeMap["stat"], -// readdir: fs.readdir as NodeMap["readdir"], -// readfile: async (path: PathLike, options?: ObjectEncodingOptions): Promise => { -// const rs = await fs.readFile(path, options); -// return toArrayBuffer(rs); -// }, -// writefile: fs.writeFile as NodeMap["writefile"], -// }; -// } diff --git a/core/protocols/cloud/msger.ts b/core/protocols/cloud/msger.ts index e7bd6e894..4cf6bccff 100644 --- a/core/protocols/cloud/msger.ts +++ b/core/protocols/cloud/msger.ts @@ -1,4 +1,4 @@ -import { BuildURI, CoerceURI, Logger, Result, runtimeFn, URI } from "@adviser/cement"; +import { BuildURI, CoerceURI, Logger, Result, runtimeFn, URI, sleep } from "@adviser/cement"; import { buildReqGestalt, defaultGestalt, @@ -30,7 +30,7 @@ import { import { ensurePath, HttpConnection } from "./http-connection.js"; import { WSConnection } from "./ws-connection.js"; import { SuperThis } from "@fireproof/core-types-base"; -import { ensureLogger, sleep } from "@fireproof/core-runtime"; +import { ensureLogger } from "@fireproof/core-runtime"; import pLimit from "@fireproof/vendor/p-limit"; // const headers = { diff --git a/core/protocols/dashboard/msg-api.ts b/core/protocols/dashboard/msg-api.ts index 1a11dec2f..f9d5b0682 100644 --- a/core/protocols/dashboard/msg-api.ts +++ b/core/protocols/dashboard/msg-api.ts @@ -1,14 +1,27 @@ -import { exception2Result, Logger, Result } from "@adviser/cement"; -import { ReqTokenByResultId, ResTokenByResultId } from "./msg-types.js"; +import { exception2Result, Lazy, Logger, Result } from "@adviser/cement"; +import { + ReqClerkPublishableKey, + ReqCloudDbToken, + ReqTokenByResultId, + ResClerkPublishableKey, + ResCloudDbToken, + ResTokenByResultId, +} from "./msg-types.js"; import { FAPIMsgImpl } from "./msg-is.js"; +import { ensureLogger } from "@fireproof/core-runtime"; +import { SuperThis } from "@fireproof/core-types-base"; -export class Api { +export class DashApi { readonly apiUrl: string; readonly isser = new FAPIMsgImpl(); - constructor(apiUrl: string) { + readonly logger: Logger; + constructor(sthis: SuperThis, apiUrl: string) { + this.logger = ensureLogger(sthis, "DashApi"); this.apiUrl = apiUrl; } + readonly hash = Lazy(() => this.apiUrl); + async request(req: Q): Promise> { return exception2Result(async () => { const res = await fetch(this.apiUrl.toString(), { @@ -27,6 +40,18 @@ export class Api { }); } + readonly getClerkPublishableKey = Lazy(async (req: Omit = {}) => { + const rRes = await this.request({ ...req, type: "reqClerkPublishableKey" }); + if (rRes.isErr()) { + throw rRes.Err(); + } + return rRes.unwrap(); + }); + + getCloudDbToken(req: Omit): Promise> { + return this.request({ ...req, type: "reqCloudDbToken" }); + } + async waitForToken(req: Omit, logger: Logger): Promise> { const rTokenByResultId = await this.request({ ...req, diff --git a/core/protocols/dashboard/msg-is.ts b/core/protocols/dashboard/msg-is.ts index 12da46b2a..7894bf2cd 100644 --- a/core/protocols/dashboard/msg-is.ts +++ b/core/protocols/dashboard/msg-is.ts @@ -18,6 +18,9 @@ import { ReqDeleteLedger, ResTokenByResultId, ReqExtendToken, + ReqClerkPublishableKey, + ResClerkPublishableKey, + ReqCloudDbToken, } from "./msg-types.js"; interface FPApiMsgInterface { @@ -32,9 +35,12 @@ interface FPApiMsgInterface { isEnsureUser(jso: unknown): jso is ReqEnsureUser; isListTenantsByUser(jso: unknown): jso is ReqListTenantsByUser; isUpdateUserTenant(jso: unknown): jso is ReqUpdateUserTenant; - isCloudSessionToken(jso: unknown): jso is ReqCloudSessionToken; + isReqCloudSessionToken(jso: unknown): jso is ReqCloudSessionToken; + isReqCloudDbToken(jso: unknown): jso is ReqCloudDbToken; isReqTokenByResultId(jso: unknown): jso is ReqTokenByResultId; isResTokenByResultId(jso: unknown): jso is ResTokenByResultId; + isReqClerkPublishableKey(jso: unknown): jso is ReqClerkPublishableKey; + isResClerkPublishableKey(jso: unknown): jso is ResClerkPublishableKey; isListLedgersByUser(jso: unknown): jso is ReqListLedgersByUser; isCreateLedger(jso: unknown): jso is ReqCreateLedger; isUpdateLedger(jso: unknown): jso is ReqUpdateLedger; @@ -91,7 +97,7 @@ export class FAPIMsgImpl implements FPApiMsgInterface { isDeleteLedger(jso: unknown): jso is ReqDeleteLedger { return hasType(jso, "reqDeleteLedger"); } - isCloudSessionToken(jso: unknown): jso is ReqCloudSessionToken { + isReqCloudSessionToken(jso: unknown): jso is ReqCloudSessionToken { return hasType(jso, "reqCloudSessionToken"); } isReqTokenByResultId(jso: unknown): jso is ReqTokenByResultId { @@ -103,4 +109,13 @@ export class FAPIMsgImpl implements FPApiMsgInterface { isReqExtendToken(jso: unknown): jso is ReqExtendToken { return hasType(jso, "reqExtendToken"); } + isReqClerkPublishableKey(jso: unknown): jso is ReqClerkPublishableKey { + return hasType(jso, "reqClerkPublishableKey"); + } + isResClerkPublishableKey(jso: unknown): jso is ResClerkPublishableKey { + return hasType(jso, "resClerkPublishableKey"); + } + isReqCloudDbToken(jso: unknown): jso is ReqCloudDbToken { + return hasType(jso, "reqCloudDbToken"); + } } diff --git a/core/protocols/dashboard/msg-types.ts b/core/protocols/dashboard/msg-types.ts index b97a2610b..dadf7b37c 100644 --- a/core/protocols/dashboard/msg-types.ts +++ b/core/protocols/dashboard/msg-types.ts @@ -1,3 +1,4 @@ +import { JWKPublic } from "@fireproof/core-types-base"; import { ReadWrite, Role, TenantLedger } from "@fireproof/core-types-protocols-cloud"; export type AuthProvider = "github" | "google" | "fp" | "invite-per-email"; @@ -124,13 +125,21 @@ export interface ResCreateTenant { readonly tenant: OutTenantParams; } -export interface InCreateTenantParams { +export interface FPApiParameters { + readonly cloudPublicKeys: JWKPublic[]; + readonly clerkPublishableKey: string; + readonly maxTenants: number; + readonly maxAdminUsers: number; + readonly maxMemberUsers: number; + readonly maxInvites: number; + readonly maxLedgers: number; +} + +export type InCreateTenantParams = { readonly name?: string; + readonly defaultTenant?: boolean; readonly ownerUserId: string; - readonly maxAdminUsers?: number; - readonly maxMemberUsers?: number; - readonly maxInvites?: number; -} +} & Partial; export interface ReqCreateTenant { readonly type: "reqCreateTenant"; @@ -405,6 +414,48 @@ export interface ResDeleteLedger { readonly type: "resDeleteLedger"; } +export interface ReqCloudDbToken { + readonly type: "reqCloudDbToken"; + readonly auth: AuthType; + readonly tenantId?: string; + readonly ledgerId?: string; + readonly localDbName: string; + readonly appId: string; + readonly deviceId: string; +} + +export interface ResCloudDbTokenBound { + readonly type: "resCloudDbToken"; + readonly status: "bound"; + readonly token: string; // JWT +} + +export interface ResCloudDbTokenNotBound { + readonly type: "resCloudDbToken"; + readonly status: "not-bound"; + readonly reason: string; + // helps in the binding process + readonly ledgers: { + readonly ledgerId: string; + readonly tenantId: string; + readonly name: string; + }[]; +} + +export type ResCloudDbToken = ResCloudDbTokenBound | ResCloudDbTokenNotBound; + +export function isResCloudDbTokenBound(res: ResCloudDbToken): res is ResCloudDbTokenBound { + return res.status === "bound" && res.type === "resCloudDbToken"; +} +export function isResCloudDbTokenNotBound(res: ResCloudDbToken): res is ResCloudDbTokenNotBound { + return res.status === "not-bound" && res.type === "resCloudDbToken"; +} + +export interface GetCloudDbTokenResult { + readonly res: ResCloudDbToken; + readonly claims?: unknown; +} + export interface ReqCloudSessionToken { readonly type: "reqCloudSessionToken"; readonly auth: AuthType; @@ -422,6 +473,16 @@ export interface ReqTokenByResultId { readonly resultId: string; } +export interface ReqClerkPublishableKey { + readonly type: "reqClerkPublishableKey"; +} + +export interface ResClerkPublishableKey { + readonly type: "resClerkPublishableKey"; + readonly publishableKey: string; + readonly cloudPublicKeys: JWKPublic[]; // same as .well-known/jwks.json +} + export interface ResTokenByResultId { readonly type: "resTokenByResultId"; readonly status: "found" | "not-found"; diff --git a/core/runtime/utils.ts b/core/runtime/utils.ts index 16595d7eb..1c7846202 100644 --- a/core/runtime/utils.ts +++ b/core/runtime/utils.ts @@ -71,6 +71,10 @@ class SuperThisImpl implements SuperThis { // console.log("superThis", this); } + hash(): string { + return "superthis-hash-is-not-implemented-but-a-dummy"; + } + nextId(bytes = 6): { str: string; bin: Uint8Array } { const bin = this.crypto.randomBytes(bytes); return { @@ -630,9 +634,9 @@ export async function hashObjectCID, S>(o: T): Promise< return { cid: CID.create(1, json.code, hash), bytes, obj: o }; } -export function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +// export function sleep(ms: number) { +// return new Promise((resolve) => setTimeout(resolve, ms)); +// } /** * Deep clone a value diff --git a/core/tests/blockstore/interceptor-gateway.test.ts b/core/tests/blockstore/interceptor-gateway.test.ts index c8ab27e8e..c307345d0 100644 --- a/core/tests/blockstore/interceptor-gateway.test.ts +++ b/core/tests/blockstore/interceptor-gateway.test.ts @@ -226,10 +226,12 @@ describe("InterceptorGateway", () => { base: "uriTest://inspector-gateway", }, gatewayInterceptor: URIInterceptor.withMapper(async (uri: URI) => - uri - .build() - .setParam("itis", "" + ++callCount) - .URI(), + Result.Ok( + uri + .build() + .setParam("itis", "" + ++callCount) + .URI(), + ), ), }); await Promise.all( diff --git a/core/tests/blockstore/standalone.test.ts b/core/tests/blockstore/standalone.test.ts index e539478f1..405e9f217 100644 --- a/core/tests/blockstore/standalone.test.ts +++ b/core/tests/blockstore/standalone.test.ts @@ -1,8 +1,8 @@ -import { BuildURI, runtimeFn, URI } from "@adviser/cement"; +import { BuildURI, runtimeFn, URI, sleep } from "@adviser/cement"; import { Link } from "multiformats"; import { stripper } from "@adviser/cement/utils"; import pLimit from "@fireproof/vendor/p-limit"; -import { ensureSuperThis, sleep } from "@fireproof/core-runtime"; +import { ensureSuperThis } from "@fireproof/core-runtime"; import { CRDT, PARAM, LedgerOpts } from "@fireproof/core-types-base"; import { describe, it, vi, expect, beforeEach, afterEach } from "vitest"; import { Loader } from "@fireproof/core-blockstore"; diff --git a/core/tests/fireproof/attachable-subscription.test.ts b/core/tests/fireproof/attachable-subscription.test.ts index 2260759f9..3524a4131 100644 --- a/core/tests/fireproof/attachable-subscription.test.ts +++ b/core/tests/fireproof/attachable-subscription.test.ts @@ -1,7 +1,7 @@ -import { AppContext, BuildURI, WithoutPromise } from "@adviser/cement"; +import { AppContext, BuildURI, WithoutPromise, sleep } from "@adviser/cement"; import { Attachable, Database, fireproof, GatewayUrlsParam, PARAM, DocBase } from "@fireproof/core"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { ensureSuperThis, sleep } from "@fireproof/core-runtime"; +import { ensureSuperThis } from "@fireproof/core-runtime"; const ROWS = 2; diff --git a/core/tests/fireproof/attachable.test.ts b/core/tests/fireproof/attachable.test.ts index 4f1aff9e1..b48242007 100644 --- a/core/tests/fireproof/attachable.test.ts +++ b/core/tests/fireproof/attachable.test.ts @@ -1,11 +1,11 @@ -import { AppContext, BuildURI, URI, WithoutPromise } from "@adviser/cement"; +import { AppContext, BuildURI, URI, WithoutPromise, sleep } from "@adviser/cement"; import { stripper } from "@adviser/cement/utils"; import { Attachable, Database, fireproof, GatewayUrlsParam, PARAM, Attached, TraceFn } from "@fireproof/core"; import { CarReader } from "@ipld/car/reader"; import * as dagCbor from "@ipld/dag-cbor"; import { mockLoader } from "../helpers.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { ensureSuperThis, sleep } from "@fireproof/core-runtime"; +import { ensureSuperThis } from "@fireproof/core-runtime"; import { DefSerdeGateway } from "@fireproof/core-gateways-base"; import { MemoryGateway } from "@fireproof/core-gateways-memory"; import { AttachedRemotesImpl } from "@fireproof/core-blockstore"; diff --git a/core/tests/fireproof/fireproof.test.ts b/core/tests/fireproof/fireproof.test.ts index 463d5c9a9..20cd6bd86 100644 --- a/core/tests/fireproof/fireproof.test.ts +++ b/core/tests/fireproof/fireproof.test.ts @@ -3,8 +3,8 @@ import { docs } from "./fireproof.test.fixture.js"; import { CID } from "multiformats/cid"; import { Index, index, fireproof, isDatabase } from "@fireproof/core-base"; -import { URI } from "@adviser/cement"; -import { ensureSuperThis, sleep } from "@fireproof/core-runtime"; +import { URI, sleep } from "@adviser/cement"; +import { ensureSuperThis } from "@fireproof/core-runtime"; import { DocResponse, DocWithId, IndexRows, Database, PARAM, MapFn, ConfigOpts } from "@fireproof/core-types-base"; import { describe, afterEach, beforeEach, it, expect, beforeAll, assert } from "vitest"; import { AnyLink } from "@fireproof/core-types-blockstore"; diff --git a/core/tests/vitest.indexeddb.config.ts b/core/tests/vitest.indexeddb.config.ts index b6c631fc7..c86bc3823 100644 --- a/core/tests/vitest.indexeddb.config.ts +++ b/core/tests/vitest.indexeddb.config.ts @@ -13,12 +13,6 @@ export default defineConfig({ instances: [ { browser: "chromium", - //setupFile: './chromium-setup.js', - // context: { - // // Disable screenshots and video recording - // recordVideo: undefined, - // recordHar: undefined, - // }, }, ], screenshotFailures: false, diff --git a/core/types/base/types.ts b/core/types/base/types.ts index 9c8afe9ea..b6bf5ddbe 100644 --- a/core/types/base/types.ts +++ b/core/types/base/types.ts @@ -159,6 +159,7 @@ export interface SuperThis { nextId(bytes?: number): { str: string; bin: Uint8Array; toString: () => string }; start(): Promise; clone(override: Partial): SuperThis; + hash(): string; } export interface IdleEventFromCommitQueue { diff --git a/core/types/protocols/cloud/gateway-control.ts b/core/types/protocols/cloud/gateway-control.ts index bc72880a7..ec6829dd9 100644 --- a/core/types/protocols/cloud/gateway-control.ts +++ b/core/types/protocols/cloud/gateway-control.ts @@ -1,15 +1,21 @@ -import { Logger, CoerceURI, URI, AppContext } from "@adviser/cement"; +import { Logger, CoerceURI, URI, AppContext, Result } from "@adviser/cement"; import { Attachable, SuperThis } from "@fireproof/core-types-base"; -import { FPCloudClaim } from "./msg-types.zod.js"; +// import { FPCloudClaim } from "./msg-types.zod.js"; export interface ToCloudAttachable extends Attachable { token?: string; readonly opts: ToCloudOpts; } -export interface TokenAndClaims { +export interface TokenAndSelectedTenantAndLedger { readonly token: string; - readonly claims?: FPCloudClaim; + readonly claims: { + readonly selected: { + readonly tenant: string; + readonly ledger: string; + }; + }; + //FPCloudClaim; // readonly exp: number; // readonly tenant?: string; // readonly ledger?: string; @@ -17,10 +23,17 @@ export interface TokenAndClaims { } export interface TokenStrategie { + waitState: "started" | "stopped"; + ready?: () => Promise; hash(): string; - open(sthis: SuperThis, logger: Logger, deviceId: string, opts: ToCloudOpts): void; - tryToken(sthis: SuperThis, logger: Logger, opts: ToCloudOpts): Promise; - waitForToken(sthis: SuperThis, logger: Logger, deviceId: string, opts: ToCloudOpts): Promise; + open(sthis: SuperThis, logger: Logger, localDbName: string, opts: ToCloudOpts): void; + // tryToken(sthis: SuperThis, logger: Logger, opts: ToCloudOpts): Promise; + waitForToken( + sthis: SuperThis, + logger: Logger, + localDbName: string, + opts: ToCloudOpts, + ): Promise>; stop(): void; } @@ -53,7 +66,7 @@ export function hashableFPCloudRef(ref?: Partial): { base?: string; export interface TokenAndClaimsEvents { hash(): string; - changed(token?: TokenAndClaims): Promise; + changed(token?: TokenAndSelectedTenantAndLedger): Promise; } export interface ToCloudRequiredOpts { diff --git a/core/types/protocols/cloud/msg-types.zod.ts b/core/types/protocols/cloud/msg-types.zod.ts index 1bfdcc9c9..48f0f3555 100644 --- a/core/types/protocols/cloud/msg-types.zod.ts +++ b/core/types/protocols/cloud/msg-types.zod.ts @@ -28,11 +28,11 @@ export const FPCloudClaimSchema = JWTPayloadSchema.extend({ email: z.email(), nickname: z.string().optional(), provider: z.enum(["github", "google"]).optional(), - created: z.date(), + created: z.coerce.date(), tenants: z.array(TenantClaimSchema), ledgers: z.array(LedgerClaimSchema), selected: TenantLedgerSchema, -}); +}).readonly(); // Type inference from schemas export type Role = z.infer; @@ -42,15 +42,15 @@ export type LedgerClaim = z.infer; export type TenantLedger = z.infer; export type FPCloudClaim = z.infer; -// For parsing JWT payload with date transformation -export const FPCloudClaimParseSchema = JWTPayloadSchema.extend({ - userId: z.string(), - email: z.email(), - nickname: z.string().optional(), - provider: z.enum(["github", "google"]).optional(), - // Transform string to Date if needed (common in JWT parsing) - created: z.union([z.date(), z.string().transform((str) => new Date(str)), z.number().transform((num) => new Date(num))]), - tenants: z.array(TenantClaimSchema), - ledgers: z.array(LedgerClaimSchema), - selected: TenantLedgerSchema, -}); +// // For parsing JWT payload with date transformation +// export const FPCloudClaimParseSchema = JWTPayloadSchema.extend({ +// userId: z.string(), +// email: z.email(), +// nickname: z.string().optional(), +// provider: z.enum(["github", "google"]).optional(), +// // Transform string to Date if needed (common in JWT parsing) +// created: z.union([z.date(), z.string().transform((str) => new Date(str)), z.number().transform((num) => new Date(num))]), +// tenants: z.array(TenantClaimSchema), +// ledgers: z.array(LedgerClaimSchema), +// selected: TenantLedgerSchema, +// }); diff --git a/dashboard/.prettierignore b/dashboard/.prettierignore new file mode 100644 index 000000000..0360df56a --- /dev/null +++ b/dashboard/.prettierignore @@ -0,0 +1,7 @@ +**/pnpm-lock.yaml +scripts/ +**/.cache/** +**/.esm-cache/** +**/dist/** +**/coverage/** +dist/ diff --git a/dashboard/backend/api.ts b/dashboard/backend/api.ts index 101d2a2aa..80d2c3d66 100644 --- a/dashboard/backend/api.ts +++ b/dashboard/backend/api.ts @@ -1,5 +1,4 @@ import { Result } from "@adviser/cement"; -import { SuperThis } from "@fireproof/core"; import { gte, and, eq, gt, inArray, lt, ne, or } from "drizzle-orm/sql/expressions"; // import type { LibSQLDatabase } from "drizzle-orm/libsql"; import { jwtVerify } from "jose"; @@ -53,6 +52,11 @@ import { UserStatus, VerifiedAuth, FAPIMsgImpl, + FPApiParameters, + ResClerkPublishableKey, + ReqClerkPublishableKey, + ReqCloudDbToken, + ResCloudDbToken, } from "@fireproof/core-protocols-dashboard"; import { prepareInviteTicket, sqlInviteTickets, sqlToInviteTickets } from "./invites.js"; import { sqlLedgerUsers, sqlLedgers, sqlToLedgers } from "./ledgers.js"; @@ -62,8 +66,10 @@ import { sqlTokenByResultId } from "./token-by-result-id.js"; import { UserNotFoundError, getUser, isUserNotFound, queryUser, upsetUserByProvider } from "./users.js"; import { createFPToken, FPTokenContext, getFPTokenContext } from "./create-fp-token.js"; import { Role, ReadWrite, toRole, toReadWrite, FPCloudClaim } from "@fireproof/core-types-protocols-cloud"; +import { SuperThis } from "@fireproof/core-types-base"; import { sts } from "@fireproof/core-runtime"; import { DashSqlite } from "./create-handler.js"; +import { getCloudDbToken, getCloudSessionToken } from "./cloud-token.js"; function sqlToOutTenantParams(sql: typeof sqlTenants.$inferSelect): OutTenantParams { return { @@ -115,6 +121,8 @@ export interface FPApiInterface { // attachUserToLedger(req: ReqAttachUserToLedger): Promise getCloudSessionToken(req: ReqCloudSessionToken): Promise>; + + getCloudDbToken(req: ReqCloudDbToken): Promise>; getTokenByResultId(req: ReqTokenByResultId): Promise>; extendToken(req: ReqExtendToken): Promise>; } @@ -162,7 +170,7 @@ interface AddUserToTenant { readonly tenantName?: string; readonly tenantId: string; readonly userId: string; - readonly default?: boolean; + readonly defaultTenant?: boolean; readonly role: Role; readonly status?: UserStatus; readonly statusReason?: string; @@ -174,7 +182,7 @@ interface AddUserToLedger { readonly ledgerId: string; readonly tenantId: string; readonly userId: string; - readonly default?: boolean; + readonly defaultLedger?: boolean; readonly status?: UserStatus; readonly statusReason?: string; readonly role: Role; @@ -200,18 +208,28 @@ interface WithAuth { readonly auth: AuthType; } -interface ActiveUser { +export interface ActiveWithUser { + readonly verifiedAuth: T; + readonly user: User; +} + +export function isActiveWithUser(obj: ActivatedAuth): obj is ActiveWithUser { + return typeof obj === "object" && obj !== null && "verifiedAuth" in obj && "user" in obj; +} + +export interface ActiveVerified { readonly verifiedAuth: T; - readonly user?: User; } -type ActiveUserWithUserId = Omit, "user"> & { +type ActiveUserWithUserId = Omit, "user"> & { user: { userId: string; maxTenants: number; }; }; +type ActivatedAuth = ActiveWithUser | ActiveVerified; + function nameFromAuth(name: string | undefined, auth: ActiveUserWithUserId): string { return name ?? `${auth.verifiedAuth.params.email ?? nickFromClarkClaim(auth.verifiedAuth.params) ?? auth.verifiedAuth.userId}`; } @@ -224,10 +242,19 @@ export class FPApiSQL implements FPApiInterface { readonly db: DashSqlite; readonly tokenApi: Record; readonly sthis: SuperThis; - constructor(sthis: SuperThis, db: DashSqlite, tokenApi: Record) { + readonly params: FPApiParameters; + constructor(sthis: SuperThis, db: DashSqlite, tokenApi: Record, params: FPApiParameters) { this.db = db; this.tokenApi = tokenApi; this.sthis = sthis; + this.params = params; + } + + getCloudSessionToken(req: ReqCloudSessionToken, ictx: Partial = {}): Promise> { + return getCloudSessionToken(this, req, ictx); + } + getCloudDbToken(req: ReqCloudDbToken, ictx: Partial = {}): Promise> { + return getCloudDbToken(this, req, ictx); } private async _authVerifyAuth(req: { readonly auth: AuthType }): Promise> { @@ -249,7 +276,7 @@ export class FPApiSQL implements FPApiInterface { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - private async activeUser(req: WithAuth, status: UserStatus[] = ["active"]): Promise> { + async activeUser(req: WithAuth, status: UserStatus[] = ["active"]): Promise> { // console.log("activeUser-1", req); const rAuth = await this._authVerifyAuth(req); if (rAuth.isErr()) { @@ -278,9 +305,9 @@ export class FPApiSQL implements FPApiInterface { if (activeUser.isErr()) { return Result.Err(activeUser.Err()); } - const user = activeUser.Ok().user; - if (!user) { - const auth = activeUser.Ok().verifiedAuth; + const activated = activeUser.Ok(); + if (!isActiveWithUser(activated)) { + const auth = activated.verifiedAuth; const userId = this.sthis.nextId(12).str; const now = new Date(); await upsetUserByProvider( @@ -313,16 +340,15 @@ export class FPApiSQL implements FPApiInterface { }, }; const rTenant = await this.insertTenant(authWithUserId, { + ...this.params, ownerUserId: userId, - maxAdminUsers: 5, - maxMemberUsers: 5, }); await this.addUserToTenant(this.db, { userName: nameFromAuth(undefined, authWithUserId), tenantId: rTenant.Ok().tenantId, userId: userId, role: "admin", - default: true, + defaultTenant: true, }); // }); @@ -330,7 +356,7 @@ export class FPApiSQL implements FPApiInterface { } return Result.Ok({ type: "resEnsureUser", - user: user, + user: activated.user, tenants: await this.listTenantsByUser({ type: "reqListTenantsByUser", auth: req.auth, @@ -371,7 +397,7 @@ export class FPApiSQL implements FPApiInterface { tenantName: toUndef(tenant.name), tenantId: req.tenantId, userId: req.userId, - default: !!tenantUser.default, + defaultTenant: !!tenantUser.default, role: toRole(tenantUser.role), status: tenantUser.status as UserStatus, statusReason: tenantUser.statusReason, @@ -382,7 +408,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rCheck.Err()); } const now = new Date().toISOString(); - if (req.default) { + if (req.defaultTenant) { await db .update(sqlTenantUsers) .set({ @@ -392,6 +418,7 @@ export class FPApiSQL implements FPApiInterface { .where(and(eq(sqlTenantUsers.userId, req.userId), ne(sqlTenantUsers.default, 0))) .run(); } + // console.log("Adding user to tenant:", req.userId, "->", req.tenantId, "as", req.defaultTenant); const ret = ( await db .insert(sqlTenantUsers) @@ -400,18 +427,24 @@ export class FPApiSQL implements FPApiInterface { userId: req.userId, name: req.userName, role: req.role, - default: req.default ? 1 : 0, + default: req.defaultTenant ? 1 : 0, createdAt: now, updatedAt: now, }) .returning() )[0]; + // const out = await db + // .select() + // .from(sqlTenantUsers) + // .where(and(eq(sqlTenantUsers.userId, req.userId))) + // .all(); + // console.log("Added user to tenant:", out); return Result.Ok({ userName: toUndef(ret.name), tenantName: tenant.name, tenantId: tenant.tenantId, userId: ret.userId, - default: ret.default ? true : false, + defaultTenant: ret.default ? true : false, status: ret.status as UserStatus, statusReason: ret.statusReason, role: toRole(ret.role), @@ -486,7 +519,7 @@ export class FPApiSQL implements FPApiInterface { ledgerId: ledgerUser.Ledgers.ledgerId, tenantId: ledgerUser.Ledgers.tenantId, userId: req.userId, - default: !!ledgerUser.LedgerUsers.default, + defaultLedger: !!ledgerUser.LedgerUsers.default, status: ledgerUser.LedgerUsers.status as UserStatus, statusReason: ledgerUser.LedgerUsers.statusReason, role: toRole(ledgerUser.LedgerUsers.role), @@ -498,7 +531,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rCheck.Err()); } const now = new Date().toISOString(); - if (req.default) { + if (req.defaultLedger) { await db .update(sqlLedgerUsers) .set({ @@ -517,7 +550,7 @@ export class FPApiSQL implements FPApiInterface { name: req.userName, role: req.role, right: req.right, - default: req.default ? 1 : 0, + default: req.defaultLedger ? 1 : 0, createdAt: now, updatedAt: now, }) @@ -531,7 +564,7 @@ export class FPApiSQL implements FPApiInterface { status: ret.status as UserStatus, statusReason: ret.statusReason, userId: req.userId, - default: req.default ?? false, + defaultLedger: req.defaultLedger ?? false, role: toRole(ret.role), right: toReadWrite(ret.right), }); @@ -543,7 +576,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAUR.Err()); } const aur = rAUR.Ok(); - if (!aur.user) { + if (!isActiveWithUser(aur)) { return Result.Err(new UserNotFoundError()); } const tenantUsers = await this.db @@ -766,7 +799,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAuth.Err()); } const auth = rAuth.Ok(); - if (!auth.user) { + if (!isActiveWithUser(auth)) { return Result.Err(new UserNotFoundError()); } return Result.Ok({ @@ -889,7 +922,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAuth.Err()); } const auth = rAuth.Ok(); - if (!auth.user) { + if (!isActiveWithUser(auth)) { return Result.Err(new UserNotFoundError()); } const rRows = await queryUser(this.db, req.query); @@ -1039,7 +1072,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAuth.Err()); } const auth = rAuth.Ok(); - if (!auth.user) { + if (!isActiveWithUser(auth)) { return Result.Err(new UserNotFoundError()); } const findUser = await queryUser(this.db, req.ticket.query); @@ -1166,7 +1199,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAuth.Err()); } const auth = rAuth.Ok(); - if (!auth.user) { + if (!isActiveWithUser(auth)) { return Result.Err(new UserNotFoundError()); } let tenantCond = and(eq(sqlTenantUsers.userId, auth.user.userId), eq(sqlTenantUsers.status, "active")); @@ -1316,7 +1349,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAuth.Err()); } const auth = rAuth.Ok(); - if (!auth.user) { + if (!isActiveWithUser(auth)) { return Result.Err(new UserNotFoundError()); } await this._deleteInvite(req.inviteId); @@ -1337,7 +1370,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAuth.Err()); } const auth = rAuth.Ok(); - if (!auth.user) { + if (!isActiveWithUser(auth)) { return Result.Err(new UserNotFoundError()); } const userId = req.userId ?? auth.user.userId; @@ -1403,10 +1436,11 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAuth.Err()); } const auth = rAuth.Ok(); - if (!auth.user) { + if (!isActiveWithUser(auth)) { return Result.Err(new UserNotFoundError()); } const rTenant = await this.insertTenant(auth as ActiveUserWithUserId, { + ...this.params, ...req.tenant, ownerUserId: auth.user.userId, }); @@ -1419,15 +1453,19 @@ export class FPApiSQL implements FPApiInterface { tenantId: tenant.tenantId, userId: auth.user.userId, role: "admin", - default: false, + defaultTenant: req.tenant.defaultTenant ?? false, }); + // console.log(`Created tenant ${tenant.tenantId} for user ${auth.user.userId}: ${req.tenant.defaultTenant}`); return Result.Ok({ type: "resCreateTenant", tenant, }); } - private async insertTenant(auth: ActiveUserWithUserId, req: InCreateTenantParams): Promise> { + private async insertTenant( + auth: ActiveUserWithUserId, + req: InCreateTenantParams & FPApiParameters, + ): Promise> { const tenantId = this.sthis.nextId(12).str; const cnt = await this.db.$count(sqlTenants, eq(sqlTenants.ownerUserId, auth.user.userId)); if (cnt + 1 >= auth.user.maxTenants) { @@ -1440,9 +1478,10 @@ export class FPApiSQL implements FPApiInterface { tenantId, name: req.name ?? `my-tenant[${tenantId}]`, ownerUserId: auth.user.userId, - maxAdminUsers: req.maxAdminUsers ?? 5, - maxMemberUsers: req.maxMemberUsers ?? 5, - maxInvites: req.maxInvites ?? 10, + maxAdminUsers: req.maxAdminUsers, + maxMemberUsers: req.maxMemberUsers, + maxInvites: req.maxInvites, + maxLedgers: req.maxLedgers, createdAt: nowStr, updatedAt: nowStr, }) @@ -1456,7 +1495,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAuth.Err()); } const auth = rAuth.Ok(); - if (!auth.user) { + if (!isActiveWithUser(auth)) { return Result.Err(new UserNotFoundError()); } const prev = await this.db.select().from(sqlTenants).where(eq(sqlTenants.tenantId, req.tenant.tenantId)).get(); @@ -1490,7 +1529,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAuth.Err()); } const auth = rAuth.Ok(); - if (!auth.user) { + if (!isActiveWithUser(auth)) { return Result.Err(new UserNotFoundError()); } // check if owner or admin of tenant @@ -1542,7 +1581,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAuth.Err()); } const auth = rAuth.Ok(); - if (!auth.user) { + if (!isActiveWithUser(auth)) { return Result.Err(new UserNotFoundError()); } // check if owner or admin of tenant @@ -1602,7 +1641,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAuth.Err()); } const auth = rAuth.Ok(); - if (!auth.user) { + if (!isActiveWithUser(auth)) { return Result.Err(new UserNotFoundError()); } const now = new Date().toISOString(); @@ -1694,7 +1733,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAuth.Err()); } const auth = rAuth.Ok(); - if (!auth.user) { + if (!isActiveWithUser(auth)) { return Result.Err(new UserNotFoundError()); } // const now = new Date().toISOString(); @@ -1715,7 +1754,7 @@ export class FPApiSQL implements FPApiInterface { return Result.Err(rAuth.Err()); } const auth = rAuth.Ok(); - if (!auth.user) { + if (!isActiveWithUser(auth)) { return Result.Err(new UserNotFoundError()); } // const now = new Date().toISOString(); @@ -1736,142 +1775,11 @@ export class FPApiSQL implements FPApiInterface { }); } - async getCloudSessionToken(req: ReqCloudSessionToken, ictx: Partial = {}): Promise> { - const resListTenants = await this.listTenantsByUser({ - type: "reqListTenantsByUser", - auth: req.auth, - }); - if (resListTenants.isErr()) { - return Result.Err(resListTenants.Err()); - } - - const resListLedgers = await this.listLedgersByUser({ - type: "reqListLedgersByUser", - auth: req.auth, - }); - - if (resListLedgers.isErr()) { - return Result.Err(resListLedgers.Err()); - } - const rCtx = await getFPTokenContext(this.sthis, ictx); - if (rCtx.isErr()) { - return Result.Err(rCtx.Err()); - } - const ctx = rCtx.Ok(); - const rAuth = await this.activeUser(req); - if (rAuth.isErr()) { - return Result.Err(rAuth.Err()); - } - const auth = rAuth.Ok(); - if (!auth.user) { - return Result.Err(new UserNotFoundError()); - } - - // verify if tenant and ledger are valid - const selected = { - tenant: resListTenants.Ok().tenants[0]?.tenantId, - ledger: resListLedgers.Ok().ledgers[0]?.ledgerId, - }; - if ( - req.selected?.tenant && - resListTenants - .Ok() - .tenants.map((i) => i.tenantId) - .includes(req.selected?.tenant) - ) { - selected.tenant = req.selected?.tenant; - } - if ( - req.selected?.ledger && - resListLedgers - .Ok() - .ledgers.map((i) => i.ledgerId) - .includes(req.selected?.ledger) - ) { - selected.ledger = req.selected?.ledger; - } - const token = await createFPToken(ctx, { - userId: auth.user.userId, - tenants: resListTenants.Ok().tenants.map((i) => ({ - id: i.tenantId, - role: i.role, - })), - ledgers: resListLedgers - .Ok() - .ledgers.map((i) => { - const rights = i.users.find((u) => u.userId === auth.user?.userId); - if (!rights) { - return undefined; - } - return { - id: i.ledgerId, - role: rights.role, - right: rights.right, - }; - }) - .filter((i) => i) as FPCloudClaim["ledgers"], - email: auth.verifiedAuth.params.email, - nickname: auth.verifiedAuth.params.nick, - provider: toProvider(auth.verifiedAuth), - created: auth.user.createdAt, - selected: { - tenant: req.selected?.tenant ?? resListTenants.Ok().tenants[0]?.tenantId, - ledger: req.selected?.ledger ?? resListLedgers.Ok().ledgers[0]?.ledgerId, - }, - } satisfies FPCloudClaim); - - // console.log("getCloudSessionToken", { - // result: req.resultId, - // }); - if (req.resultId && req.resultId.length > "laenger".length) { - await this.addTokenByResultId({ - status: "found", - resultId: req.resultId, - token, - now: new Date(), - }); - // console.log("getCloudSessionToken-ok", { - // result: req.resultId, - // }); - } else if (req.resultId) { - this.sthis.logger.Warn().Any({ resultId: req.resultId }).Msg("resultId too short"); - console.log("getCloudSessionToken-failed", { - result: req.resultId, - }); - } - // console.log(">>>>-post:", ctx, privKey) - return Result.Ok({ - type: "resCloudSessionToken", - token, - }); - } - - async addTokenByResultId(req: TokenByResultIdParam): Promise> { - const now = (req.now ?? new Date()).toISOString(); - await this.db - .insert(sqlTokenByResultId) - .values({ - resultId: req.resultId, - status: req.status, - token: req.token, - createdAt: now, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: [sqlTokenByResultId.resultId], - set: { - updatedAt: now, - resultId: req.resultId, - token: req.token, - status: req.status, - }, - }) - .run(); - const past = new Date(new Date(now).getTime() - 15 * 60 * 1000).toISOString(); - await this.db.delete(sqlTokenByResultId).where(lt(sqlTokenByResultId.updatedAt, past)).run(); + async getClerkPublishableKey(_req: ReqClerkPublishableKey): Promise> { return Result.Ok({ - type: "resTokenByResultId", - ...req, + type: "resClerkPublishableKey", + publishableKey: this.params.clerkPublishableKey, + cloudPublicKeys: this.params.cloudPublicKeys, }); } @@ -1942,71 +1850,3 @@ export class FPApiSQL implements FPApiInterface { } } } - -function toProvider(i: ClerkVerifyAuth): FPCloudClaim["provider"] { - if (i.params.nick) { - return "github"; - } - return "google"; -} - -// // eslint-disable-next-line @typescript-eslint/no-unused-vars -// async attachUserToTenant(req: ReqAttachUserToTenant): Promise> { -// const maxTenants = await this.db.select({ -// maxTenants: users.maxTenants -// }).from(users).where(eq(users.userId, req.userId)).get() ?? { maxTenants: 5 } - -// const tendantCount = await this.db.$count(tenantUsers, -// and( -// eq(tenants.ownerUserId, req.userId), -// ne(tenantUsers.active, 0) -// )) - -// if (tendantCount >= maxTenants.maxTenants) { -// return Result.Err(`max tenants reached:${maxTenants.maxTenants}`) -// } - -// const now = new Date().toISOString(); -// const values = { -// userId: req.userId, -// tenantId: req.tenantId, -// name: req.name, -// active: 1, -// createdAt: now, -// updatedAt: now -// } -// const rRes = await this.db -// .insert(tenantUsers) -// .values(values) -// .onConflictDoNothing() -// .returning() -// .run() -// const res = rRes.toJSON()[0] -// return Result.Ok({ -// type: 'resAttachUserToTenant', -// name: req.name, -// tenant: { -// tenantId: res. -// name: req.name, -// ownerUserId: req.userId, -// adminUserIds: [], -// memberUserIds: [], -// maxAdminUsers: 5, -// maxMemberUsers: 5, -// createdAt: new Date(), -// updatedAt: new Date() -// }, -// userId: req.userId, -// role: req.role -// }) - -// // throw new Error("Method not implemented."); -// } -// // eslint-disable-next-line @typescript-eslint/no-unused-vars -// async listLedgersByTenant(req: ReqListLedgerByTenant): Promise { -// throw new Error("Method not implemented."); -// } -// // eslint-disable-next-line @typescript-eslint/no-unused-vars -// async attachUserToLedger(req: ReqAttachUserToLedger): Promise { -// throw new Error("Method not implemented."); -// } diff --git a/dashboard/backend/bound-local-dbname.ts b/dashboard/backend/bound-local-dbname.ts new file mode 100644 index 000000000..199ac88e6 --- /dev/null +++ b/dashboard/backend/bound-local-dbname.ts @@ -0,0 +1,15 @@ +import { sqliteTable, text, primaryKey } from "drizzle-orm/sqlite-core"; + +export const sqlBoundLocalDbnames = sqliteTable( + "BoundLocalDbnames", + { + appId: text().notNull(), + localDbName: text().notNull(), + tenantId: text().notNull(), + ledgerId: text().notNull(), + deviceIds: text().notNull(), // JSON stringified array of device IDs + createdAt: text().notNull(), + updatedAt: text().notNull(), + }, + (table) => [primaryKey({ columns: [table.appId, table.localDbName] })], +); diff --git a/dashboard/backend/cf-serve.ts b/dashboard/backend/cf-serve.ts index e1ce7ec8e..95a1897b9 100644 --- a/dashboard/backend/cf-serve.ts +++ b/dashboard/backend/cf-serve.ts @@ -8,7 +8,17 @@ export interface Env { DB: D1Database; // CLERK_SECRET_KEY: string; ASSETS: Fetcher; + + MAX_TENANTS?: number; + MAX_ADMIN_USERS?: number; + MAX_MEMBER_USERS?: number; + MAX_INVITES?: number; + MAX_LEDGERS?: number; + + CLERK_PUBLISHABLE_KEY: string; + CLOUD_SESSION_TOKEN_PUBLIC: string; } + export default { async fetch(request: Request, env: Env) { const uri = URI.from(request.url); @@ -16,9 +26,26 @@ export default { switch (true) { case uri.pathname.startsWith("/api"): // console.log("cf-serve", request.url, env); - ares = createHandler(drizzle(env.DB), env)(request) as unknown as Promise; + ares = createHandler(drizzle(env.DB), env).then((fn) => fn(request) as unknown as Promise); break; + // case uri.pathname.startsWith("/fp-cloud-connector/injected-iframe.html"): { + // const html = await injectedHtml(); + // // console.log("cf-serve request", request.method, uri.toString(), "[",html, "]"); + // return new Response(html, { status: 200, headers: { "Content-Type": "text/html" } }); + // } + // break; + case uri.pathname.startsWith("/@fireproof/cloud-connector-svc"): { + console.log("module request", request.method, uri.toString()); + return env.ASSETS.fetch(uri.build().appendRelative("index.js").asURL(), request as unknown as CFRequest); + } + case uri.pathname.startsWith("/@fireproof/cloud-connector-iframe"): { + console.log("direct request", request.method, uri.toString()); + return env.ASSETS.fetch(request as unknown as CFRequest); + } + // } + // break; + case uri.pathname.startsWith("/.well-known/jwks.json"): ares = resWellKnownJwks(request, env as unknown as Record) as unknown as Promise; break; diff --git a/dashboard/backend/cloud-token.ts b/dashboard/backend/cloud-token.ts new file mode 100644 index 000000000..dce63a31a --- /dev/null +++ b/dashboard/backend/cloud-token.ts @@ -0,0 +1,446 @@ +import { Logger, Result } from "@adviser/cement"; +import { + ClerkVerifyAuth, + ReqCloudDbToken, + ReqCloudSessionToken, + ResCloudDbToken, + ResCloudSessionToken, + ResTokenByResultId, +} from "@fireproof/core-protocols-dashboard"; +import { FPCloudClaim, toRole } from "@fireproof/core-types-protocols-cloud"; +import { lt, eq, and } from "drizzle-orm"; +import { FPTokenContext, getFPTokenContext, createFPToken } from "./create-fp-token.js"; +import { sqlTokenByResultId } from "./token-by-result-id.js"; +import { UserNotFoundError } from "./users.js"; +import { ActiveWithUser, FPApiSQL, isActiveWithUser, TokenByResultIdParam } from "./api.js"; +import { sqlLedgers, sqlLedgerUsers } from "./ledgers.js"; +import { sqlTenantUsers } from "./tenants.js"; +import { ensureLogger } from "@fireproof/core-runtime"; +import { sqlBoundLocalDbnames } from "./bound-local-dbname.js"; + +function toProvider(i: ClerkVerifyAuth): FPCloudClaim["provider"] { + if (i.params.nick) { + return "github"; + } + return "google"; +} + +async function upsertBoundLocalDbname( + api: FPApiSQL, + appId: string, + localDbName: string, + tenantId: string, + ledgerId: string, + deviceId: string, +): Promise> { + const now = new Date().toISOString(); + const results = await api.db + .insert(sqlBoundLocalDbnames) + .values({ + appId, + localDbName, + tenantId, + ledgerId, + deviceIds: JSON.stringify([deviceId]), + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [sqlBoundLocalDbnames.appId, sqlBoundLocalDbnames.localDbName], + set: { + updatedAt: now, + }, + }) + .returning(); + if (results.length !== 1) { + return Result.Err("Failed to upsert bound local dbname"); + } + const result = results[0]; + if (result.deviceIds) { + const deviceIds: string[] = JSON.parse(result.deviceIds ?? "[]"); + const uniqueDeviceIds = Array.from(new Set([...deviceIds, deviceId])).sort(); + if (uniqueDeviceIds.length !== deviceIds.length) { + await api.db + .update(sqlBoundLocalDbnames) + .set({ + deviceIds: JSON.stringify(uniqueDeviceIds), + updatedAt: now, + }) + .where(and(eq(sqlBoundLocalDbnames.appId, appId), eq(sqlBoundLocalDbnames.localDbName, localDbName))) + .run(); + } + } + return Result.Ok(undefined); +} + +async function createBoundToken( + req: { ledgerId: string; tenantId: string }, + api: FPApiSQL, + auth: ActiveWithUser, + logger: Logger, + ctx: FPTokenContext, +): Promise> { + const tandl = await api.db + .select() + .from(sqlLedgers) + .innerJoin(sqlTenantUsers, eq(sqlLedgers.tenantId, sqlTenantUsers.tenantId)) + .innerJoin(sqlLedgerUsers, eq(sqlLedgers.ledgerId, sqlLedgerUsers.ledgerId)) + .where(and(eq(sqlLedgers.ledgerId, req.ledgerId), eq(sqlLedgers.tenantId, req.tenantId))) + .get(); + if (tandl) { + // console.log("createBoundToken", auth); + return Result.Ok({ + type: "resCloudDbToken", + status: "bound", + token: await createFPToken(ctx, { + userId: auth.user.userId, + tenants: [{ id: req.tenantId, role: toRole(tandl.TenantUsers.role) }], + ledgers: [ + { + id: req.ledgerId, + role: toRole(tandl.LedgerUsers.role), + right: tandl.LedgerUsers.right as "read" | "write", + }, + ], + email: auth.verifiedAuth.params.email, + nickname: auth.verifiedAuth.params.nick, + provider: toProvider(auth.verifiedAuth), + created: auth.user.createdAt, + selected: { + tenant: req.tenantId, + ledger: req.ledgerId, + }, + } satisfies FPCloudClaim), + }); + } + return logger + .Error() + .Any({ ...req }) + .Msg("User has no access to tenant or ledger") + .ResultError(); +} + +export async function getCloudDbToken( + api: FPApiSQL, + req: ReqCloudDbToken, + ictx: Partial, +): Promise> { + const logger = ensureLogger(api.sthis, "getCloudDbToken", { + appId: req.appId, + deviceId: req.deviceId, + localDbName: req.localDbName, + }); + const rCtx = await getFPTokenContext(api.sthis, ictx); + if (rCtx.isErr()) { + return Result.Err(rCtx.Err()); + } + const ctx = rCtx.Ok(); + const rAuth = await api.activeUser(req); + if (rAuth.isErr()) { + return Result.Err(rAuth.Err()); + } + const auth = rAuth.Ok(); + if (!isActiveWithUser(auth)) { + return Result.Err(new UserNotFoundError()); + } + if (req.tenantId && req.ledgerId) { + // check if user has access to tenant and ledger + return createBoundToken( + { + ledgerId: req.ledgerId, + tenantId: req.tenantId, + }, + api, + auth, + logger, + ctx, + ); + } + const binding = await api.db + .select() + .from(sqlBoundLocalDbnames) + .where(and(eq(sqlBoundLocalDbnames.appId, req.appId), eq(sqlBoundLocalDbnames.localDbName, req.localDbName))) + .get(); + if (binding) { + return createBoundToken( + { + ledgerId: binding.ledgerId, + tenantId: binding.tenantId, + }, + api, + auth, + logger, + ctx, + ); + } + + let tenantToCreateLedger: string; + // console.log("No binding found for localDbName:", req.localDbName, "appId:", req.appId, "tenantId:", req.tenantId); + if (!req.tenantId) { + const rListLedgers = await api.listLedgersByUser({ + type: "reqListLedgersByUser", + auth: req.auth, + }); + if (rListLedgers.isErr()) { + return Result.Err(rListLedgers.Err()); + } + const ledgersPerTenant = rListLedgers.Ok().ledgers.filter((l) => l.name === req.localDbName); + // console.log("Ledgers with names for user:", auth.user.userId, ledgersPerTenant); + if (ledgersPerTenant.length === 1) { + return createBoundToken( + { + ledgerId: ledgersPerTenant[0].ledgerId, + tenantId: ledgersPerTenant[0].tenantId, + }, + api, + auth, + logger, + ctx, + ); + } + if (ledgersPerTenant.length > 1) { + return Result.Ok({ + type: "resCloudDbToken", + status: "not-bound", + reason: "Multiple ledgers exist for user; with the same name existing", + ledgers: ledgersPerTenant.map((l) => ({ + ledgerId: l.ledgerId, + tenantId: l.tenantId, + name: l.name, + })), + }); + } + const rListTenants = await api.listTenantsByUser({ + type: "reqListTenantsByUser", + auth: req.auth, + }); + if (rListTenants.isErr()) { + return Result.Err(rListTenants); + } + if (rListTenants.Ok().tenants.length === 0) { + return logger.Error().Any({ userId: auth.user.userId }).Msg("User has no tenants").ResultError(); + } + const defaultTenant = rListTenants.Ok().tenants.find((i) => i.default); + if (!defaultTenant) { + return logger.Error().Any({ userId: auth.user.userId }).Msg("User has no default tenant").ResultError(); + } + + // console.log("Using default tenant for user:", auth.user.userId, "->", defaultTenant.tenantId, rListTenants.Ok().tenants); + tenantToCreateLedger = defaultTenant.tenantId; + + // get all ledgers for user + // checkIf localName is as LedgerName + // return unbound token + // getDefault tenant for user + } else { + tenantToCreateLedger = req.tenantId; + const rListLedgers = await api.listLedgersByUser({ + type: "reqListLedgersByUser", + auth: req.auth, + tenantIds: [req.tenantId], + }); + if (rListLedgers.isErr()) { + return Result.Err(rListLedgers.Err()); + } + const ledgersPerTenant = rListLedgers.Ok().ledgers.filter((l) => l.name); + if (ledgersPerTenant.length === 1) { + return createBoundToken( + { + ledgerId: ledgersPerTenant[0].ledgerId, + tenantId: ledgersPerTenant[0].tenantId, + }, + api, + auth, + logger, + ctx, + ); + } + if (ledgersPerTenant.length > 1) { + return Result.Ok({ + type: "resCloudDbToken", + status: "not-bound", + reason: "Multiple ledgers exist for user; with the same name existing", + ledgers: ledgersPerTenant.map((l) => ({ + ledgerId: l.ledgerId, + tenantId: l.tenantId, + name: l.name, + })), + }); + } + } + // create ledger with localDbName in tenant + const rCreateLedger = await api.createLedger({ + type: "reqCreateLedger", + auth: req.auth, + ledger: { + tenantId: tenantToCreateLedger, + name: req.localDbName, + }, + }); + if (rCreateLedger.isErr()) { + return Result.Err(rCreateLedger.Err()); + } + await upsertBoundLocalDbname( + api, + req.appId, + req.localDbName, + tenantToCreateLedger, + rCreateLedger.Ok().ledger.ledgerId, + req.deviceId, + ); + + // create bound localDbName token + return createBoundToken( + { + ledgerId: rCreateLedger.Ok().ledger.ledgerId, + tenantId: tenantToCreateLedger, + }, + api, + auth, + logger, + ctx, + ); +} + +export async function getCloudSessionToken( + api: FPApiSQL, + req: ReqCloudSessionToken, + ictx: Partial, +): Promise> { + const resListTenants = await api.listTenantsByUser({ + type: "reqListTenantsByUser", + auth: req.auth, + }); + if (resListTenants.isErr()) { + return Result.Err(resListTenants.Err()); + } + + const resListLedgers = await api.listLedgersByUser({ + type: "reqListLedgersByUser", + auth: req.auth, + }); + + if (resListLedgers.isErr()) { + return Result.Err(resListLedgers.Err()); + } + const rCtx = await getFPTokenContext(api.sthis, ictx); + if (rCtx.isErr()) { + return Result.Err(rCtx.Err()); + } + const ctx = rCtx.Ok(); + const rAuth = await api.activeUser(req); + if (rAuth.isErr()) { + return Result.Err(rAuth.Err()); + } + const auth = rAuth.Ok(); + if (!isActiveWithUser(auth)) { + return Result.Err(new UserNotFoundError()); + } + + // verify if tenant and ledger are valid + const selected = { + tenant: resListTenants.Ok().tenants[0]?.tenantId, + ledger: resListLedgers.Ok().ledgers[0]?.ledgerId, + }; + if ( + req.selected?.tenant && + resListTenants + .Ok() + .tenants.map((i) => i.tenantId) + .includes(req.selected?.tenant) + ) { + selected.tenant = req.selected?.tenant; + } + if ( + req.selected?.ledger && + resListLedgers + .Ok() + .ledgers.map((i) => i.ledgerId) + .includes(req.selected?.ledger) + ) { + selected.ledger = req.selected?.ledger; + } + const token = await createFPToken(ctx, { + userId: auth.user.userId, + tenants: resListTenants.Ok().tenants.map((i) => ({ + id: i.tenantId, + role: i.role, + })), + ledgers: resListLedgers + .Ok() + .ledgers.map((i) => { + const rights = i.users.find((u) => u.userId === auth.user?.userId); + if (!rights) { + return undefined; + } + return { + id: i.ledgerId, + role: rights.role, + right: rights.right, + }; + }) + .filter((i) => i) as FPCloudClaim["ledgers"], + email: auth.verifiedAuth.params.email, + nickname: auth.verifiedAuth.params.nick, + provider: toProvider(auth.verifiedAuth), + created: auth.user.createdAt, + selected: { + tenant: req.selected?.tenant ?? resListTenants.Ok().tenants[0]?.tenantId, + ledger: req.selected?.ledger ?? resListLedgers.Ok().ledgers[0]?.ledgerId, + }, + } satisfies FPCloudClaim); + + // console.log("getCloudSessionToken", { + // result: req.resultId, + // }); + if (req.resultId && req.resultId.length > "laenger".length) { + await addTokenByResultId(api, { + status: "found", + resultId: req.resultId, + token, + now: new Date(), + }); + // console.log("getCloudSessionToken-ok", { + // result: req.resultId, + // }); + } else if (req.resultId) { + api.sthis.logger.Warn().Any({ resultId: req.resultId }).Msg("resultId too short"); + // console.log("getCloudSessionToken-failed", { + // result: req.resultId, + // }); + } + // console.log(">>>>-post:", ctx, privKey) + return Result.Ok({ + type: "resCloudSessionToken", + token, + }); +} + +async function addTokenByResultId(api: FPApiSQL, req: TokenByResultIdParam): Promise> { + const now = (req.now ?? new Date()).toISOString(); + await api.db + .insert(sqlTokenByResultId) + .values({ + resultId: req.resultId, + status: req.status, + token: req.token, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [sqlTokenByResultId.resultId], + set: { + updatedAt: now, + resultId: req.resultId, + token: req.token, + status: req.status, + }, + }) + .run(); + const past = new Date(new Date(now).getTime() - 15 * 60 * 1000).toISOString(); + await api.db.delete(sqlTokenByResultId).where(lt(sqlTokenByResultId.updatedAt, past)).run(); + return Result.Ok({ + type: "resTokenByResultId", + ...req, + }); +} diff --git a/dashboard/backend/create-fp-token.ts b/dashboard/backend/create-fp-token.ts index 696bb863f..a7abbba5f 100644 --- a/dashboard/backend/create-fp-token.ts +++ b/dashboard/backend/create-fp-token.ts @@ -1,5 +1,5 @@ import { param, Result } from "@adviser/cement"; -import { SuperThis } from "@fireproof/core"; +import { SuperThis } from "@fireproof/core-types-base"; import { sts } from "@fireproof/core-runtime"; import { FPCloudClaim } from "@fireproof/core-types-protocols-cloud"; import { SignJWT } from "jose/jwt/sign"; diff --git a/dashboard/backend/create-handler.ts b/dashboard/backend/create-handler.ts index 124ea3e06..e0de5c4b0 100644 --- a/dashboard/backend/create-handler.ts +++ b/dashboard/backend/create-handler.ts @@ -2,13 +2,14 @@ import { CoercedHeadersInit, HttpHeader, Lazy, LoggerImpl, Result, exception2Result, param } from "@adviser/cement"; import { verifyToken } from "@clerk/backend"; import { verifyJwt } from "@clerk/backend/jwt"; -import { SuperThis, SuperThisOpts } from "@fireproof/core"; +import { SuperThis, SuperThisOpts } from "@fireproof/core-types-base"; import { FPAPIMsg, FPApiSQL, FPApiToken } from "./api.js"; import type { Env } from "./cf-serve.js"; import { VerifiedAuth } from "@fireproof/core-protocols-dashboard"; import { ensureSuperThis, ensureLogger } from "@fireproof/core-runtime"; import { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"; import { ResultSet } from "@libsql/client"; +import { getCloudPubkeyFromEnv } from "./get-cloud-pubkey-from-env.js"; // import { jwtVerify } from "jose/jwt/verify"; // import { JWK } from "jose"; @@ -176,8 +177,25 @@ class ClerkApiToken implements FPApiToken { export type DashSqlite = BaseSQLiteDatabase<"async", ResultSet | D1Result, Record>; +function coerceInt(value: undefined | string | number, def: number): number { + if (!value) { + return def; + } + if (typeof value === "number") { + return value; + } + const n = parseInt(value); + if (isNaN(n)) { + return def; + } + return n; +} + // BaseSQLiteDatabase<'async', ResultSet, TSchema> -export function createHandler(db: T, env: Record | Env) { +export async function createHandler( + db: T, + env: Record | Env, +): Promise<(req: Request) => Promise> { // const stream = new utils.ConsoleWriterStream(); const sthis = ensureSuperThis({ logger: new LoggerImpl(), @@ -191,10 +209,29 @@ export function createHandler(db: T, env: Record); const logger = ensureLogger(sthis, "createHandler"); - const fpApi = new FPApiSQL(sthis, db, { - clerk: new ClerkApiToken(sthis), - // better: new BetterApiToken(sthis), - }); + if (!(env as Env).CLERK_PUBLISHABLE_KEY) { + throw new Error("CLERK_PUBLISHABLE_KEY is required in env"); + } + const rCloudPublicKey = await getCloudPubkeyFromEnv((env as Env).CLOUD_SESSION_TOKEN_PUBLIC, sthis); + if (rCloudPublicKey.isErr()) { + throw rCloudPublicKey.Err(); + } + const fpApi = new FPApiSQL( + sthis, + db, + { + clerk: new ClerkApiToken(sthis), + }, + { + cloudPublicKeys: [rCloudPublicKey.Ok()], + clerkPublishableKey: (env as Env).CLERK_PUBLISHABLE_KEY, + maxTenants: coerceInt(env.MAX_TENANTS, 10), + maxAdminUsers: coerceInt(env.MAX_ADMIN_USERS, 5), + maxMemberUsers: coerceInt(env.MAX_MEMBER_USERS, 5), + maxInvites: coerceInt(env.MAX_INVITES, 10), + maxLedgers: coerceInt(env.MAX_LEDGERS, 5), + }, + ); return async (req: Request): Promise => { const startTime = performance.now(); if (req.method === "OPTIONS") { @@ -265,7 +302,7 @@ export function createHandler(db: T, env: Record(db: T, env: Record { reqs: ReqEnsureUser; ress: ResEnsureUser; }[]; + const logger = ensureLogger(sthis, "dashboard-backend-db-api-test"); beforeAll(async () => { const client = createClient({ url: `file://${process.cwd()}/dist/sqlite.db` }); db = drizzle(client); - fpApi = new FPApiSQL(sthis, db, { clerk: new TestApiToken(sthis) }); + fpApi = new FPApiSQL( + sthis, + db, + { clerk: new TestApiToken(sthis) }, + { + cloudPublicKeys: [], + clerkPublishableKey: "test-clerk-publishable-key", + maxTenants: 10, + maxAdminUsers: 5, + maxMemberUsers: 5, + maxInvites: 10, + maxLedgers: 5, + }, + ); data.push( ...Array(10) @@ -1026,6 +1044,359 @@ describe("db-api", () => { expect(verifyExtended.payload.iss).toBe("TEST_I"); expect(verifyExtended.payload.aud).toBe("TEST_A"); }); + + // describe("getCloudDbToken", () => { + const ctx = { + secretToken: + "z33KxHvFS3jLz72v9DeyGBqo7H34SCC1RA5LvQFCyDiU4r4YBR4jEZxZwA9TqBgm6VB5QzwjrZJoVYkpmHgH7kKJ6Sasat3jTDaBCkqWWfJAVrBL7XapUstnKW3AEaJJKvAYWrKYF9JGqrHNU8WVjsj3MZNyqqk8iAtTPPoKtPTLo2c657daVMkxibmvtz2egnK5wPeYEUtkbydrtBzteN25U7zmGqhS4BUzLjDiYKMLP8Tayi", + publicToken: + "zeWndr5LEoaySgKSo2aZniYqcrEJBPswFRe3bwyxY7Nmr3bznXkHhFm77VxHprvCskpKVHEwVzgQpM6SAYkUZpZcEdEunwKmLUYd1yJ4SSteExyZw4GC1SvJPLDpGxKBKb6jkkCsaQ3MJ5YFMKuGUkqpKH31Dw7cFfjdQr5XUiXue", + issuer: "TEST_I", + audience: "TEST_A", + validFor: 3600000, // 1 hour + }; + // eslint-disable-next-line no-restricted-globals + const publicKey = JSON.parse(new TextDecoder().decode(base58btc.decode(ctx.publicToken))) as JWKPublic; + + it("with ledger and tenant ", async () => { + const tenant = await fpApi.createTenant({ + type: "reqCreateTenant", + auth: data[0].reqs.auth, + tenant: { + // ownerUserId: data[0].ress.user.userId, + }, + }); + const ledger = await fpApi.createLedger({ + type: "reqCreateLedger", + auth: data[0].reqs.auth, + ledger: { + tenantId: tenant.Ok().tenant.tenantId, + name: `DB Token Ledger`, + }, + }); + const rRes = await fpApi.getCloudDbToken( + { + type: "reqCloudDbToken", + auth: data[0].reqs.auth, + tenantId: tenant.Ok().tenant.tenantId, + ledgerId: ledger.Ok().ledger.ledgerId, + localDbName: "not-existing-db", + appId: "not-existing-app", + deviceId: "not-existing-device", + }, + ctx, + ); + const res = rRes.Ok(); + if (!isResCloudDbTokenBound(res)) { + assert.fail("Expected not bound response"); + return; + } + const rTandC = await convertToTokenAndClaims( + { + getClerkPublishableKey(): Promise { + return Promise.resolve({ + type: "resClerkPublishableKey", + publishableKey: "undefined", + cloudPublicKeys: [publicKey], + }); + }, + }, + logger, + res.token, + ); + // console.log(rTandC); + expect(rTandC.isOk()).toBeTruthy(); + const tandC = rTandC.Ok(); + expect(tandC.claims.selected.tenant).toBe(tenant.Ok().tenant.tenantId); + expect(tandC.claims.selected.ledger).toBe(ledger.Ok().ledger.ledgerId); + // expect(tandC.claims.ledgers).toEqual([ + // { + // id: ledger.Ok().ledger.ledgerId, + // right: "write", + // role: "admin", + // }, + // ]); + }); + it("with non existing ledger and tenant", async () => { + const rRes = await fpApi.getCloudDbToken( + { + type: "reqCloudDbToken", + auth: data[0].reqs.auth, + ledgerId: "non-existing-ledger", + tenantId: "non-existing-tenant", + localDbName: `no-existing-local-db-${sthis.nextId(6).str}`, + appId: "not-existing-app", + deviceId: "not-existing-device", + }, + ctx, + ); + const res = rRes.Err(); + expect(JSON.parse(res.message).msg).toEqual("User has no access to tenant or ledger"); + }); + it("without ledger and tenant but appId and localDbName not existing no ambiguity", async () => { + const tenant = await fpApi.createTenant({ + type: "reqCreateTenant", + auth: data[0].reqs.auth, + tenant: { + defaultTenant: true, + // ownerUserId: data[0].ress.user.userId, + }, + }); + const rRes = await fpApi.getCloudDbToken( + { + type: "reqCloudDbToken", + auth: data[0].reqs.auth, + localDbName: `no-existing-local-db-${sthis.nextId(6).str}`, + appId: "not-existing-app", + deviceId: "not-existing-device", + }, + ctx, + ); + const res = rRes.Ok(); + if (!isResCloudDbTokenBound(res)) { + assert.fail("Expected not bound response"); + return; + } + const rTandC = await convertToTokenAndClaims( + { + getClerkPublishableKey(): Promise { + return Promise.resolve({ + type: "resClerkPublishableKey", + publishableKey: "undefined", + cloudPublicKeys: [publicKey], + }); + }, + }, + logger, + res.token, + ); + const tandC = rTandC.Ok(); + // console.log(data.map((i) => i.ress.tenants).map((j) => j.map((k) => k.tenantId))); + expect(tandC.claims.selected.tenant).toBe(tenant.Ok().tenant.tenantId); + expect(tandC.claims.selected.ledger).toBeDefined(); + }); + it("without ledger and tenant but appId and localDbName existing", async () => { + const tenant = await fpApi.createTenant({ + type: "reqCreateTenant", + auth: data[0].reqs.auth, + tenant: { + defaultTenant: true, + // ownerUserId: data[0].ress.user.userId, + }, + }); + const ledger = await fpApi.createLedger({ + type: "reqCreateLedger", + auth: data[0].reqs.auth, + ledger: { + tenantId: tenant.Ok().tenant.tenantId, + name: `DB Token Ledger-${sthis.nextId(6).str}`, + }, + }); + const rRes = await fpApi.getCloudDbToken( + { + type: "reqCloudDbToken", + auth: data[0].reqs.auth, + localDbName: ledger.Ok().ledger.name, + appId: "not-existing-app", + deviceId: "not-existing-device", + }, + ctx, + ); + const res = rRes.Ok(); + if (!isResCloudDbTokenBound(res)) { + assert.fail("Expected not bound response"); + return; + } + const rTandC = await convertToTokenAndClaims( + { + getClerkPublishableKey(): Promise { + return Promise.resolve({ + type: "resClerkPublishableKey", + publishableKey: "undefined", + cloudPublicKeys: [publicKey], + }); + }, + }, + logger, + res.token, + ); + const tandC = rTandC.Ok(); + // console.log(data.map((i) => i.ress.tenants).map((j) => j.map((k) => k.tenantId))); + expect(tandC.claims.selected.tenant).toBe(tenant.Ok().tenant.tenantId); + expect(tandC.claims.selected.ledger).toBe(ledger.Ok().ledger.ledgerId); + }); + // it("without ledger and tenant but appId and localDbName and ambiguity", async () => { + // const tenant = await fpApi.createTenant({ + // type: "reqCreateTenant", + // auth: data[0].reqs.auth, + // tenant: { + // defaultTenant: true, + // // ownerUserId: data[0].ress.user.userId, + // }, + // }); + // const name = `DB Token Ledger-${sthis.nextId(6).str}`; + // await fpApi.createLedger({ + // type: "reqCreateLedger", + // auth: data[0].reqs.auth, + // ledger: { + // tenantId: tenant.Ok().tenant.tenantId, + // name, + // }, + // }); + // await fpApi.createLedger({ + // type: "reqCreateLedger", + // auth: data[0].reqs.auth, + // ledger: { + // tenantId: tenant.Ok().tenant.tenantId, + // name, + // }, + // }); + + // const rRes = await fpApi.getCloudDbToken( + // { + // type: "reqCloudDbToken", + // auth: data[0].reqs.auth, + // tenantId: tenant.Ok().tenant.tenantId, + // localDbName: name, + // appId: "not-existing-app", + // deviceId: "not-existing-device", + // }, + // ctx, + // ); + // const res = rRes.Ok(); + // if (!isResCloudDbTokenBound(res)) { + // assert.fail("Expected not bound response"); + // return; + // } + // const rTandC = await convertToTokenAndClaims( + // { + // getClerkPublishableKey(): Promise { + // return Promise.resolve({ + // type: "resClerkPublishableKey", + // publishableKey: "undefined", + // cloudPublicKeys: [publicKey], + // }); + // }, + // }, + // logger, + // res.token, + // ); + // const tandC = rTandC.Ok(); + // // console.log(data.map((i) => i.ress.tenants).map((j) => j.map((k) => k.tenantId))); + // expect(tandC.claims.selected.tenant).toBe(tenant.Ok().tenant.tenantId); + // expect(tandC.claims.selected.ledger).toBe("xxx"); //ledger.Ok().ledger.ledgerId); + // }); + it("without ledger but tenant appId and localDbName and no ambiguity", async () => { + const ledgerName = `DB Token Ledger-${sthis.nextId(6).str}`; + + const otherTenant = await fpApi.createTenant({ + type: "reqCreateTenant", + auth: data[0].reqs.auth, + tenant: {}, + }); + await fpApi.createLedger({ + type: "reqCreateLedger", + auth: data[0].reqs.auth, + ledger: { + tenantId: otherTenant.Ok().tenant.tenantId, + name: ledgerName, + }, + }); + const tenant = await fpApi.createTenant({ + type: "reqCreateTenant", + auth: data[0].reqs.auth, + tenant: { + defaultTenant: true, + // ownerUserId: data[0].ress.user.userId, + }, + }); + const ledger = await fpApi.createLedger({ + type: "reqCreateLedger", + auth: data[0].reqs.auth, + ledger: { + tenantId: tenant.Ok().tenant.tenantId, + name: ledgerName, + }, + }); + const rRes = await fpApi.getCloudDbToken( + { + type: "reqCloudDbToken", + auth: data[0].reqs.auth, + tenantId: tenant.Ok().tenant.tenantId, + localDbName: ledgerName, + appId: "not-existing-app", + deviceId: "not-existing-device", + }, + ctx, + ); + const res = rRes.Ok(); + if (!isResCloudDbTokenBound(res)) { + assert.fail("Expected not bound response"); + return; + } + const rTandC = await convertToTokenAndClaims( + { + getClerkPublishableKey(): Promise { + return Promise.resolve({ + type: "resClerkPublishableKey", + publishableKey: "undefined", + cloudPublicKeys: [publicKey], + }); + }, + }, + logger, + res.token, + ); + const tandC = rTandC.Ok(); + // console.log(data.map((i) => i.ress.tenants).map((j) => j.map((k) => k.tenantId))); + expect(tandC.claims.selected.tenant).toBe(tenant.Ok().tenant.tenantId); + expect(tandC.claims.selected.ledger).toBe(ledger.Ok().ledger.ledgerId); //ledger.Ok().ledger.ledgerId); + }); + + it("without ledger but tenant appId and localDbName implicit create", async () => { + const ledgerName = `DB Token Ledger-${sthis.nextId(6).str}`; + const tenant = await fpApi.createTenant({ + type: "reqCreateTenant", + auth: data[0].reqs.auth, + tenant: { + defaultTenant: true, + // ownerUserId: data[0].ress.user.userId, + }, + }); + const rRes = await fpApi.getCloudDbToken( + { + type: "reqCloudDbToken", + auth: data[0].reqs.auth, + tenantId: tenant.Ok().tenant.tenantId, + localDbName: ledgerName, + appId: "not-existing-app", + deviceId: "not-existing-device", + }, + ctx, + ); + const res = rRes.Ok(); + if (!isResCloudDbTokenBound(res)) { + assert.fail("Expected not bound response"); + return; + } + const rTandC = await convertToTokenAndClaims( + { + getClerkPublishableKey(): Promise { + return Promise.resolve({ + type: "resClerkPublishableKey", + publishableKey: "undefined", + cloudPublicKeys: [publicKey], + }); + }, + }, + logger, + res.token, + ); + const tandC = rTandC.Ok(); + // console.log(data.map((i) => i.ress.tenants).map((j) => j.map((k) => k.tenantId))); + expect(tandC.claims.selected.tenant).toBe(tenant.Ok().tenant.tenantId); + expect(tandC.claims.selected.ledger).toBeDefined(); + }); }); it("queryEmail strips +....@", async () => { diff --git a/dashboard/backend/deno-serve.ts b/dashboard/backend/deno-serve.ts index 5790fde2c..efbd41dee 100644 --- a/dashboard/backend/deno-serve.ts +++ b/dashboard/backend/deno-serve.ts @@ -12,7 +12,7 @@ function getClient() { async function main() { Deno.serve({ port: 7370, - handler: createHandler(getClient(), Deno.env.toObject()), + handler: await createHandler(getClient(), Deno.env.toObject()), }); } diff --git a/dashboard/backend/device-id-svc.ts.off b/dashboard/backend/device-id-svc.ts.off new file mode 100644 index 000000000..9daa7ec74 --- /dev/null +++ b/dashboard/backend/device-id-svc.ts.off @@ -0,0 +1,160 @@ +import { SuperThis } from "@fireproof/core-types-base"; +import { DeviceIdProtocol, DeviceIdProtocolSrv, DeviceIdProtocolSrvOpts, GenerateSerialNumber } from "@fireproof/core-device-id"; +import { Result } from "@adviser/cement"; +import { calculateJwkThumbprint } from "jose"; +import { ClerkVerifyAuth, ReqEnsureDeviceId, ResEnsureDeviceId } from "@fireproof/core-protocols-dashboard"; +import { ActiveUser } from "./api.js"; +import { DashSqlite } from "./create-handler.js"; +import { sqlDevIdsPerUser } from "./device-ids.js"; +import { and, eq, gt, gte } from "drizzle-orm/sql/expressions"; + +export interface DeviceIdSvc extends Omit { + readonly db: DashSqlite; + readonly reAuthTime?: number; // seconds typically 30 days + readonly maxDevicesPerUser?: number; // typically 10 + readonly revalidateTime?: number; // seconds, typically 3 days +} + +export class DeviceIdService { + static async create(sthis: SuperThis, opts: DeviceIdSvc): Promise> { + const svc = new DeviceIdService(sthis, { + db: opts.db, + reAuthTime: opts.reAuthTime || (365 / 2) * 24 * 60 * 60, + maxDevicesPerUser: opts.maxDevicesPerUser || 10, + revalidateTime: opts.revalidateTime || ~~((opts.reAuthTime || (365 / 2) * 24 * 60 * 60) / 100), + }); + const res = await DeviceIdProtocolSrv.create(sthis, { + ...opts, + // actions: { + // generateSerialNumber: svc.generateSerialNumber.bind(svc), + // }, + }); + if (res.isErr()) { + return Result.Err(res); + } + return Result.Ok(svc); + } + + readonly sthis: SuperThis; + deviceIdProtocol!: DeviceIdProtocol; + readonly reAuthTime: number; + readonly revalidateTime: number; // window + readonly maxDevicesPerUser: number; + + constructor( + sthis: SuperThis, + readonly db: DashSqlite, + opts: DeviceIdSvc, + ) { + this.sthis = sthis; + this.db = db; + this.reAuthTime = opts.reAuthTime || (365 / 2) * 24 * 60 * 60; + this.maxDevicesPerUser = opts.maxDevicesPerUser || 10; + this.revalidateTime = opts.revalidateTime || ~~(this.reAuthTime / 100); + } + + isValidToAddDevice(userId: string): Promise> { + if (getResult.status !== "active") { + return Result.Err(`Device ID not active: ${getResult.status}:${getResult.deviceId}:${getResult.userId}`); + } + const allDevs = await this.db + .select() + .from(sqlDevIdsPerUser) + .where( + and( + eq(sqlDevIdsPerUser.userId, auth.user.userId), + gte(sqlDevIdsPerUser.updatedAt, new Date(Date.now() - this.reAuthTime * 1000).toISOString()), + ), + ) + .all(); + if (!allDevs || allDevs.length === 0) { + return Result.Err("No active device IDs found for user"); + } + const active = allDevs.filter((d) => d.status === "active"); + if (active.length !== allDevs.length) { + return Result.Err(`UserID is not active: ${auth.user.userId}`); + } + if (allDevs.length >= this.maxDevicesPerUser) { + return Result.Err(`Too many device IDs for user: ${allDevs.length} > ${this.maxDevicesPerUser}`); + } + } + + async ensureDeviceId(auth: ActiveUser, req: ReqEnsureDeviceId): Promise> { + const res = await this.deviceIdProtocol.issueCertificate(req.csrJWT, { + generateSerialNumber: async (pub, opts) => { + if (!auth.user) { + return Result.Err("User not authenticated"); + } + const hash = await calculateJwkThumbprint(pub); + const getResult = await this.db + .select() + .from(sqlDevIdsPerUser) + .where( + and( + eq(sqlDevIdsPerUser.userId, auth.user.userId), + eq(sqlDevIdsPerUser.deviceId, hash), + gt(sqlDevIdsPerUser.updatedAt, new Date(Date.now() - this.reAuthTime * 1000).toISOString()), + ), + ) + .get(); + if (!getResult) { + const isAddable = await this.isValidToAddDevice(auth.user.userId); + if (isAddable.isErr()) { + return Result.Err(isAddable.Err()); + } + const now = new Date().toISOString(); + const val = await this.db + .insert(sqlDevIdsPerUser) + .values({ + deviceId: hash, + userId: auth.user.userId, + status: "active", + serial: 1, + createdAt: now, + updatedAt: now, + }) + .returning() + .get(); + return Result.Ok({ + serialNr: val.serial, + issuedAt: new Date(val.updatedAt), + expiryAt: new Date(val.updatedAt + opts.validityPeriod * 1000), // default 1 year + } satisfies GenerateSerialNumber); + } + if (getResult.status !== "active") { + return Result.Err(`Device ID not active: ${getResult.status}:${getResult.deviceId}:${getResult.userId}`); + } + if (new Date(getResult.updatedAt) < new Date(Date.now() - opts.validityPeriod * 1000)) { + return Result.Ok({ + reauth: true, + }); + } + if (new Date(getResult.updatedAt) < new Date(Date.now() + (opts.validityPeriod - opts.revalidateTime) * 1000)) { + const now = new Date(); + await this.db + .update(sqlDevIdsPerUser) + .set({ + updatedAt: now.toISOString(), + serial: getResult.serial + 1, + }) + .where(and(eq(sqlDevIdsPerUser.deviceId, getResult.deviceId), eq(sqlDevIdsPerUser.userId, getResult.userId))) + .run(); + return Result.Ok({ + ...getResult, + serialNr: getResult.serial + 1, + status: "reActivated", + issuedAt: now, + } satisfies GenerateSerialNumber); + } + }, + }); + if (res.isErr()) { + return Result.Err(res); + } + + // .update(sqlInviteTickets) + // .set({ status: "expired" }) + // .where(and(eq(sqlInviteTickets.status, "pending"), lt(sqlInviteTickets.expiresAfter, new Date().toISOString()))) + // .run(); + } +} diff --git a/dashboard/backend/get-cloud-pubkey-from-env.ts b/dashboard/backend/get-cloud-pubkey-from-env.ts new file mode 100644 index 000000000..487a2bf9f --- /dev/null +++ b/dashboard/backend/get-cloud-pubkey-from-env.ts @@ -0,0 +1,29 @@ +import { Result, toCryptoRuntime } from "@adviser/cement"; +import { ensureSuperThis, sts } from "@fireproof/core-runtime"; +import { JWKPublic, JWKPublicSchema, SuperThis, toJwksAlg } from "@fireproof/core-types-base"; +import { isArrayBuffer, isUint8Array } from "util/types"; + +export async function getCloudPubkeyFromEnv(cloudToken?: string, sthis: SuperThis = ensureSuperThis()): Promise> { + const cstPub = cloudToken ?? sthis.env.get("CLOUD_SESSION_TOKEN_PUBLIC"); + if (!cstPub) { + return Result.Err("no public key: env:CLOUD_SESSION_TOKEN_PUBLIC"); + } + const key = await sts.env2jwk(cstPub, "ES256", sthis); + const jwKey = await toCryptoRuntime().exportKey("jwk", key); + if (isUint8Array(jwKey) || isArrayBuffer(jwKey)) { + return Result.Err("invalid key: jwk is ArrayBuffer or Uint8Array"); + } + const rJwtPublicKey = JWKPublicSchema.safeParse({ + use: "sig", + // kty: ktyFromAlg(key.algorithm.name), + ...jwKey, + alg: toJwksAlg(key.algorithm.name, jwKey), + ext: undefined, + key_ops: undefined, + kid: undefined, + }); + if (!rJwtPublicKey.success) { + return Result.Err(rJwtPublicKey.error); + } + return Result.Ok(rJwtPublicKey.data); +} diff --git a/dashboard/backend/invites.ts b/dashboard/backend/invites.ts index d7e909910..a2ebdf108 100644 --- a/dashboard/backend/invites.ts +++ b/dashboard/backend/invites.ts @@ -4,7 +4,7 @@ import { sqlUsers } from "./users.js"; import { sqlTenants } from "./tenants.js"; import { sqlLedgers } from "./ledgers.js"; import { queryEmail, queryNick, toUndef } from "./sql-helper.js"; -import { SuperThis } from "@fireproof/core"; +import { SuperThis } from "@fireproof/core-types-base"; import { AuthProvider, InvitedParams, diff --git a/dashboard/backend/well-known-jwks.ts b/dashboard/backend/well-known-jwks.ts index ec7b8f7cc..3a8d5269d 100644 --- a/dashboard/backend/well-known-jwks.ts +++ b/dashboard/backend/well-known-jwks.ts @@ -1,37 +1,17 @@ -import { Lazy, toCryptoRuntime } from "@adviser/cement"; -import { ensureSuperThis, sts } from "@fireproof/core-runtime"; -import { JWKPublicSchema, toJwksAlg } from "@fireproof/core-types-base"; -import { isArrayBuffer, isUint8Array } from "util/types"; +import { Lazy } from "@adviser/cement"; +import { getCloudPubkeyFromEnv } from "./get-cloud-pubkey-from-env.js"; const getKey = Lazy(async (opts: Record) => { - const sthis = ensureSuperThis(); - const cstPub = opts.CLOUD_SESSION_TOKEN_PUBLIC ?? sthis.env.get("CLOUD_SESSION_TOKEN_PUBLIC"); - if (!cstPub) + const rJwtPublicKey = await getCloudPubkeyFromEnv(opts.CLOUD_SESSION_TOKEN_PUBLIC); + if (rJwtPublicKey.isErr()) { return { status: 500, - value: { error: "no public key: env:CLOUD_SESSION_TOKEN_PUBLIC" }, - }; - - const key = await sts.env2jwk(cstPub, "ES256", sthis); - const jwKey = await toCryptoRuntime().exportKey("jwk", key); - if (isUint8Array(jwKey) || isArrayBuffer(jwKey)) { - return { - status: 500, - value: { error: "invalid key is not a CTJsonWebKey" }, + value: { keys: [] }, }; } - const jwPublicKey = JWKPublicSchema.parse({ - use: "sig", - // kty: ktyFromAlg(key.algorithm.name), - ...jwKey, - alg: toJwksAlg(key.algorithm.name, jwKey), - ext: undefined, - key_ops: undefined, - kid: undefined, - }); return { status: 200, - value: { keys: [jwPublicKey] }, + value: { keys: [rJwtPublicKey.Ok()] }, }; }); diff --git a/dashboard/fp-cloud-connector-test.html b/dashboard/fp-cloud-connector-test.html new file mode 100644 index 000000000..e70bd3e38 --- /dev/null +++ b/dashboard/fp-cloud-connector-test.html @@ -0,0 +1,26 @@ + + + + + + + Interactive Test Fireproof Cloud Connector + + +

Interactive Test Fireproof Cloud Connector

+ + + + diff --git a/dashboard/package.json b/dashboard/package.json index f438bc5ba..18fc5fdef 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -10,10 +10,10 @@ "deploy:cf": "wrangler deploy -c wrangler.toml", "build": "core-cli tsc && pnpm run build:vite", "build:vite": "vite build", - "lint": "eslint .", "preview": "vite preview", "test": "vitest --run", - "format": "prettier --config ../.prettierrc .", + "format": "prettier .", + "lint": "eslint", "check": "pnpm format --write && core-cli tsc --noEmit && pnpm lint && pnpm test && pnpm build", "drizzle:libsql": "drizzle-kit push --config ./drizzle.libsql.config.ts", "drizzle:d1-local": "drizzle-kit push --config ./drizzle.d1-local-backend.config.ts", @@ -30,6 +30,7 @@ "@clerk/clerk-js": "^5.107.0", "@clerk/clerk-react": "^5.54.0", "@fireproof/core": "workspace:0.0.0", + "@fireproof/core-base": "^0.23.15", "@fireproof/core-protocols-cloud": "workspace:0.0.0", "@fireproof/core-protocols-dashboard": "workspace:0.0.0", "@fireproof/core-runtime": "workspace:0.0.0", @@ -49,8 +50,9 @@ "react-hook-form": "^7.66.0", "react-router-dom": "^7.9.5", "react-simple-code-editor": "^0.14.1", + "ts-essentials": "^10.1.1", "use-editable": "^2.3.3", - "use-fireproof": "workspace:0.0.0", + "use-fireproof": "workspace:*", "zod": "^4.1.12" }, "devDependencies": { @@ -58,7 +60,8 @@ "@cloudflare/vite-plugin": "^1.14.1", "@cloudflare/workers-types": "^4.20251111.0", "@eslint/js": "^9.39.1", - "@fireproof/core-cli": "workspace:0.0.0", + "@fireproof/cloud-connector-iframe": "workspace:0.0.0", + "@fireproof/core-cli": "workspace:*", "@libsql/client": "^0.15.15", "@libsql/kysely-libsql": "^0.4.1", "@rollup/plugin-replace": "^6.0.3", diff --git a/dashboard/src/app-context.tsx b/dashboard/src/app-context.tsx index 98a62a7e0..5def32668 100644 --- a/dashboard/src/app-context.tsx +++ b/dashboard/src/app-context.tsx @@ -1,6 +1,6 @@ import React, { createContext, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; -import type { SuperThis } from "@fireproof/core"; +import type { SuperThis } from "@fireproof/core-types-base"; import { ensureSuperThis } from "@fireproof/core-runtime"; import { CloudContext } from "./cloud-context.js"; diff --git a/dashboard/src/components/App.tsx b/dashboard/src/components/App.tsx index e9e114665..827df3ea8 100644 --- a/dashboard/src/components/App.tsx +++ b/dashboard/src/components/App.tsx @@ -41,6 +41,10 @@ import { Index, indexLoader } from "../pages/index.jsx"; import { Login, loginLoader } from "../pages/login.jsx"; import { SignUpPage, signupLoader } from "../pages/signup.jsx"; +setInterval(() => { + localStorage.setItem("app", `${new Date().toISOString()}:${window.location.href}`); +}, 10000); + export function App() { const ctx = useContext(AppContext); // console.log(">>>>>>>>>>>>>>", window.location.href); diff --git a/dashboard/src/components/DynamicTable.tsx b/dashboard/src/components/DynamicTable.tsx index c6a4a8270..3a37cdcb0 100644 --- a/dashboard/src/components/DynamicTable.tsx +++ b/dashboard/src/components/DynamicTable.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useNavigate } from "react-router-dom"; -import { DocBase, DocWithId } from "@fireproof/core"; +import { DocBase, DocWithId } from "@fireproof/core-types-base"; // export interface TableRow extends DocBase { // // readonly _id: string; diff --git a/dashboard/src/pages/cloud.tsx b/dashboard/src/pages/cloud.tsx index f36fc91db..8d6a119dd 100644 --- a/dashboard/src/pages/cloud.tsx +++ b/dashboard/src/pages/cloud.tsx @@ -1,6 +1,4 @@ import React, { useContext } from "react"; -// import { Clerk } from "@clerk/clerk-js"; -// import { Clerk } from "@clerk/clerk-react"; import { Navigate, NavLink, useLocation, useParams } from "react-router-dom"; import { AppContext } from "../app-context.jsx"; import { Plus } from "../components/Plus.jsx"; diff --git a/dashboard/src/pages/databases.tsx b/dashboard/src/pages/databases.tsx index aede09994..36cafe3c3 100644 --- a/dashboard/src/pages/databases.tsx +++ b/dashboard/src/pages/databases.tsx @@ -2,7 +2,7 @@ import React, { useContext } from "react"; import { NavLink, useLoaderData, useNavigate } from "react-router-dom"; import { AppContext } from "../app-context.jsx"; import { SYNC_DB_NAME, truncateDbName } from "../helpers.js"; -import { fireproof } from "@fireproof/core"; +import { fireproof } from "@fireproof/core-base"; import { WithSidebar } from "../layouts/with-sidebar.jsx"; const reservedDbNames: string[] = [`fp.${SYNC_DB_NAME}`, "fp.petname_mappings", "fp.fp_sync"]; diff --git a/dashboard/src/pages/databases/connect.tsx b/dashboard/src/pages/databases/connect.tsx index 1b09f4950..b24035cbb 100644 --- a/dashboard/src/pages/databases/connect.tsx +++ b/dashboard/src/pages/databases/connect.tsx @@ -1,6 +1,6 @@ import React from "react"; import { redirect } from "react-router-dom"; -import { fireproof } from "@fireproof/core"; +import { fireproof } from "@fireproof/core-base"; import { DEFAULT_ENDPOINT, SYNC_DB_NAME } from "../../helpers.js"; import { URI } from "@adviser/cement"; diff --git a/dashboard/src/pages/databases/new.tsx b/dashboard/src/pages/databases/new.tsx index 66a4fc251..d4164fd5a 100644 --- a/dashboard/src/pages/databases/new.tsx +++ b/dashboard/src/pages/databases/new.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useForm } from "react-hook-form"; import { Form, redirect, SubmitTarget, useSubmit } from "react-router-dom"; -import { fireproof } from "@fireproof/core"; +import { fireproof } from "@fireproof/core-base"; export async function newDatabaseAction({ request }: { request: Request }) { const dbName = (await request.json<{ dbName: string }>()).dbName; diff --git a/dashboard/src/pages/databases/query.tsx b/dashboard/src/pages/databases/query.tsx index 3a9271d6c..c4e4d2e53 100644 --- a/dashboard/src/pages/databases/query.tsx +++ b/dashboard/src/pages/databases/query.tsx @@ -24,7 +24,7 @@ export function DatabasesQuery() { async function runTempQuery() { try { - // Try to evaluate the function to check for errors + // Try to evaluate the function to check for errors eval(`(${editorCode})`); setEditorCodeFnString(editorCode); setUserCodeError(null); diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts index 1968c0b8f..dffe5e2e9 100644 --- a/dashboard/vite.config.ts +++ b/dashboard/vite.config.ts @@ -1,9 +1,82 @@ import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; -// import { visualizer } from "rollup-plugin-visualizer"; +import { defineConfig, Plugin } from "vite"; import { dotenv } from "zx"; import { cloudflare } from "@cloudflare/vite-plugin"; import * as path from "path"; +import * as fs from "fs"; +import * as esbuild from "esbuild"; + +const serveFireproofAssets = (): Plugin => ({ + name: "serve-fireproof-assets", + + // Development server + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + // Serve the HTML file + let url = req.url?.split("?")[0] || ""; + if (url.startsWith("/@fireproof")) { + if (url === "/@fireproof/cloud-connector-svc") { + url += "/index.js"; + } + + const filePath = path.resolve(__dirname, `node_modules/${url}`); + if (url.endsWith(".html")) { + const content = await fs.promises.readFile(filePath, "utf-8"); + res.setHeader("Content-Type", "text/html"); + res.end(content); + return; + } + try { + // Use Vite's built-in transform + const result = await server.transformRequest(filePath); + if (result) { + res.setHeader("Content-Type", "application/javascript"); + res.end(result.code); + return; + } + } catch (e) { + res.statusCode = 500; + res.end(`Error: ${e}`); + return; + } + // console.log("serve-fireproof-assets", req.url, url); + + // const content = fs.readFileSync(htmlPath, "utf-8"); + // res.setHeader("Content-Type", "text/html"); + // res.end(content); + return; + } + next(); + }); + }, + + async generateBundle() { + // Emit HTML file + const htmlPath = path.resolve(__dirname, "node_modules/@fireproof/cloud-connector-iframe/injected-iframe.html"); + this.emitFile({ + type: "asset", + fileName: "@fireproof/cloud-connector-iframe/injected-iframe.html", + source: fs.readFileSync(htmlPath, "utf-8"), + }); + + const result = await esbuild.build({ + entryPoints: [path.resolve(__dirname, "node_modules/@fireproof/cloud-connector-svc/index.ts")], + bundle: true, + format: "esm", + write: false, + minify: false, + platform: "browser", + }); + + const bundledCode = result.outputFiles[0].text; + + this.emitFile({ + type: "asset", + fileName: "@fireproof/cloud-connector-svc/index.js", + source: bundledCode, + }); + }, +}); function defines() { try { @@ -24,11 +97,36 @@ function defines() { // https://vitejs.dev/config/ export default defineConfig({ plugins: [ + serveFireproofAssets(), // multilines // tsconfigPaths(), - react(), + react({ + jsxRuntime: "classic", // Use classic instead of automatic + }), cloudflare(), // visualizer(), + { + name: "serve-fp-cloud-connector", + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + if (req.url?.startsWith("/fp-cloud-connector/")) { + const filePath = path.join(__dirname, req.url.split("?")[0]); + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + const ext = path.extname(filePath); + // Only serve HTML directly, let Vite handle .ts/.js files + if (ext === ".html") { + const content = fs.readFileSync(filePath); + res.setHeader("Content-Type", "text/html"); + res.end(content); + return; + } + // For .ts/.js files, let Vite's normal processing handle them + } + } + next(); + }); + }, + }, ], define: { ...defines(), @@ -40,21 +138,31 @@ export default defineConfig({ emptyOutDir: true, // also necessary manifest: true, }, + // optimizeDeps: { + // include: ['use-fireproof'] + // }, server: { port: 7370, - hmr: false, - proxy: { - "/*": { - rewrite: () => "/index.html", - }, + // hmr: false, + fs: { + allow: [ + // Allow serving files from the project root + "..", + ], }, + allowedHosts: ["localhost", "dev-local-1.adviser.com", "dev-local-2.adviser.com"], + }, + resolve: { + // ...(process.env.USE_SOURCE + // ? { + // alias: { + // "react-router": path.resolve(__dirname, "../../packages/react-router/index.js"), + // "react-router-dom": path.resolve(__dirname, "../../packages/react-router-dom/index.jsx"), + // }, + // } + // : {}, + // ), }, - resolve: process.env.USE_SOURCE - ? { - alias: { - "react-router": path.resolve(__dirname, "../../packages/react-router/index.js"), - "react-router-dom": path.resolve(__dirname, "../../packages/react-router-dom/index.jsx"), - }, - } - : {}, }); + +// console.log(">>>>>>", path.resolve(__dirname, "node_modules/@fireproof/cloud-connector-svc/index.ts")); diff --git a/eslint.config.mjs b/eslint.config.mjs index 4ed00b38a..d27c8cf2e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,7 @@ const opts = tseslint.config( ignores: [ "babel.config.cjs", "jest.config.js", + "playwright-chrome/", "**/dist/", "**/pubdir/", "**/node_modules/", diff --git a/package.json b/package.json index 0925f8fb8..ed0583845 100644 --- a/package.json +++ b/package.json @@ -31,8 +31,8 @@ "test:file": "vitest --config vitest.file.config.ts --run", "test:indexeddb": "vitest --config vitest.indexeddb.config.ts --run", "test:deno": "deno run --quiet --allow-net --allow-write --allow-run --allow-sys --allow-ffi --allow-read --allow-env ./node_modules/vitest/vitest.mjs --run --project core:file", - "format": "prettier .", "check": "pnpm format --write --log-level silent && pnpm lint --quiet && pnpm test && pnpm build", + "format": "prettier .", "lint": "eslint", "drizzle:libsql": "drizzle-kit push --config ./cloud/backend/node/drizzle.cloud.libsql.config.ts", "drizzle:d1-local": "drizzle-kit push --config ./cloud/backend/cf-d1/drizzle.cloud.d1-local.config.ts", @@ -105,7 +105,8 @@ "workerd" ], "patchedDependencies": { - "drizzle-kit": "patches/drizzle-kit.patch" + "drizzle-kit": "patches/drizzle-kit.patch", + "@clerk/clerk-js": "patches/@clerk__clerk-js.patch" } } } diff --git a/patches/@clerk__clerk-js.patch b/patches/@clerk__clerk-js.patch new file mode 100644 index 000000000..4192aa190 --- /dev/null +++ b/patches/@clerk__clerk-js.patch @@ -0,0 +1,21 @@ +diff --git a/package.json b/package.json +index b710adceaf4b01d3c8b7eae2df52c4581c94f8e5..fe3567b54515024301e7ec819519b777f050eac2 100644 +--- a/package.json ++++ b/package.json +@@ -10,6 +10,16 @@ + "session", + "jwt" + ], ++ "exports": { ++ ".": { ++ "import": "./dist/clerk.mjs", ++ "types": "./dist/types/index.d.ts" ++ }, ++ "./headless": { ++ "import": "./headless/index.js", ++ "types": "./headless/index.d.ts" ++ } ++ }, + "homepage": "https://clerk.com/", + "bugs": { + "url": "https://github.com/clerk/javascript/issues" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23d503c74..0f2828b7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false patchedDependencies: + '@clerk/clerk-js': + hash: b21e97952bd0811ee3373b4ce4f4c363cd8d1d7cf01ed705089e8eb2ef466f7e + path: patches/@clerk__clerk-js.patch drizzle-kit: hash: 9e79163b9304da5cbc3c787034937aeddaf678492ba5636df601baaa78e130d8 path: patches/drizzle-kit.patch @@ -137,6 +140,12 @@ importers: '@adviser/cement': specifier: ^0.4.63 version: 0.4.63(typescript@5.9.3) + preact: + specifier: ^10.27.2 + version: 10.27.2 + preact-render-to-string: + specifier: ^6.6.2 + version: 6.6.2(preact@10.27.2) react: specifier: ^19.2.0 version: 19.2.0 @@ -386,6 +395,157 @@ importers: specifier: ^8.8.5 version: 8.8.5 + cloud/box-party: + dependencies: + '@adviser/cement': + specifier: ^0.4.63 + version: 0.4.63(typescript@5.9.3) + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + use-fireproof: + specifier: workspace:0.0.0 + version: link:../../use-fireproof + devDependencies: + '@types/react': + specifier: ^19.2.2 + version: 19.2.3 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.3(@types/react@19.2.3) + '@vitejs/plugin-react': + specifier: ^5.1.0 + version: 5.1.1(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.1)) + vite: + specifier: ^7.1.12 + version: 7.2.2(@types/node@24.10.1)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.1) + + cloud/connector/base: + dependencies: + '@adviser/cement': + specifier: ^0.4.63 + version: 0.4.63(typescript@5.9.3) + '@fireproof/core-runtime': + specifier: workspace:* + version: link:../../../core/runtime + '@fireproof/core-types-base': + specifier: workspace:* + version: link:../../../core/types/base + '@fireproof/core-types-protocols-cloud': + specifier: workspace:* + version: link:../../../core/types/protocols/cloud + jose: + specifier: ^6.0.12 + version: 6.1.1 + ts-essentials: + specifier: ^10.1.1 + version: 10.1.1(typescript@5.9.3) + zod: + specifier: ^4.1.12 + version: 4.1.12 + + cloud/connector/iframe: + dependencies: + '@adviser/cement': + specifier: ^0.4.63 + version: 0.4.63(typescript@5.9.3) + '@clerk/clerk-js': + specifier: ^5.102.0 + version: 5.107.0(patch_hash=b21e97952bd0811ee3373b4ce4f4c363cd8d1d7cf01ed705089e8eb2ef466f7e)(@types/react@19.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.0))(zod@4.1.12) + '@fireproof/cloud-connector-base': + specifier: workspace:* + version: link:../base + '@fireproof/core-protocols-dashboard': + specifier: workspace:* + version: link:../../../core/protocols/dashboard + '@fireproof/core-runtime': + specifier: workspace:* + version: link:../../../core/runtime + '@fireproof/core-types-base': + specifier: workspace:* + version: link:../../../core/types/base + '@fireproof/core-types-protocols-cloud': + specifier: workspace:* + version: link:../../../core/types/protocols/cloud + + cloud/connector/page: + dependencies: + '@adviser/cement': + specifier: ^0.4.63 + version: 0.4.63(typescript@5.9.3) + '@fireproof/cloud-connector-base': + specifier: workspace:* + version: link:../base + '@fireproof/core-runtime': + specifier: workspace:* + version: link:../../../core/runtime + '@fireproof/core-types-base': + specifier: workspace:* + version: link:../../../core/types/base + ts-essentials: + specifier: ^10.1.1 + version: 10.1.1(typescript@5.9.3) + + cloud/connector/svc: + dependencies: + '@adviser/cement': + specifier: ^0.4.63 + version: 0.4.63(typescript@5.9.3) + '@clerk/clerk-js': + specifier: ^5.102.1 + version: 5.107.0(patch_hash=b21e97952bd0811ee3373b4ce4f4c363cd8d1d7cf01ed705089e8eb2ef466f7e)(@types/react@19.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.0))(zod@4.1.12) + '@fireproof/cloud-connector-base': + specifier: workspace:* + version: link:../base + '@fireproof/core-protocols-dashboard': + specifier: workspace:* + version: link:../../../core/protocols/dashboard + '@fireproof/core-runtime': + specifier: workspace:* + version: link:../../../core/runtime + '@fireproof/core-types-base': + specifier: workspace:* + version: link:../../../core/types/base + '@fireproof/core-types-protocols-cloud': + specifier: workspace:* + version: link:../../../core/types/protocols/cloud + ts-essentials: + specifier: ^10.1.1 + version: 10.1.1(typescript@5.9.3) + + cloud/connector/test: + dependencies: + '@fireproof/cloud-connector-base': + specifier: workspace:* + version: link:../base + '@fireproof/cloud-connector-iframe': + specifier: workspace:* + version: link:../iframe + '@fireproof/cloud-connector-page': + specifier: workspace:* + version: link:../page + '@fireproof/cloud-connector-svc': + specifier: workspace:* + version: link:../svc + '@fireproof/core-runtime': + specifier: workspace:* + version: link:../../../core/runtime + '@vitest/browser': + specifier: ^4.0.8 + version: 4.0.8(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.1))(vitest@4.0.8) + '@vitest/browser-playwright': + specifier: ^4.0.8 + version: 4.0.8(playwright@1.56.1)(vite@7.2.2(@types/node@24.10.1)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.1))(vitest@4.0.8) + ts-essentials: + specifier: ^10.1.1 + version: 10.1.1(typescript@5.9.3) + vitest: + specifier: ^4.0.8 + version: 4.0.8(@types/node@24.10.1)(@vitest/browser-playwright@4.0.8)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.1) + cloud/todo-app: dependencies: '@adviser/cement': @@ -1068,13 +1228,16 @@ importers: version: 2.21.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@clerk/clerk-js': specifier: ^5.107.0 - version: 5.107.0(@types/react@19.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.0))(zod@4.1.12) + version: 5.107.0(patch_hash=b21e97952bd0811ee3373b4ce4f4c363cd8d1d7cf01ed705089e8eb2ef466f7e)(@types/react@19.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.0))(zod@4.1.12) '@clerk/clerk-react': specifier: ^5.54.0 version: 5.54.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@fireproof/core': specifier: workspace:0.0.0 version: link:../core/core + '@fireproof/core-base': + specifier: ^0.23.15 + version: 0.23.15(typescript@5.9.3) '@fireproof/core-protocols-cloud': specifier: workspace:0.0.0 version: link:../core/protocols/cloud @@ -1132,11 +1295,14 @@ importers: react-simple-code-editor: specifier: ^0.14.1 version: 0.14.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + ts-essentials: + specifier: ^10.1.1 + version: 10.1.1(typescript@5.9.3) use-editable: specifier: ^2.3.3 version: 2.3.3(react@19.2.0) use-fireproof: - specifier: workspace:0.0.0 + specifier: workspace:* version: link:../use-fireproof zod: specifier: ^4.1.12 @@ -1151,8 +1317,11 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.1 - '@fireproof/core-cli': + '@fireproof/cloud-connector-iframe': specifier: workspace:0.0.0 + version: link:../cloud/connector/iframe + '@fireproof/core-cli': + specifier: workspace:* version: link:../cli '@libsql/client': specifier: ^0.15.15 @@ -1265,12 +1434,21 @@ importers: jose: specifier: ^6.1.1 version: 6.1.1 + preact: + specifier: ^10.27.2 + version: 10.27.2 + preact-render-to-string: + specifier: ^6.6.2 + version: 6.6.2(preact@10.27.2) react: specifier: '>=18.0.0' version: 19.2.0 ts-essentials: specifier: ^10.1.1 version: 10.1.1(typescript@5.9.3) + zod: + specifier: ^4.1.12 + version: 4.1.12 devDependencies: '@fireproof/core-cli': specifier: workspace:0.0.0 @@ -1757,12 +1935,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.25.10': - resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -1787,12 +1959,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.25.10': - resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -1817,12 +1983,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.25.10': - resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -1847,12 +2007,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.25.10': - resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -1877,12 +2031,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.25.10': - resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -1907,12 +2055,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.25.10': - resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -1937,12 +2079,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.25.10': - resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -1967,12 +2103,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.10': - resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -1997,12 +2127,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.25.10': - resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -2027,12 +2151,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.25.10': - resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -2057,12 +2175,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.25.10': - resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -2087,12 +2199,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.25.10': - resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -2117,12 +2223,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.25.10': - resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -2147,12 +2247,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.25.10': - resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -2177,12 +2271,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.25.10': - resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -2207,12 +2295,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.25.10': - resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -2237,12 +2319,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.25.10': - resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -2255,12 +2331,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.10': - resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -2285,12 +2355,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.10': - resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -2303,12 +2367,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.10': - resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -2333,12 +2391,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.10': - resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -2351,12 +2403,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.10': - resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -2375,12 +2421,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.25.10': - resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -2405,12 +2445,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.25.10': - resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -2435,12 +2469,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.25.10': - resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -2465,12 +2493,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.25.10': - resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -2521,6 +2543,57 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fireproof/core-base@0.23.15': + resolution: {integrity: sha512-oOfkW9GGGahnf+ws+hN01U9TUW7cRi+1i5Jvh6B2Ewh6IXBhnMMQ3aYAT9EU8HwubyqlrTbMxLt/B0vnhBKWsQ==} + + '@fireproof/core-blockstore@0.23.15': + resolution: {integrity: sha512-plfM4zYvcpgD98C+C9wqwGtuQrFnELKBcPEY96Jw+6jw+UCWKxld9c3rLWBhpab/coAew2LM6UW1NVgMUO0tKA==} + + '@fireproof/core-gateways-base@0.23.15': + resolution: {integrity: sha512-qKpD1ci/+1GDawWQyM8KLe03VG/M3rpvVSdOTBuphDg2sosU1LjWMmgqsxXj+vHqtYi10jlbex2LsFrZCp5E3g==} + + '@fireproof/core-gateways-cloud@0.23.15': + resolution: {integrity: sha512-rXH+FF7P5E/7KA2Lr2iwtwPhR1ZhxGnJgFcN+7jRGWr9ZZCIwmYkmPZRgFsr/S/bEYhzy4nE1+1fm7VcoyH+1g==} + + '@fireproof/core-gateways-file-deno@0.23.15': + resolution: {integrity: sha512-k/f5etfhGl1jh5fDXDQaEPvZS2d7hAK4om0ZzECmClJN++iY/MyCEZeSbDOqo+vUrzyQ3KyW9dLAtOJzYhj4Qg==} + + '@fireproof/core-gateways-file-node@0.23.15': + resolution: {integrity: sha512-f0UfhiE/lIgKsi7cgHEbQQAOTgtyRE/kxqancEHim68HX9MWcxfwx4GGJwLjjb2F//AQuHdyze8lIrXLAWVWow==} + + '@fireproof/core-gateways-file@0.23.15': + resolution: {integrity: sha512-zmRsAZ4HppKre53dtLhpjbD/F7BAZ3fpvJ4fG+ziXJ1P/pPxPggIxzMAtmfAR8w6enmHaroLRGAh3WqRViq1zw==} + + '@fireproof/core-gateways-indexeddb@0.23.15': + resolution: {integrity: sha512-T2PzbU/jC1WygBN5SqJrF+JfKWeKc0/p7Udqi5jHG8O1WZBDdaGgrOagt8PtE1FoZ0DvrMxM+pDwmXTZMJpSqQ==} + + '@fireproof/core-gateways-memory@0.23.15': + resolution: {integrity: sha512-1ZiZxexg2ret5kJTWKRXc+Lk1tntZWf74irCXwLE3IBlkc11FLhSlGhAS6xJNtp1RkkUHMJZ30OjUgQxpOuJAg==} + + '@fireproof/core-keybag@0.23.15': + resolution: {integrity: sha512-4u+Ax/3HopPv+pENerE/LUt48MhLhe7NUR65+wez+K9ZrAmZeNxChUA7cWFgkQFE6qDwvOCnZptvn4quWLKyIQ==} + + '@fireproof/core-protocols-cloud@0.23.15': + resolution: {integrity: sha512-bT+hDKcS39DnFY2x79vdkNfRDG2KfWgSFYl2yvo1p9/qA2OmzPYQHbHgBbx5sIgHBhrIjm6QMQL/gPDaVlRFEQ==} + + '@fireproof/core-runtime@0.23.15': + resolution: {integrity: sha512-lYcoG4J2gMMio+FJKdi5Pm+xf2yFiRJ/xYEsCFuNVUxt8jKI+vQqah31w8ePDTh3EMqOPJB1oj3yN2Kd/bXKWw==} + + '@fireproof/core-types-base@0.23.15': + resolution: {integrity: sha512-Te513vn1zrzsqsMwYnaPKOG0AK8kk8rWOeuLriRurr4M+EO5R+05mBv4xVCDCVCjUMOzsuw4nj8UeCGFA5eIKQ==} + + '@fireproof/core-types-blockstore@0.23.15': + resolution: {integrity: sha512-kaPzqnckvuxtNK+R0Pk5RWBd7OTspGrl1+FtOLMM8SPB2U4tQJBPQ74E8z2Digu07zYais5xTOYM79bnHp0wFw==} + + '@fireproof/core-types-protocols-cloud@0.23.15': + resolution: {integrity: sha512-SNeJsq2FhNljwt/N8WsMGA06tAUC1qOfIzMeWpmKfEMSoDOe/vtNF0GQALF91gMVKph26h/OvwjnnnlZ6dvfZg==} + + '@fireproof/core-types-runtime@0.23.15': + resolution: {integrity: sha512-n9bJvxNFI22E/gN/P8ocTh6/JuqpNcwWUXrpfLZW3FDXJS/tmww65mzxhZTNOV0SjmzVw2EKeEB1FIClsrpfdw==} + + '@fireproof/vendor@0.23.15': + resolution: {integrity: sha512-cwbNOffgitbjhZd83Jwakl6Sqe9pdONNyacEbTtALnbrqmL1FgzOBaPXOy/htUKWz61Irm7go03zGNKe8IIQ6w==} + '@fireproof/vendor@3.0.0': resolution: {integrity: sha512-d2aoTkxWN9wYQ6pOafGxifxIijEvTpX3sBIJSaTrEqhorxRWdIZm8PC84kNV6qQqMr2GNfCUfgqn1mgR54M2rQ==} @@ -3997,11 +4070,6 @@ packages: engines: {node: '>=12'} hasBin: true - esbuild@0.25.10: - resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -5158,6 +5226,11 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@6.6.2: + resolution: {integrity: sha512-VJ++Pkzv6+ZOmeN/9Qvx0mRdXqnei1Lo3uu9bGvYHhoMI1VUkDT44hcpGbiokl/kuuYTayYa3yvmYTLZMplfMA==} + peerDependencies: + preact: '>=10 || >= 11.0.0-0' + preact@10.24.2: resolution: {integrity: sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==} @@ -6407,7 +6480,7 @@ snapshots: - react - react-dom - '@clerk/clerk-js@5.107.0(@types/react@19.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.0))(zod@4.1.12)': + '@clerk/clerk-js@5.107.0(patch_hash=b21e97952bd0811ee3373b4ce4f4c363cd8d1d7cf01ed705089e8eb2ef466f7e)(@types/react@19.2.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.0))(zod@4.1.12)': dependencies: '@base-org/account': 2.0.1(@types/react@19.2.3)(react@19.2.0)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.0))(zod@4.1.12) '@clerk/localizations': 3.27.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -6637,9 +6710,6 @@ snapshots: '@esbuild/aix-ppc64@0.19.12': optional: true - '@esbuild/aix-ppc64@0.25.10': - optional: true - '@esbuild/aix-ppc64@0.25.12': optional: true @@ -6652,9 +6722,6 @@ snapshots: '@esbuild/android-arm64@0.19.12': optional: true - '@esbuild/android-arm64@0.25.10': - optional: true - '@esbuild/android-arm64@0.25.12': optional: true @@ -6667,9 +6734,6 @@ snapshots: '@esbuild/android-arm@0.19.12': optional: true - '@esbuild/android-arm@0.25.10': - optional: true - '@esbuild/android-arm@0.25.12': optional: true @@ -6682,9 +6746,6 @@ snapshots: '@esbuild/android-x64@0.19.12': optional: true - '@esbuild/android-x64@0.25.10': - optional: true - '@esbuild/android-x64@0.25.12': optional: true @@ -6697,9 +6758,6 @@ snapshots: '@esbuild/darwin-arm64@0.19.12': optional: true - '@esbuild/darwin-arm64@0.25.10': - optional: true - '@esbuild/darwin-arm64@0.25.12': optional: true @@ -6712,9 +6770,6 @@ snapshots: '@esbuild/darwin-x64@0.19.12': optional: true - '@esbuild/darwin-x64@0.25.10': - optional: true - '@esbuild/darwin-x64@0.25.12': optional: true @@ -6727,9 +6782,6 @@ snapshots: '@esbuild/freebsd-arm64@0.19.12': optional: true - '@esbuild/freebsd-arm64@0.25.10': - optional: true - '@esbuild/freebsd-arm64@0.25.12': optional: true @@ -6742,9 +6794,6 @@ snapshots: '@esbuild/freebsd-x64@0.19.12': optional: true - '@esbuild/freebsd-x64@0.25.10': - optional: true - '@esbuild/freebsd-x64@0.25.12': optional: true @@ -6757,9 +6806,6 @@ snapshots: '@esbuild/linux-arm64@0.19.12': optional: true - '@esbuild/linux-arm64@0.25.10': - optional: true - '@esbuild/linux-arm64@0.25.12': optional: true @@ -6772,9 +6818,6 @@ snapshots: '@esbuild/linux-arm@0.19.12': optional: true - '@esbuild/linux-arm@0.25.10': - optional: true - '@esbuild/linux-arm@0.25.12': optional: true @@ -6787,9 +6830,6 @@ snapshots: '@esbuild/linux-ia32@0.19.12': optional: true - '@esbuild/linux-ia32@0.25.10': - optional: true - '@esbuild/linux-ia32@0.25.12': optional: true @@ -6802,9 +6842,6 @@ snapshots: '@esbuild/linux-loong64@0.19.12': optional: true - '@esbuild/linux-loong64@0.25.10': - optional: true - '@esbuild/linux-loong64@0.25.12': optional: true @@ -6817,9 +6854,6 @@ snapshots: '@esbuild/linux-mips64el@0.19.12': optional: true - '@esbuild/linux-mips64el@0.25.10': - optional: true - '@esbuild/linux-mips64el@0.25.12': optional: true @@ -6832,9 +6866,6 @@ snapshots: '@esbuild/linux-ppc64@0.19.12': optional: true - '@esbuild/linux-ppc64@0.25.10': - optional: true - '@esbuild/linux-ppc64@0.25.12': optional: true @@ -6847,9 +6878,6 @@ snapshots: '@esbuild/linux-riscv64@0.19.12': optional: true - '@esbuild/linux-riscv64@0.25.10': - optional: true - '@esbuild/linux-riscv64@0.25.12': optional: true @@ -6862,9 +6890,6 @@ snapshots: '@esbuild/linux-s390x@0.19.12': optional: true - '@esbuild/linux-s390x@0.25.10': - optional: true - '@esbuild/linux-s390x@0.25.12': optional: true @@ -6877,18 +6902,12 @@ snapshots: '@esbuild/linux-x64@0.19.12': optional: true - '@esbuild/linux-x64@0.25.10': - optional: true - '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/linux-x64@0.25.4': optional: true - '@esbuild/netbsd-arm64@0.25.10': - optional: true - '@esbuild/netbsd-arm64@0.25.12': optional: true @@ -6901,18 +6920,12 @@ snapshots: '@esbuild/netbsd-x64@0.19.12': optional: true - '@esbuild/netbsd-x64@0.25.10': - optional: true - '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/netbsd-x64@0.25.4': optional: true - '@esbuild/openbsd-arm64@0.25.10': - optional: true - '@esbuild/openbsd-arm64@0.25.12': optional: true @@ -6925,18 +6938,12 @@ snapshots: '@esbuild/openbsd-x64@0.19.12': optional: true - '@esbuild/openbsd-x64@0.25.10': - optional: true - '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openbsd-x64@0.25.4': optional: true - '@esbuild/openharmony-arm64@0.25.10': - optional: true - '@esbuild/openharmony-arm64@0.25.12': optional: true @@ -6946,9 +6953,6 @@ snapshots: '@esbuild/sunos-x64@0.19.12': optional: true - '@esbuild/sunos-x64@0.25.10': - optional: true - '@esbuild/sunos-x64@0.25.12': optional: true @@ -6961,9 +6965,6 @@ snapshots: '@esbuild/win32-arm64@0.19.12': optional: true - '@esbuild/win32-arm64@0.25.10': - optional: true - '@esbuild/win32-arm64@0.25.12': optional: true @@ -6976,9 +6977,6 @@ snapshots: '@esbuild/win32-ia32@0.19.12': optional: true - '@esbuild/win32-ia32@0.25.10': - optional: true - '@esbuild/win32-ia32@0.25.12': optional: true @@ -6991,9 +6989,6 @@ snapshots: '@esbuild/win32-x64@0.19.12': optional: true - '@esbuild/win32-x64@0.25.10': - optional: true - '@esbuild/win32-x64@0.25.12': optional: true @@ -7046,6 +7041,227 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fireproof/core-base@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-blockstore': 0.23.15(typescript@5.9.3) + '@fireproof/core-keybag': 0.23.15(typescript@5.9.3) + '@fireproof/core-runtime': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-blockstore': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-protocols-cloud': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + '@ipld/dag-cbor': 9.2.5 + '@web3-storage/pail': 0.6.2 + charwise: 3.0.1 + prolly-trees: 1.0.4 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + + '@fireproof/core-blockstore@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-gateways-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-gateways-cloud': 0.23.15(typescript@5.9.3) + '@fireproof/core-gateways-file': 0.23.15(typescript@5.9.3) + '@fireproof/core-gateways-indexeddb': 0.23.15(typescript@5.9.3) + '@fireproof/core-gateways-memory': 0.23.15(typescript@5.9.3) + '@fireproof/core-keybag': 0.23.15(typescript@5.9.3) + '@fireproof/core-runtime': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-blockstore': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-runtime': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + '@ipld/car': 5.4.2 + '@ipld/dag-cbor': 9.2.5 + '@ipld/dag-json': 10.2.5 + '@web3-storage/pail': 0.6.2 + multiformats: 13.4.1 + p-map: 7.0.3 + p-retry: 7.1.0 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + + '@fireproof/core-gateways-base@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-runtime': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-blockstore': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + '@ipld/dag-json': 10.2.5 + '@web3-storage/pail': 0.6.2 + transitivePeerDependencies: + - typescript + + '@fireproof/core-gateways-cloud@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-gateways-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-protocols-cloud': 0.23.15(typescript@5.9.3) + '@fireproof/core-runtime': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-blockstore': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-protocols-cloud': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + jose: 6.1.1 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + + '@fireproof/core-gateways-file-deno@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + '@types/deno': 2.5.0 + '@types/node': 24.10.1 + transitivePeerDependencies: + - typescript + + '@fireproof/core-gateways-file-node@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@fireproof/core-gateways-file@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-gateways-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-gateways-file-deno': 0.23.15(typescript@5.9.3) + '@fireproof/core-gateways-file-node': 0.23.15(typescript@5.9.3) + '@fireproof/core-runtime': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-blockstore': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@fireproof/core-gateways-indexeddb@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-gateways-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-runtime': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-blockstore': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + idb: 8.0.3 + transitivePeerDependencies: + - typescript + + '@fireproof/core-gateways-memory@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-gateways-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-runtime': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-blockstore': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@fireproof/core-keybag@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-gateways-file': 0.23.15(typescript@5.9.3) + '@fireproof/core-gateways-indexeddb': 0.23.15(typescript@5.9.3) + '@fireproof/core-runtime': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + jose: 6.1.1 + multiformats: 13.4.1 + zod: 4.1.12 + transitivePeerDependencies: + - typescript + + '@fireproof/core-protocols-cloud@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-runtime': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-protocols-cloud': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + '@types/ws': 8.18.1 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + + '@fireproof/core-runtime@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@adviser/ts-xxhash': 1.0.2 + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-blockstore': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-protocols-cloud': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-runtime': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + cborg: 4.3.0 + jose: 6.1.1 + multiformats: 13.4.1 + transitivePeerDependencies: + - typescript + + '@fireproof/core-types-base@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-types-blockstore': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + '@web3-storage/pail': 0.6.2 + jose: 6.1.1 + multiformats: 13.4.1 + prolly-trees: 1.0.4 + zod: 4.1.12 + transitivePeerDependencies: + - typescript + + '@fireproof/core-types-blockstore@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-runtime': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + '@web3-storage/pail': 0.6.2 + multiformats: 13.4.1 + transitivePeerDependencies: + - typescript + + '@fireproof/core-types-protocols-cloud@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/core-types-base': 0.23.15(typescript@5.9.3) + '@fireproof/core-types-blockstore': 0.23.15(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + jose: 6.1.1 + multiformats: 13.4.1 + zod: 4.1.12 + transitivePeerDependencies: + - typescript + + '@fireproof/core-types-runtime@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + '@fireproof/vendor': 0.23.15(typescript@5.9.3) + multiformats: 13.4.1 + transitivePeerDependencies: + - typescript + + '@fireproof/vendor@0.23.15(typescript@5.9.3)': + dependencies: + '@adviser/cement': 0.4.63(typescript@5.9.3) + yocto-queue: 1.2.1 + transitivePeerDependencies: + - typescript + '@fireproof/vendor@3.0.0': dependencies: yocto-queue: 1.2.1 @@ -8567,35 +8783,6 @@ snapshots: '@esbuild/win32-ia32': 0.19.12 '@esbuild/win32-x64': 0.19.12 - esbuild@0.25.10: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.10 - '@esbuild/android-arm': 0.25.10 - '@esbuild/android-arm64': 0.25.10 - '@esbuild/android-x64': 0.25.10 - '@esbuild/darwin-arm64': 0.25.10 - '@esbuild/darwin-x64': 0.25.10 - '@esbuild/freebsd-arm64': 0.25.10 - '@esbuild/freebsd-x64': 0.25.10 - '@esbuild/linux-arm': 0.25.10 - '@esbuild/linux-arm64': 0.25.10 - '@esbuild/linux-ia32': 0.25.10 - '@esbuild/linux-loong64': 0.25.10 - '@esbuild/linux-mips64el': 0.25.10 - '@esbuild/linux-ppc64': 0.25.10 - '@esbuild/linux-riscv64': 0.25.10 - '@esbuild/linux-s390x': 0.25.10 - '@esbuild/linux-x64': 0.25.10 - '@esbuild/netbsd-arm64': 0.25.10 - '@esbuild/netbsd-x64': 0.25.10 - '@esbuild/openbsd-arm64': 0.25.10 - '@esbuild/openbsd-x64': 0.25.10 - '@esbuild/openharmony-arm64': 0.25.10 - '@esbuild/sunos-x64': 0.25.10 - '@esbuild/win32-arm64': 0.25.10 - '@esbuild/win32-ia32': 0.25.10 - '@esbuild/win32-x64': 0.25.10 - esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -9858,6 +10045,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact-render-to-string@6.6.2(preact@10.27.2): + dependencies: + preact: 10.27.2 + preact@10.24.2: {} preact@10.27.2: {} @@ -10460,7 +10651,7 @@ snapshots: tsx@4.20.5: dependencies: - esbuild: 0.25.10 + esbuild: 0.25.12 get-tsconfig: 4.10.1 optionalDependencies: fsevents: 2.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c43359386..6b6c60735 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - "core/*" - "cloud/*" - "cloud/backend/*" + - "cloud/connector/*" - "cli" - "core/types/*" - "core/types/protocols/*" diff --git a/smoke/esm/package-template.json b/smoke/esm/package-template.json index df350526a..e6cabc569 100644 --- a/smoke/esm/package-template.json +++ b/smoke/esm/package-template.json @@ -9,12 +9,13 @@ "devDependencies": { "deno": "^2.5.0", "typescript": "^5.9.0", - "playwright": "^1.55.1", - "playwright-chromium": "^1.55.1", - "@vitest/browser": "^3.2.4", - "@vitest/ui": "^3.2.4", - "vitest": "^3.2.4", - "vite": "^7.1.0" + "playwright": "^1.56.1", + "playwright-chromium": "^1.56.1", + "@vitest/browser": "^4.0.4", + "@vitest/browser-playwright": "^4.0.4", + "@vitest/ui": "^4.0.4", + "vitest": "^4.0.4", + "vite": "^7.1.12" }, "pnpm": { "onlyBuiltDependencies": ["esbuild", "msw", "playwright-chromium", "deno"] diff --git a/smoke/esm/src/index.test.ts b/smoke/esm/src/index.test.ts index 6de2f9427..384bc23d8 100644 --- a/smoke/esm/src/index.test.ts +++ b/smoke/esm/src/index.test.ts @@ -1,4 +1,4 @@ -import { page } from "@vitest/browser/context"; +import { page } from "vitest/browser"; import { expect, it, vi } from "vitest"; interface FPWindow extends Window { diff --git a/smoke/esm/vitest.config.ts b/smoke/esm/vitest.config.ts index 142d38be5..986ada14b 100644 --- a/smoke/esm/vitest.config.ts +++ b/smoke/esm/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "vitest/config"; +import { playwright } from "@vitest/browser-playwright"; export default defineConfig({ test: { @@ -6,12 +7,12 @@ export default defineConfig({ setupFiles: "./setup.js", browser: { enabled: true, - provider: "playwright", - // provider: "webdriverio", headless: true, + provider: playwright({ + // ...custom playwright options + }), instances: [ { - // browser: "chrome", browser: "chromium", }, ], diff --git a/smoke/react/package-template.json b/smoke/react/package-template.json index 65c3c497f..585b05c9b 100644 --- a/smoke/react/package-template.json +++ b/smoke/react/package-template.json @@ -22,13 +22,13 @@ "@testing-library/react": "^16.0.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", - "playwright": "^1.55.0", - "playwright-chromium": "^1.55.0", + "playwright": "^1.56.1", "@vitejs/plugin-react": "^5.0.4", - "@vitest/browser": "^3.2.4", - "@vitest/ui": "^3.2.4", + "@vitest/browser": "^4.0.4", + "@vitest/browser-playwright": "^4.0.4", + "@vitest/ui": "^4.0.4", "typescript": "^5.9.0", - "vite": "^7.1.0", - "vitest": "^3.2.4" + "vite": "^7.1.12", + "vitest": "^4.0.4" } } diff --git a/smoke/react/vite.config.ts b/smoke/react/vite.config.ts index 29cc3fa26..43ffb9d73 100644 --- a/smoke/react/vite.config.ts +++ b/smoke/react/vite.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "vitest/config"; +import { playwright } from "@vitest/browser-playwright"; export default defineConfig({ test: { @@ -7,16 +8,14 @@ export default defineConfig({ browser: { enabled: true, headless: true, - provider: "playwright", - // provider: "webdriverio", + provider: playwright({ + // ...custom playwright options + }), instances: [ { - // browser: "chrome", browser: "chromium", }, ], - // name: "chrome", // browser name is required - // Disable screenshots providerOptions: { use: { screenshot: "off", diff --git a/use-fireproof/fp-cloud-connect-strategy-impl.ts b/use-fireproof/fp-cloud-connect-strategy-impl.ts new file mode 100644 index 000000000..8cee74d81 --- /dev/null +++ b/use-fireproof/fp-cloud-connect-strategy-impl.ts @@ -0,0 +1,164 @@ +import { BuildURI, KeyedResolvOnce, Lazy, Logger, ResolveSeq, Result } from "@adviser/cement"; +import { SuperThis } from "@fireproof/core-types-base"; +import { ToCloudOpts, TokenAndSelectedTenantAndLedger, TokenStrategie } from "@fireproof/core-types-protocols-cloud"; +import { ensureLogger, ensureSuperThis, hashObjectSync } from "@fireproof/core-runtime"; + +import { FPCCEvtApp, dbAppKey } from "@fireproof/cloud-connector-base"; +import { FPCloudConnectOpts, PageControllerImpl } from "./fp-cloud-connect-strategy.js"; +import { FPCloudFrontendImpl } from "./window-open-fp-cloud.js"; + +const registerLocalDbNames = new KeyedResolvOnce, string>(); + +export class FPCloudConnectStrategyImpl implements TokenStrategie { + overlayNode?: HTMLDivElement; + waitState: "started" | "stopped" = "stopped"; + + readonly fpCloudConnectURL: string; + readonly sthis: SuperThis; + readonly logger: Logger; + readonly pageController: PageControllerImpl; + + constructor(opts: Partial) { + const dashboardURI = opts.dashboardURI ?? "https://dev.connect.fireproof.direct/"; + let fpCloudConnectURL: BuildURI; + if (opts.fpCloudConnectURL) { + fpCloudConnectURL = BuildURI.from(opts.fpCloudConnectURL); + } else { + fpCloudConnectURL = BuildURI.from( + // eslint-disable-next-line no-restricted-globals + new URL("/", dashboardURI).toString(), + ).pathname("/@fireproof/cloud-connector-iframe/injected-iframe.html"); + } + + if (opts.dashboardURI) { + fpCloudConnectURL.setParam("dashboard_uri", opts.dashboardURI); + } + if (opts.cloudApiURI) { + fpCloudConnectURL.setParam("cloud_api_uri", opts.cloudApiURI); + } + this.fpCloudConnectURL = fpCloudConnectURL.toString(); + // console.log("FPCloudConnectStrategy constructed with fpCloudConnectURL", this.fpCloudConnectURL); + this.sthis = opts.sthis ?? ensureSuperThis(); + this.logger = ensureLogger(this.sthis, "FPCloudConnectStrategy"); + this.pageController = new PageControllerImpl({ + sthis: this.sthis, + iframeHref: this.fpCloudConnectURL, + logger: this.logger, + fpCloudFrontend: opts.fpCloudFrontend ?? new FPCloudFrontendImpl(opts), + }); + } + readonly hash = Lazy(() => + hashObjectSync({ + pageController: this.pageController.hash(), + fpCloudConnectURL: this.fpCloudConnectURL, + }), + ); + + readonly openloginSeq = new ResolveSeq(); + // readonly waitForTokenPerLocalDbFuture = new KeyedResolvOnce>>(); + + fpccEvtApp2TokenAndClaims(evt: FPCCEvtApp): Result { + // convertToTokenAndClaims({ + + // }, this.logger, evt.localDb.accessToken) + const tAndC: TokenAndSelectedTenantAndLedger = { + token: evt.localDb.accessToken, + claims: { + selected: { + tenant: evt.localDb.tenantId, + ledger: evt.localDb.ledgerId, + }, + }, + }; + return Result.Ok(tAndC); + } + + // getPageProtocol(sthis: SuperThis): Promise { + // const key = ppageProtocolKey(this.fpCloudConnectURL); + // return ppageProtocolInstances.get(key).once(async () => { + // console.log("FPCloudConnectStrategy creating new PageFPCCProtocol for key", key, import.meta.url); + // const ppage = new PageFPCCProtocol(sthis, { iframeHref: key }); + // await initializeIframe(ppage); + // await ppage.ready(); + // ppage.onFPCCEvtNeedsLogin((msg) => { + // this.openloginSeq.add(() => { + // // test if all dbs are ready + // console.log("FPCloudConnectStrategy detected needs login event"); + // this.openFireproofLogin(msg); + // return sleep(10000); + // }); + // // logger.Info().Msg("FPCloudConnectStrategy detected needs login event"); + // }); + // // this.waitForTokenPerLocalDbFuture.get(key).once(() => new Future()); + // ppage.onFPCCEvtApp((evt) => { + // const key = dbAppKey({ appId: evt.appId, dbName: evt.localDb.dbName }); + // const rTAndC = this.fpccEvtApp2TokenAndClaims(evt); + // console.log("FPCloudConnectStrategy received FPCCEvtApp, resolving waitForTokenAndClaims for key", key, rTAndC.Ok()); + // this.waitForTokenAndClaims.get(key).reset(() => rTAndC); + // // if (future) { + // // future.resolve(rTAndC) + // // } + // }); + // return ppage.ready(); + // }); + // } + + open(sthis: SuperThis, _logger: Logger, localDbName: string, _opts: ToCloudOpts) { + console.log("FPCloudConnectStrategy open called for localDbName", localDbName); + return this.pageController.ready().then(() => { + return registerLocalDbNames.get(`${localDbName}:${this.pageController.appId()}:${ppage.dst}`).once(() => { + console.log("FPCloudConnectStrategy open registering localDbName", localDbName); + }); + }); + } + + // private currentToken?: TokenAndClaims; + + // waiting?: ReturnType; + + stop() { + console.log("FPCloudConnectStrategy stop called"); + // if (this.waiting) { + // clearTimeout(this.waiting); + // this.waiting = undefined; + // } + // this.waitState = "stopped"; + } + + readonly waitForTokenAndClaims = new KeyedResolvOnce>(); + // async tryToken(sthis: SuperThis, logger: Logger, opts: ToCloudOpts): Promise { + // console.log("FPCloudConnectStrategy tryToken called", opts); + // // if (!this.currentToken) { + // // const webCtx = opts.context.get(WebCtx) as WebToCloudCtx; + // // this.currentToken = await webCtx.token(); + // // // console.log("RedirectStrategy tryToken - ctx", this.currentToken); + // // } + // // return this.currentToken; + // return undefined; + // } + + async waitForToken( + _sthis: SuperThis, + _logger: Logger, + localDbName: string, + _opts: ToCloudOpts, + ): Promise> { + // console.log("FPCloudConnectStrategy waitForToken called for localDbName", localDbName); + await this.pageController.ready(); + const key = dbAppKey({ appId: this.pageController.appId(), dbName: localDbName }); + await this.openloginSeq.flush(); + return this.waitForTokenAndClaims.get(key).once(() => { + return this.pageController.registerDatabase(localDbName).then((evt) => { + if (evt.isErr()) { + console.log("FPCloudConnectStrategy waitForToken registering database failed for key", key, evt); + return Result.Err(evt); + } + console.log("FPCloudConnectStrategy waitForToken resolving for key", key); + return this.fpccEvtApp2TokenAndClaims(evt.Ok()); + }); + + // const future = this.waitForTokenPerLocalDbFuture.get(key).once(() => new Future>()) + // return future.asPromise(); + }); + } +} diff --git a/use-fireproof/fp-cloud-connect-strategy.ts b/use-fireproof/fp-cloud-connect-strategy.ts new file mode 100644 index 000000000..47d632936 --- /dev/null +++ b/use-fireproof/fp-cloud-connect-strategy.ts @@ -0,0 +1,258 @@ +import { Future, KeyedResolvOnce, Lazy, Logger, poller, ResolveSeq, sleep } from "@adviser/cement"; +import { SuperThis } from "@fireproof/core-types-base"; +import { TokenStrategie } from "@fireproof/core-types-protocols-cloud"; +import { ensureSuperThis, hashObjectSync } from "@fireproof/core-runtime"; +import { RedirectStrategyOpts } from "./redirect-strategy.js"; + +import { FPCCProtocol, FPCCProtocolBase, isInIframe } from "@fireproof/cloud-connector-base"; +import { useEffect, useState } from "react"; +import { defaultFPCloudConnectorOpts, fpCloudConnector } from "../cloud/connector/svc/fp-cloud-connector.js"; +import { FPCloudConnectStrategyImpl } from "./fp-cloud-connect-strategy-impl.js"; +import { FPCloudFrontend, initializeIframe, PageFPCCProtocolOpts } from "@fireproof/cloud-connector-page"; +import { FPCloudFrontendImpl } from "./window-open-fp-cloud.js"; + +export interface FPCloudConnectOpts extends RedirectStrategyOpts { + readonly dashboardURI?: string; + readonly cloudApiURI?: string; + readonly fpCloudConnectURL: string; + readonly pageController: PageControllerImpl; + readonly title?: string; + readonly sthis?: SuperThis; + readonly fpCloudFrontend?: FPCloudFrontend; +} + +// which cases exist +// stategy is called in the calling page +// - ask my self if providing iframe services use them +// - search for iframes and ask if ready use them +// - if not found create an iframe and wait for it to be ready + +// stategy is called in an iframe +// - ask my self if providing iframe services use them +// - wait for parent to provide services + +interface PageControllerOpts { + readonly window: Window; + readonly sthis: SuperThis; + readonly logger: Logger; + // readonly frontend: FPCloudFrontend; +} + +export type PageControllerImplOpts = PageFPCCProtocolOpts & Partial; + +// const registerIframe = Lazy((callback: (iframe: HTMLIFrameElement) => void) => { +// // Check existing iframes first +// document.querySelectorAll("iframe").forEach((iframe) => { +// callback(iframe as HTMLIFrameElement); +// }); + +// // Watch for new iframes +// const observer = new MutationObserver((mutations) => { +// mutations.forEach((mutation) => { +// mutation.addedNodes.forEach((node) => { +// if (node.nodeName === "IFRAME") { +// callback(node as HTMLIFrameElement); +// } + +// // Check if added node contains iframes +// if (node instanceof Element) { +// node.querySelectorAll("iframe").forEach((iframe) => { +// callback(iframe as HTMLIFrameElement); +// }); +// } +// }); +// }); +// }); + +// observer.observe(document.body, { +// childList: true, +// subtree: true, +// }); + +// return observer; // Return so you can disconnect later +// }); + +export class PageControllerImpl { + mode: "myself" | "parent" | "child" | "unknown" | "timeout" = "unknown"; + + readonly window: Window; + readonly registerWaitTime: number; + readonly protocol: FPCCProtocolBase; + readonly sthis: SuperThis; + readonly intervalMs: number; + readonly iframeHref: string; + readonly fpCloudFrontend: FPCloudFrontend; + + constructor(opts: PageControllerImplOpts) { + this.window = opts.window ?? window; + this.sthis = opts.sthis ?? ensureSuperThis(); + this.registerWaitTime = opts.registerWaitTime || 10000; + this.intervalMs = opts.intervalMs || 150; + this.protocol = new FPCCProtocolBase(this.sthis, opts.logger); + this.iframeHref = opts.iframeHref; + this.fpCloudFrontend = + opts.fpCloudFrontend ?? + new FPCloudFrontendImpl({ + sthis: this.sthis, + }); + } + + hash = Lazy(() => + hashObjectSync({ + registerWaitTime: this.registerWaitTime, + intervalMs: this.intervalMs, + protocol: this.protocol.hash(), + iframeHref: this.iframeHref, + }), + ); + + readonly appId = Lazy(() => { + // setup in ready + return `we-need-to-implement-app-id-this:${this.sthis.nextId(8)}`; + }); + + readonly openloginSeq = new ResolveSeq(); + readonly ready = Lazy(async (): Promise => { + const actions: Promise<"timeout" | "parent" | "myself" | "child">[] = [ + sleep(this.registerWaitTime).then(() => "timeout" as const), + ]; + actions.push(this.myselfWaiting().then(() => "myself" as const)); + if (isInIframe()) { + actions.push(this.parentWaiting().then(() => "parent" as const)); + } else { + actions.push(this.childWaiting().then(() => "child" as const)); + } + + this.protocol.onFPCCEvtNeedsLogin((msg) => { + this.openloginSeq.add(async () => { + // test if all dbs are ready + console.log("FPCloudConnectStrategy detected needs login event"); + this.fpCloudFrontend.openLogin(msg); + return; + }); + // logger.Info().Msg("FPCloudConnectStrategy detected needs login event"); + }); + + return Promise.race(actions).then((mode) => { + this.mode = mode; + }); + }); + + async #waitingForReady(tid: string, dst: string): Promise { + const ready = new Future(); + const unreg = this.protocol.onFPCCEvtConnectorReady((msg, _srcEvent) => { + if (msg.tid === tid) { + return Promise.resolve(); + } + ready.resolve(); + }); + + const abCtl = new AbortController(); + return Promise.race([ + poller( + async () => { + this.protocol.sendMessage( + { + tid: tid, + src: "parentWaiting", + dst: "myself", + type: "FPCCReqWaitConnectorReady", + }, + dst, + ); + return { + state: "waiting", + }; + }, + { + intervalMs: this.intervalMs, + abortSignal: abCtl.signal, + }, + ).then(() => { + /* nop */ + }), + ready.asPromise(), + ]).finally(() => { + unreg(); + abCtl.abort(); + }); + } + async parentWaiting(): Promise { + const tid = this.sthis.nextId(16).str; + await this.#waitingForReady(tid, this.window.parent.location.href); + } + async childWaiting(): Promise { + const tid = this.sthis.nextId(16).str; + const waitingIframes: Promise[] = []; + const iframes = document.querySelectorAll("iframe"); + if (iframes.length === 0) { + const iframe = await initializeIframe(this.protocol, this.iframeHref); + waitingIframes.push(this.#waitingForReady(tid, iframe.src)); + } else { + iframes.forEach((iframe) => { + waitingIframes.push(this.#waitingForReady(tid, (iframe as HTMLIFrameElement).src)); + }); + } + return Promise.race(waitingIframes); + } + async myselfWaiting(): Promise { + const tid = this.sthis.nextId(16).str; + await this.#waitingForReady(tid, this.window.location.href); + } +} + +export const PageController = Lazy((opts: PageControllerImplOpts) => { + return new PageControllerImpl(opts); +}); + +// open(sthis: SuperThis, logger: Logger, deviceId: string, opts: ToCloudOpts): void; +// tryToken(sthis: SuperThis, logger: Logger, opts: ToCloudOpts): Promise; +// waitForToken(sthis: SuperThis, logger: Logger, deviceId: string, opts: ToCloudOpts): Promise; +// stop(): void; + +// const ppageProtocolInstances = new KeyedResolvOnce(); + +// function ppageProtocolKey(iframeSrc: string): string { +// let iframeHref: URI; +// if (typeof iframeSrc === "string" && iframeSrc.match(/^[./]/)) { +// // Infer the path to in-iframe.js from the current module's location +// // eslint-disable-next-line no-restricted-globals +// const scriptUrl = new URL(import.meta.url); +// // eslint-disable-next-line no-restricted-globals +// iframeHref = URI.from(new URL(iframeSrc, scriptUrl).href); +// } else { +// iframeHref = URI.from(iframeSrc); +// } +// return iframeHref.toString(); +// } + +const fpCloudConnectStrategyInstances = new KeyedResolvOnce(); +// this is the frontend of fp service connector +export function FPCloudConnectStrategy(opts: Partial = {}): TokenStrategie { + const key = hashObjectSync(opts); + return fpCloudConnectStrategyInstances.get(key).once(() => { + return new FPCloudConnectStrategyImpl(opts); + }); +} + +// this is the backend fp service connector +export function useFPCloudConnectSvc(): { fpSvc: FPCCProtocol; state: string } { + const fpSvc = fpCloudConnector( + defaultFPCloudConnectorOpts({ + loadUrlStr: window.location.href, + }), + ); + const [fpSvcState, setFpSvcState] = useState("initializing"); + useEffect(() => { + console.log("useFPCloudConnect initializing token strategy"); + fpSvc.ready().then(() => { + console.log("useFPCloudConnect token strategy ready"); + setFpSvcState("ready"); + }); + }, [fpSvc]); + + return { + fpSvc, + state: fpSvcState, + }; +} diff --git a/use-fireproof/iframe-strategy.ts b/use-fireproof/iframe-strategy.ts index cdf7e4d51..b738aaeb7 100644 --- a/use-fireproof/iframe-strategy.ts +++ b/use-fireproof/iframe-strategy.ts @@ -1,10 +1,11 @@ -import { BuildURI, Logger } from "@adviser/cement"; +import { BuildURI, Logger, Result } from "@adviser/cement"; import { SuperThis } from "@fireproof/core-types-base"; -import { TokenStrategie, ToCloudOpts, TokenAndClaims } from "@fireproof/core-types-protocols-cloud"; +import { TokenStrategie, ToCloudOpts, TokenAndSelectedTenantAndLedger } from "@fireproof/core-types-protocols-cloud"; import { WebCtx } from "./react/use-attach.js"; import { WebToCloudCtx } from "./react/types.js"; export class IframeStrategy implements TokenStrategie { + waitState: "started" | "stopped" = "stopped"; hash(): string { return "IframeStrategy: this is not a advance"; } @@ -82,7 +83,7 @@ export class IframeStrategy implements TokenStrategie { const redirectCtx = opts.context.get(WebCtx) as WebToCloudCtx; document.body.appendChild(this.overlayDiv(deviceId, redirectCtx.dashboardURI)); } - async tryToken(sthis: SuperThis, logger: Logger, opts: ToCloudOpts): Promise { + async tryToken(sthis: SuperThis, logger: Logger, opts: ToCloudOpts): Promise { const redirectCtx = opts.context.get(WebCtx) as WebToCloudCtx; // const uri = URI.from(window.location.href); // const uriFpToken = uri.getParam(redirectCtx.tokenParam); @@ -95,10 +96,8 @@ export class IframeStrategy implements TokenStrategie { } // eslint-disable-next-line @typescript-eslint/no-unused-vars - async waitForToken(sthis: SuperThis, logger: Logger, deviceId: string): Promise { + async waitForToken(sthis: SuperThis, logger: Logger, deviceId: string): Promise> { // throw new Error("waitForToken not implemented"); - return new Promise(() => { - /* */ - }); + return Result.Err("not implemented"); } } diff --git a/use-fireproof/index.ts b/use-fireproof/index.ts index 2e493ae8d..a4296cc69 100644 --- a/use-fireproof/index.ts +++ b/use-fireproof/index.ts @@ -12,13 +12,18 @@ import { TokenStrategie, TokenAndClaimsEvents, ToCloudAttachable, - TokenAndClaims, + TokenAndSelectedTenantAndLedger, } from "@fireproof/core-types-protocols-cloud"; import { WebToCloudCtx } from "./react/types.js"; import { defaultWebToCloudOpts, WebCtx } from "./react/use-attach.js"; import { toCloud as toCloudCore } from "@fireproof/core-gateways-cloud"; import { ensureSuperThis } from "@fireproof/core-runtime"; +export { FPCloudConnectStrategy } from "./fp-cloud-connect-strategy.js"; +export { convertToTokenAndClaims } from "@fireproof/cloud-connector-base"; + +export * as jsx from "./jsx-helper.js"; + export type UseFpToCloudParam = Omit, "context">, "events"> & Partial & { readonly strategy?: TokenStrategie; @@ -49,7 +54,7 @@ export function toCloud(opts: UseFpToCloudParam = {}): ToCloudAttachable { const webCtx = defaultWebToCloudOpts(myOpts); if (!opts.events) { // hacky but who has a better idea? - myOpts.events.changed = async (token?: TokenAndClaims) => { + myOpts.events.changed = async (token?: TokenAndSelectedTenantAndLedger) => { if (token) { await webCtx.setToken(token); } else { diff --git a/use-fireproof/jsx-helper.ts b/use-fireproof/jsx-helper.ts new file mode 100644 index 000000000..183068405 --- /dev/null +++ b/use-fireproof/jsx-helper.ts @@ -0,0 +1,6 @@ +export { renderToString } from "preact-render-to-string"; +import { createElement } from "preact"; + +export const React = { + createElement, +}; diff --git a/use-fireproof/overlay-html-defaults.tsx b/use-fireproof/overlay-html-defaults.tsx new file mode 100644 index 000000000..97be0956f --- /dev/null +++ b/use-fireproof/overlay-html-defaults.tsx @@ -0,0 +1,56 @@ +import { React, renderToString } from "./jsx-helper.js"; + +export function defaultOverlayHtml(redirectLink: string) { + return renderToString( +
+
×
+ Fireproof Dashboard Sign in to Fireproof Dashboard + + Redirect to Fireproof + +
, + ); +} + +export function defaultOverlayCss() { + return ` +.fpContainer { + position: relative; /* Needed for absolute positioning of the overlay */ +} + +.fpOverlay { + display: none; /* Initially hidden */ + position: fixed; /* Covers the whole viewport */ + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent background */ + z-index: 1; /* Ensure it's on top of other content */ +} + +.fpOverlayContent { + position: absolute; + // width: calc(100vw - 50px); + // height: calc(100vh - 50px); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); /* Center the content */ + // transform: translate(0%, 0%); /* Center the content */ + background-color: white; + color: black; + // margin: 10px; + padding: 20px; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); +} + +.fpCloseButton { + position: absolute; + top: 10px; + right: 15px; + font-size: 20px; + cursor: pointer; +} +`; +} diff --git a/use-fireproof/package.json b/use-fireproof/package.json index aa39a53de..94ebd7631 100644 --- a/use-fireproof/package.json +++ b/use-fireproof/package.json @@ -34,7 +34,10 @@ "@fireproof/vendor": "workspace:0.0.0", "dompurify": "^3.3.0", "jose": "^6.1.1", - "ts-essentials": "^10.1.1" + "preact": "^10.27.2", + "preact-render-to-string": "^6.6.2", + "ts-essentials": "^10.1.1", + "zod": "^4.1.12" }, "peerDependencies": { "@adviser/cement": ">=0.4.20", diff --git a/use-fireproof/react/types.ts b/use-fireproof/react/types.ts index e154f21eb..fe4a08abb 100644 --- a/use-fireproof/react/types.ts +++ b/use-fireproof/react/types.ts @@ -19,7 +19,7 @@ import type { KeyBagIf, IndexRowsWithDocs, } from "@fireproof/core-types-base"; -import { ToCloudAttachable, TokenAndClaims } from "@fireproof/core-types-protocols-cloud"; +import { ToCloudAttachable, TokenAndSelectedTenantAndLedger } from "@fireproof/core-types-protocols-cloud"; export type LiveQueryResult = IndexRowsWithDocs; @@ -110,7 +110,7 @@ export interface InitialTokenAndClaimsState { } export interface ReadyTokenAndClaimsState { readonly state: "ready"; - readonly tokenAndClaims: TokenAndClaims; + readonly tokenAndClaims: TokenAndSelectedTenantAndLedger; readonly reset: () => void; } @@ -130,10 +130,10 @@ export interface WebToCloudCtx { ready(db: Database): Promise; - onTokenChange(on: (token?: TokenAndClaims) => void): void; + onTokenChange(on: (token?: TokenAndSelectedTenantAndLedger) => void): void; resetToken(): Promise; - setToken(token: TokenAndClaims | string): Promise; - token(): Promise; + setToken(token: TokenAndSelectedTenantAndLedger | string): Promise; + token(): Promise; } export type UseFPConfig = ConfigOpts & { readonly attach?: ToCloudAttachable }; diff --git a/use-fireproof/react/use-attach.ts b/use-fireproof/react/use-attach.ts index 88d84e5ef..983e922a4 100644 --- a/use-fireproof/react/use-attach.ts +++ b/use-fireproof/react/use-attach.ts @@ -10,7 +10,7 @@ import { FPCloudClaim, ToCloudAttachable, ToCloudOptionalOpts, - TokenAndClaims, + TokenAndSelectedTenantAndLedger, TokenStrategie, } from "@fireproof/core-types-protocols-cloud"; import { getKeyBag } from "@fireproof/core-keybag"; @@ -23,7 +23,7 @@ export type ToCloudParam = Omit & Partial & { readonly strategy?: TokenStrategie; readonly context?: AppContext }; class WebCtxImpl implements WebToCloudCtx { - readonly onActions = new Set<(token?: TokenAndClaims) => void>(); + readonly onActions = new Set<(token?: TokenAndSelectedTenantAndLedger) => void>(); readonly dashboardURI!: string; readonly tokenApiURI!: string; // readonly uiURI: string; @@ -72,13 +72,13 @@ class WebCtxImpl implements WebToCloudCtx { this.keyBag = this.keyBag ?? (await getKeyBag(this.sthis)); } - async onAction(token?: TokenAndClaims) { + async onAction(token?: TokenAndSelectedTenantAndLedger) { for (const action of this.onActions.values()) { action(token); } } - onTokenChange(on: (token?: TokenAndClaims) => void) { + onTokenChange(on: (token?: TokenAndSelectedTenantAndLedger) => void) { if (this.opts.onTokenChange) { return this.opts.onTokenChange(on); } @@ -92,7 +92,7 @@ class WebCtxImpl implements WebToCloudCtx { }; } - readonly _tokenAndClaims = new ResolveOnce(); + readonly _tokenAndClaims = new ResolveOnce(); async token() { if (this.opts.token) { @@ -126,7 +126,7 @@ class WebCtxImpl implements WebToCloudCtx { this.onAction(); } - async setToken(token: TokenAndClaims) { + async setToken(token: TokenAndSelectedTenantAndLedger) { if (this.opts.setToken) { return this.opts.setToken(token); } diff --git a/use-fireproof/redirect-strategy.ts b/use-fireproof/redirect-strategy.ts index d02990e26..48994f726 100644 --- a/use-fireproof/redirect-strategy.ts +++ b/use-fireproof/redirect-strategy.ts @@ -1,66 +1,15 @@ -import { BuildURI, Lazy, Logger } from "@adviser/cement"; +import { BuildURI, Lazy, Logger, Result } from "@adviser/cement"; import { SuperThis } from "@fireproof/core-types-base"; import { decodeJwt } from "jose"; import DOMPurify from "dompurify"; -import { FPCloudClaim, ToCloudOpts, TokenAndClaims, TokenStrategie } from "@fireproof/core-types-protocols-cloud"; -import { Api } from "@fireproof/core-protocols-dashboard"; +import { FPCloudClaim, ToCloudOpts, TokenAndSelectedTenantAndLedger, TokenStrategie } from "@fireproof/core-types-protocols-cloud"; +import { DashApi } from "@fireproof/core-protocols-dashboard"; import { WebToCloudCtx } from "./react/types.js"; import { WebCtx } from "./react/use-attach.js"; import { hashObjectSync } from "@fireproof/core-runtime"; +import { defaultOverlayCss, defaultOverlayHtml } from "./overlay-html-defaults.js"; -function defaultOverlayHtml(redirectLink: string) { - return ` -
-
×
- Fireproof Dashboard - Sign in to Fireproof Dashboard - Redirect to Fireproof -
- `; -} - -const defaultOverlayCss = ` -.fpContainer { - position: relative; /* Needed for absolute positioning of the overlay */ -} - -.fpOverlay { - display: none; /* Initially hidden */ - position: fixed; /* Covers the whole viewport */ - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent background */ - z-index: 1; /* Ensure it's on top of other content */ -} - -.fpOverlayContent { - position: absolute; - // width: calc(100vw - 50px); - // height: calc(100vh - 50px); - top: 50%; - left: 50%; - transform: translate(-50%, -50%); /* Center the content */ - // transform: translate(0%, 0%); /* Center the content */ - background-color: white; - color: black; - // margin: 10px; - padding: 20px; - border-radius: 5px; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); -} - -.fpCloseButton { - position: absolute; - top: 10px; - right: 15px; - font-size: 20px; - cursor: pointer; -} -`; - -interface RedirectStrategyOpts { +export interface RedirectStrategyOpts { readonly overlayCss: string; readonly overlayHtml?: (redirectLink: string) => string; } @@ -74,7 +23,7 @@ export class RedirectStrategy implements TokenStrategie { readonly overlayHtml: (redirectLink: string) => string; constructor(opts: Partial = {}) { - this.overlayCss = opts.overlayCss ?? defaultOverlayCss; + this.overlayCss = opts.overlayCss ?? defaultOverlayCss(); this.overlayHtml = opts.overlayHtml ?? defaultOverlayHtml; } readonly hash = Lazy(() => @@ -148,7 +97,7 @@ export class RedirectStrategy implements TokenStrategie { // window.location.href = url.toString(); } - private currentToken?: TokenAndClaims; + private currentToken?: TokenAndSelectedTenantAndLedger; waiting?: ReturnType; @@ -160,7 +109,7 @@ export class RedirectStrategy implements TokenStrategie { this.waitState = "stopped"; } - async tryToken(sthis: SuperThis, logger: Logger, opts: ToCloudOpts): Promise { + async tryToken(sthis: SuperThis, logger: Logger, opts: ToCloudOpts): Promise { if (!this.currentToken) { const webCtx = opts.context.get(WebCtx) as WebToCloudCtx; this.currentToken = await webCtx.token(); @@ -171,10 +120,10 @@ export class RedirectStrategy implements TokenStrategie { async getTokenAndClaimsByResultId( logger: Logger, - dashApi: Api, + dashApi: DashApi, resultId: undefined | string, opts: ToCloudOpts, - resolve: (value: TokenAndClaims) => void, + resolve: (value: TokenAndSelectedTenantAndLedger) => void, attempts = 0, ) { if (!resultId) { @@ -203,17 +152,22 @@ export class RedirectStrategy implements TokenStrategie { this.waiting = setTimeout(() => this.getTokenAndClaimsByResultId(logger, dashApi, resultId, opts, resolve), opts.intervalSec); } - async waitForToken(sthis: SuperThis, logger: Logger, deviceId: string, opts: ToCloudOpts): Promise { + async waitForToken( + sthis: SuperThis, + logger: Logger, + deviceId: string, + opts: ToCloudOpts, + ): Promise> { if (!this.resultId) { throw new Error("waitForToken not working on redirect strategy"); } const webCtx = opts.context.get(WebCtx) as WebToCloudCtx; - const dashApi = new Api(webCtx.tokenApiURI); + const dashApi = new DashApi(sthis, webCtx.tokenApiURI); this.waitState = "started"; - return new Promise((resolve) => { + return new Promise>((resolve) => { this.getTokenAndClaimsByResultId(logger, dashApi, this.resultId, opts, (tokenAndClaims) => { this.currentToken = tokenAndClaims; - resolve(tokenAndClaims); + resolve(Result.Ok(tokenAndClaims)); }); }); } diff --git a/use-fireproof/window-open-fp-cloud.ts b/use-fireproof/window-open-fp-cloud.ts new file mode 100644 index 000000000..3de76f4d3 --- /dev/null +++ b/use-fireproof/window-open-fp-cloud.ts @@ -0,0 +1,100 @@ +import { FPCCEvtNeedsLogin } from "@fireproof/cloud-connector-base"; +import DOMPurify from "dompurify"; +import { ensureLogger, ensureSuperThis, hashObjectSync } from "@fireproof/core-runtime"; +import { Logger } from "@adviser/cement"; +import { defaultOverlayCss, defaultOverlayHtml } from "./overlay-html-defaults.js"; +import { SuperThis } from "@fireproof/core-types-base"; +import { RedirectStrategyOpts } from "./redirect-strategy.js"; +import { FPCloudFrontend } from "@fireproof/cloud-connector-page"; + +export interface FPCloudFrontendImplOpts extends RedirectStrategyOpts { + readonly sthis: SuperThis; + readonly title: string; +} + +export class FPCloudFrontendImpl implements FPCloudFrontend { + readonly logger: Logger; + readonly sthis: SuperThis; + readonly overlayCss: string; + readonly overlayHtml: (redirectLink: string) => string; + readonly title: string; + + overlayNode?: HTMLDivElement; + + constructor(readonly opts: Partial = {}) { + this.sthis = opts.sthis ?? ensureSuperThis(); + this.logger = ensureLogger(this.sthis, "FPCloudFrontendImpl"); + + this.overlayCss = opts.overlayCss ?? defaultOverlayCss(); + this.overlayHtml = opts.overlayHtml ?? defaultOverlayHtml; + this.title = opts.title ?? "Fireproof Login"; + } + + hash(): string { + return hashObjectSync({ + overlayCss: this.overlayCss, + overlayHtml: this.overlayHtml(""), + title: this.title, + }); + } + + stop(): void { + this.logger.Debug().Msg("stop called"); + this.overlayNode?.remove(); + this.overlayNode = undefined; + } + + openLogin(msg: FPCCEvtNeedsLogin): void { + // const redirectCtx = opts.context.get(WebCtx) as WebToCloudCtx; + this.logger.Debug().Url(msg.loginURL).Msg("open redirect"); + + let overlayNode = document.body.querySelector("#fpOverlay") as HTMLDivElement; + if (!overlayNode) { + const styleNode = document.createElement("style"); + styleNode.innerHTML = DOMPurify.sanitize(this.overlayCss); + document.head.appendChild(styleNode); + overlayNode = document.createElement("div") as HTMLDivElement; + overlayNode.id = "fpOverlay"; + overlayNode.className = "fpOverlay"; + const myHtml = this.overlayHtml(msg.loginURL); + console.log("FPCloudConnectStrategy openFireproofLogin creating overlay with html", myHtml); + overlayNode.innerHTML = DOMPurify.sanitize(myHtml); + document.body.appendChild(overlayNode); + overlayNode.querySelector(".fpCloseButton")?.addEventListener("click", () => { + if (overlayNode) { + if (overlayNode.style.display === "block") { + overlayNode.style.display = "none"; + this.stop(); + } else { + overlayNode.style.display = "block"; + } + } + }); + } + overlayNode.style.display = "block"; + this.overlayNode = overlayNode; + const width = 800; + const height = 600; + const parentScreenX = window.screenX || window.screenLeft; // Cross-browser compatibility + const parentScreenY = window.screenY || window.screenTop; // Cross-browser compatibility + + // Get the parent window's outer dimensions (including chrome) + const parentOuterWidth = window.outerWidth; + const parentOuterHeight = window.outerHeight; + + // Calculate the left position for the new window + // Midpoint of parent window's width - half of new window's width + const left = parentScreenX + parentOuterWidth / 2 - width / 2; + + // Calculate the top position for the new window + // Midpoint of parent window's height - half of new window's height + const top = parentScreenY + parentOuterHeight / 2 - height / 2; + + window.open( + // eslint-disable-next-line no-restricted-globals + new URL(msg.loginURL), + this.title, + `left=${left},top=${top},width=${width},height=${height},scrollbars=yes,resizable=yes,popup=yes`, + ); + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 4d2933683..a2c3b9873 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ "cloud/backend/cf-d1/vitest.config.ts", "cloud/backend/node/vitest.config.ts", "cloud/backend/base/vitest.config.ts", + "cloud/connector/test/vitest.config.ts", "cli/vitest.config.ts", "dashboard/vitest.config.ts", ],