diff --git a/core/protocols/dashboard/msg-is.ts b/core/protocols/dashboard/msg-is.ts index 12da46b2a..e08487cbb 100644 --- a/core/protocols/dashboard/msg-is.ts +++ b/core/protocols/dashboard/msg-is.ts @@ -1,23 +1,24 @@ import { - ReqDeleteTenant, - ReqUpdateTenant, + ReqCloudSessionToken, + ReqCreateLedger, ReqCreateTenant, ReqDeleteInvite, - ReqListInvites, - ReqInviteUser, - ReqFindUser, - ReqRedeemInvite, + ReqDeleteLedger, + ReqDeleteTenant, ReqEnsureUser, + ReqExtendToken, + ReqFindUser, + ReqInviteUser, + ReqListInvites, + ReqListLedgersByUser, ReqListTenantsByUser, - ReqUpdateUserTenant, - ReqCloudSessionToken, + ReqRedeemInvite, + ReqShareWithUser, ReqTokenByResultId, - ReqListLedgersByUser, - ReqCreateLedger, ReqUpdateLedger, - ReqDeleteLedger, + ReqUpdateTenant, + ReqUpdateUserTenant, ResTokenByResultId, - ReqExtendToken, } from "./msg-types.js"; interface FPApiMsgInterface { @@ -40,6 +41,7 @@ interface FPApiMsgInterface { isUpdateLedger(jso: unknown): jso is ReqUpdateLedger; isDeleteLedger(jso: unknown): jso is ReqDeleteLedger; isReqExtendToken(jso: unknown): jso is ReqExtendToken; + isReqShareWithUser(jso: unknown): jso is ReqShareWithUser; } function hasType(jso: unknown, t: string): jso is { type: string } { @@ -103,4 +105,7 @@ export class FAPIMsgImpl implements FPApiMsgInterface { isReqExtendToken(jso: unknown): jso is ReqExtendToken { return hasType(jso, "reqExtendToken"); } + isReqShareWithUser(jso: unknown): jso is ReqShareWithUser { + return hasType(jso, "reqShareWithUser"); + } } diff --git a/core/protocols/dashboard/msg-types.ts b/core/protocols/dashboard/msg-types.ts index 75d039dd5..6947ce50e 100644 --- a/core/protocols/dashboard/msg-types.ts +++ b/core/protocols/dashboard/msg-types.ts @@ -404,6 +404,25 @@ export interface ResDeleteLedger { readonly type: "resDeleteLedger"; } +export interface ReqShareWithUser { + readonly type: "reqShareWithUser"; + readonly auth: AuthType; + readonly email: string; + readonly role?: Role; + readonly right?: ReadWrite; +} + +export interface ResShareWithUser { + readonly type: "resShareWithUser"; + readonly success: boolean; + readonly message: string; + readonly ledgerId: string; + readonly userId: string; + readonly email: string; + readonly role: Role; + readonly right: ReadWrite; +} + export interface ReqCloudSessionToken { readonly type: "reqCloudSessionToken"; readonly auth: AuthType; diff --git a/dashboard/backend/api.ts b/dashboard/backend/api.ts index b6a11cd4c..f7aad533b 100644 --- a/dashboard/backend/api.ts +++ b/dashboard/backend/api.ts @@ -1,12 +1,13 @@ 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 { and, eq, gt, gte, inArray, lt, ne, or } from "drizzle-orm/sql/expressions"; // import type { LibSQLDatabase } from "drizzle-orm/libsql"; -import { jwtVerify } from "jose"; import { AuthType, ClerkClaim, ClerkVerifyAuth, + FAPIMsgImpl, + FPApiParameters, InCreateTenantParams, InviteTicket, InvitedParams, @@ -26,6 +27,7 @@ import { ReqListLedgersByUser, ReqListTenantsByUser, ReqRedeemInvite, + ReqShareWithUser, ReqTokenByResultId, ReqUpdateLedger, ReqUpdateTenant, @@ -44,6 +46,7 @@ import { ResListLedgersByUser, ResListTenantsByUser, ResRedeemInvite, + ResShareWithUser, ResTokenByResultId, ResUpdateLedger, ResUpdateTenant, @@ -52,19 +55,18 @@ import { User, UserStatus, VerifiedAuth, - FAPIMsgImpl, - FPApiParameters, } from "@fireproof/core-protocols-dashboard"; +import { sts } from "@fireproof/core-runtime"; +import { FPCloudClaim, ReadWrite, Role, toReadWrite, toRole } from "@fireproof/core-types-protocols-cloud"; +import { jwtVerify } from "jose"; +import { FPTokenContext, createFPToken, getFPTokenContext } from "./create-fp-token.js"; +import { DashSqlite } from "./create-handler.js"; import { prepareInviteTicket, sqlInviteTickets, sqlToInviteTickets } from "./invites.js"; import { sqlLedgerUsers, sqlLedgers, sqlToLedgers } from "./ledgers.js"; import { queryCondition, queryEmail, queryNick, toBoolean, toUndef } from "./sql-helper.js"; import { sqlTenantUsers, sqlTenants } from "./tenants.js"; import { sqlTokenByResultId } from "./token-by-result-id.js"; import { UserNotFoundError, getUser, isUserNotFound, queryUser, sqlUsers, 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 { sts } from "@fireproof/core-runtime"; -import { DashSqlite } from "./create-handler.js"; import { getTableColumns } from "drizzle-orm/utils"; function sqlToOutTenantParams(sql: typeof sqlTenants.$inferSelect): OutTenantParams { @@ -112,6 +114,7 @@ export interface FPApiInterface { listLedgersByUser(req: ReqListLedgersByUser): Promise>; updateLedger(req: ReqUpdateLedger): Promise>; deleteLedger(req: ReqDeleteLedger): Promise>; + shareWithUser(req: ReqShareWithUser): Promise>; // listLedgersByTenant(req: ReqListLedgerByTenant): Promise @@ -1778,6 +1781,109 @@ export class FPApiSQL implements FPApiInterface { ledgerId: req.ledger.ledgerId, }); } + + async shareWithUser(req: ReqShareWithUser, ictx: Partial = {}): Promise> { + // 1. Get JWT context and verify token + const rCtx = await getFPTokenContext(this.sthis, ictx); + if (rCtx.isErr()) { + return Result.Err(rCtx.Err()); + } + const ctx = rCtx.Ok(); + + const rPayload = await this.verifyFPToken(req.auth.token, ctx); + if (rPayload.isErr()) { + return Result.Err(rPayload.Err()); + } + const payload = rPayload.Ok(); + + // 2. Extract user ID and ledger ID from token + if (!payload.userId) { + return Result.Err("No user ID in token"); + } + + if (!payload.selected?.ledger) { + return Result.Err("No ledger selected in token"); + } + + const userId = payload.userId; + const ledgerId = payload.selected.ledger; + + // 3. Check if user is admin of the ledger + if (!(await this.isAdminOfLedger(userId, ledgerId))) { + return Result.Err("Not authorized to share this ledger. Admin access required."); + } + + // 4. Get ledger details + const ledger = await this.db + .select() + .from(sqlLedgers) + .where(and(eq(sqlLedgers.ledgerId, ledgerId), eq(sqlLedgers.status, "active"))) + .get(); + + if (!ledger) { + return Result.Err("Ledger not found or inactive"); + } + + // 5. Find user by email + const rUser = await queryUser(this.db, { byString: req.email }); + if (rUser.isErr()) { + return Result.Err(rUser.Err()); + } + + const users = rUser.Ok(); + if (users.length === 0) { + return Result.Err(`User with email ${req.email} not found. User must sign up first.`); + } + + if (users.length > 1) { + return Result.Err(`Multiple users found for email ${req.email}`); + } + + const targetUser = users[0]; + + // Prevent self-sharing + if (targetUser.userId === userId) { + return Result.Err("Cannot share ledger with yourself"); + } + + // 6. Add user to tenant first + const rAddUserToTenant = await this.addUserToTenant(this.db, { + userName: req.email, + tenantId: ledger.tenantId, + userId: targetUser.userId, + role: "member", + }); + + if (rAddUserToTenant.isErr()) { + return Result.Err(rAddUserToTenant.Err()); + } + + // 7. Add user to ledger + const rAddUser = await this.addUserToLedger(this.db, { + userName: req.email, + ledgerId: ledgerId, + tenantId: ledger.tenantId, + userId: targetUser.userId, + role: req.role || "member", + right: req.right || "read", + }); + + if (rAddUser.isErr()) { + return Result.Err(rAddUser.Err()); + } + + return Result.Ok({ + type: "resShareWithUser", + success: true, + message: `Successfully shared ledger with ${req.email}`, + ledgerId: ledgerId, + userId: targetUser.userId, + email: req.email, + role: req.role || "member", + right: req.right || "write", + }); + } + async listLedgersByUser(req: ReqListLedgersByUser): Promise> { const rAuth = await this.activeUser(req); if (rAuth.isErr()) { @@ -1961,21 +2067,10 @@ export class FPApiSQL implements FPApiInterface { }); } - /** - * Extract token from request, validate it, and extend expiry by 1 day - */ - async extendToken(req: ReqExtendToken, ictx: Partial = {}): Promise> { - const rCtx = await getFPTokenContext(this.sthis, ictx); - if (rCtx.isErr()) { - return Result.Err(rCtx.Err()); - } - const ctx = rCtx.Ok(); + private async verifyFPToken(token: string, ctx: FPTokenContext): Promise> { try { - // Get the public key for verification const pubKey = await sts.env2jwk(ctx.publicToken, "ES256"); - - // Verify the token - const verifyResult = await jwtVerify(req.token, pubKey, { + const verifyResult = await jwtVerify(token, pubKey, { issuer: ctx.issuer, audience: ctx.audience, }); @@ -1986,22 +2081,41 @@ export class FPApiSQL implements FPApiInterface { if (!payload.exp || payload.exp * 1000 <= now) { return Result.Err("Token is expired"); } - // Create new token with extended expiry using the private key - // JWT expects expiration time in seconds, not milliseconds - const newToken = await createFPToken( - { - ...ctx, - validFor: ctx.extendValidFor, - }, - payload, - ); - return Result.Ok({ - type: "resExtendToken", - token: newToken, - }); + + return Result.Ok(payload); } catch (error) { - return Result.Err(`Token validation failed: ${error instanceof Error ? error.message : String(error)}`); + return Result.Err(`Token verification failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Extract token from request, validate it, and extend expiry by 1 day + */ + async extendToken(req: ReqExtendToken, ictx: Partial = {}): Promise> { + const rCtx = await getFPTokenContext(this.sthis, ictx); + if (rCtx.isErr()) { + return Result.Err(rCtx.Err()); + } + const ctx = rCtx.Ok(); + + const rPayload = await this.verifyFPToken(req.token, ctx); + if (rPayload.isErr()) { + return Result.Err(rPayload.Err()); } + + // Create new token with extended expiry + const newToken = await createFPToken( + { + ...ctx, + validFor: ctx.extendValidFor, + }, + rPayload.Ok(), + ); + + return Result.Ok({ + type: "resExtendToken", + token: newToken, + }); } } diff --git a/dashboard/backend/cf-serve.ts b/dashboard/backend/cf-serve.ts index e8f651c49..6aa026789 100644 --- a/dashboard/backend/cf-serve.ts +++ b/dashboard/backend/cf-serve.ts @@ -1,7 +1,7 @@ +import { URI } from "@adviser/cement"; +import { Request as CFRequest, Response as CFResponse, D1Database, Fetcher } from "@cloudflare/workers-types"; import { drizzle } from "drizzle-orm/d1"; -import { D1Database, Fetcher, Request as CFRequest, Response as CFResponse } from "@cloudflare/workers-types"; import { DefaultHttpHeaders, createHandler } from "./create-handler.js"; -import { URI } from "@adviser/cement"; import { resWellKnownJwks } from "./well-known-jwks.js"; export interface Env { diff --git a/dashboard/backend/create-handler.ts b/dashboard/backend/create-handler.ts index 872da916e..768f4633b 100644 --- a/dashboard/backend/create-handler.ts +++ b/dashboard/backend/create-handler.ts @@ -3,12 +3,12 @@ import { CoercedHeadersInit, HttpHeader, Lazy, LoggerImpl, Result, exception2Res import { verifyToken } from "@clerk/backend"; import { verifyJwt } from "@clerk/backend/jwt"; import { SuperThis, SuperThisOpts } from "@fireproof/core"; -import { FPAPIMsg, FPApiSQL, FPApiToken } from "./api.js"; -import type { Env } from "./cf-serve.js"; import { VerifiedAuth } from "@fireproof/core-protocols-dashboard"; -import { ensureSuperThis, ensureLogger, coerceInt } from "@fireproof/core-runtime"; -import { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"; +import { coerceInt, ensureLogger, ensureSuperThis } from "@fireproof/core-runtime"; import { ResultSet } from "@libsql/client"; +import { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"; +import { FPAPIMsg, FPApiSQL, FPApiToken } from "./api.js"; +import type { Env } from "./cf-serve.js"; import { getCloudPubkeyFromEnv } from "./get-cloud-pubkey-from-env.js"; // import { jwtVerify } from "jose/jwt/verify"; // import { JWK } from "jose"; @@ -301,6 +301,10 @@ export async function createHandler(db: T, env: Record