Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions core/protocols/dashboard/msg-is.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 } {
Expand Down Expand Up @@ -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");
}
}
19 changes: 19 additions & 0 deletions core/protocols/dashboard/msg-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
184 changes: 149 additions & 35 deletions dashboard/backend/api.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -26,6 +27,7 @@ import {
ReqListLedgersByUser,
ReqListTenantsByUser,
ReqRedeemInvite,
ReqShareWithUser,
ReqTokenByResultId,
ReqUpdateLedger,
ReqUpdateTenant,
Expand All @@ -44,6 +46,7 @@ import {
ResListLedgersByUser,
ResListTenantsByUser,
ResRedeemInvite,
ResShareWithUser,
ResTokenByResultId,
ResUpdateLedger,
ResUpdateTenant,
Expand All @@ -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 {
Expand Down Expand Up @@ -112,6 +114,7 @@ export interface FPApiInterface {
listLedgersByUser(req: ReqListLedgersByUser): Promise<Result<ResListLedgersByUser>>;
updateLedger(req: ReqUpdateLedger): Promise<Result<ResUpdateLedger>>;
deleteLedger(req: ReqDeleteLedger): Promise<Result<ResDeleteLedger>>;
shareWithUser(req: ReqShareWithUser): Promise<Result<ResShareWithUser>>;

// listLedgersByTenant(req: ReqListLedgerByTenant): Promise<ResListLedgerByTenant>

Expand Down Expand Up @@ -1778,6 +1781,109 @@ export class FPApiSQL implements FPApiInterface {
ledgerId: req.ledger.ledgerId,
});
}

async shareWithUser(req: ReqShareWithUser, ictx: Partial<FPTokenContext> = {}): Promise<Result<ResShareWithUser>> {
// 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",
});
}
Comment on lines +1785 to +1885
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add maxShares validation before sharing.

The shareWithUser method doesn't check the ledger's maxShares limit (defined in the sqlLedgers schema) before adding a new user. While addUserToLedger checks for duplicate users (lines 470-493), it doesn't validate against maxShares. The checkMaxRoles call validates tenant-level role limits, not ledger-level share limits.

Add a check after line 1756 to count existing active users on the ledger and reject if it would exceed maxShares:

    // Check maxShares limit
    const activeUsers = await this.db
      .select()
      .from(sqlLedgerUsers)
      .where(
        and(
          eq(sqlLedgerUsers.ledgerId, ledgerId),
          eq(sqlLedgerUsers.status, "active")
        )
      )
      .all();
    
    if (ledger.maxShares && activeUsers.length >= ledger.maxShares) {
      return Result.Err("Ledger share limit reached. Maximum shares exceeded.");
    }
🤖 Prompt for AI Agents
In dashboard/backend/api.ts around lines 1716 to 1816 (add the new check after
line 1756), add a pre-share validation that queries sqlLedgerUsers for active
users on the ledger and compares the count against ledger.maxShares; if
ledger.maxShares is set and the active count is already >= maxShares, return a
Result.Err indicating the ledger share limit has been reached. Ensure the DB
query filters by ledgerId and status="active" and use the existing Result.Err
format/message consistent with other errors.


async listLedgersByUser(req: ReqListLedgersByUser): Promise<Result<ResListLedgersByUser>> {
const rAuth = await this.activeUser(req);
if (rAuth.isErr()) {
Expand Down Expand Up @@ -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<FPTokenContext> = {}): Promise<Result<ResExtendToken>> {
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<Result<FPCloudClaim>> {
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,
});
Expand All @@ -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<FPTokenContext> = {}): Promise<Result<ResExtendToken>> {
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,
});
}
}

Expand Down
4 changes: 2 additions & 2 deletions dashboard/backend/cf-serve.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
12 changes: 8 additions & 4 deletions dashboard/backend/create-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -301,6 +301,10 @@ export async function createHandler<T extends DashSqlite>(db: T, env: Record<str
res = fpApi.extendToken(jso);
break;

case FPAPIMsg.isReqShareWithUser(jso):
res = fpApi.shareWithUser(jso);
break;

default:
return new Response("Invalid request", { status: 400, headers: DefaultHttpHeaders() });
}
Expand Down
Loading