Skip to content

Commit 803b88b

Browse files
committed
feat: add shareWithUser endpoint
1 parent 8778561 commit 803b88b

File tree

4 files changed

+192
-50
lines changed

4 files changed

+192
-50
lines changed

core/protocols/dashboard/msg-is.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,24 @@
11
import {
2-
ReqDeleteTenant,
3-
ReqUpdateTenant,
2+
ReqCloudSessionToken,
3+
ReqCreateLedger,
44
ReqCreateTenant,
55
ReqDeleteInvite,
6-
ReqListInvites,
7-
ReqInviteUser,
8-
ReqFindUser,
9-
ReqRedeemInvite,
6+
ReqDeleteLedger,
7+
ReqDeleteTenant,
108
ReqEnsureUser,
9+
ReqExtendToken,
10+
ReqFindUser,
11+
ReqInviteUser,
12+
ReqListInvites,
13+
ReqListLedgersByUser,
1114
ReqListTenantsByUser,
12-
ReqUpdateUserTenant,
13-
ReqCloudSessionToken,
15+
ReqRedeemInvite,
16+
ReqShareWithUser,
1417
ReqTokenByResultId,
15-
ReqListLedgersByUser,
16-
ReqCreateLedger,
1718
ReqUpdateLedger,
18-
ReqDeleteLedger,
19+
ReqUpdateTenant,
20+
ReqUpdateUserTenant,
1921
ResTokenByResultId,
20-
ReqExtendToken,
2122
} from "./msg-types.js";
2223

2324
interface FPApiMsgInterface {
@@ -40,6 +41,7 @@ interface FPApiMsgInterface {
4041
isUpdateLedger(jso: unknown): jso is ReqUpdateLedger;
4142
isDeleteLedger(jso: unknown): jso is ReqDeleteLedger;
4243
isReqExtendToken(jso: unknown): jso is ReqExtendToken;
44+
isReqShareWithUser(jso: unknown): jso is ReqShareWithUser;
4345
}
4446

4547
function hasType(jso: unknown, t: string): jso is { type: string } {
@@ -103,4 +105,7 @@ export class FAPIMsgImpl implements FPApiMsgInterface {
103105
isReqExtendToken(jso: unknown): jso is ReqExtendToken {
104106
return hasType(jso, "reqExtendToken");
105107
}
108+
isReqShareWithUser(jso: unknown): jso is ReqShareWithUser {
109+
return hasType(jso, "reqShareWithUser");
110+
}
106111
}

core/protocols/dashboard/msg-types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,25 @@ export interface ResDeleteLedger {
405405
readonly type: "resDeleteLedger";
406406
}
407407

408+
export interface ReqShareWithUser {
409+
readonly type: "reqShareWithUser";
410+
readonly auth: AuthType;
411+
readonly email: string;
412+
readonly role?: Role;
413+
readonly right?: ReadWrite;
414+
}
415+
416+
export interface ResShareWithUser {
417+
readonly type: "resShareWithUser";
418+
readonly success: boolean;
419+
readonly message: string;
420+
readonly ledgerId: string;
421+
readonly userId: string;
422+
readonly email: string;
423+
readonly role: Role;
424+
readonly right: ReadWrite;
425+
}
426+
408427
export interface ReqCloudSessionToken {
409428
readonly type: "reqCloudSessionToken";
410429
readonly auth: AuthType;

dashboard/backend/api.ts

Lines changed: 148 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { Result } from "@adviser/cement";
22
import { SuperThis } from "@fireproof/core";
3-
import { gte, and, eq, gt, inArray, lt, ne, or } from "drizzle-orm/sql/expressions";
3+
import { and, eq, gt, gte, inArray, lt, ne, or } from "drizzle-orm/sql/expressions";
44
// import type { LibSQLDatabase } from "drizzle-orm/libsql";
5-
import { jwtVerify } from "jose";
65
import {
76
AuthType,
87
ClerkClaim,
98
ClerkVerifyAuth,
9+
FAPIMsgImpl,
1010
InCreateTenantParams,
1111
InviteTicket,
1212
InvitedParams,
@@ -26,6 +26,7 @@ import {
2626
ReqListLedgersByUser,
2727
ReqListTenantsByUser,
2828
ReqRedeemInvite,
29+
ReqShareWithUser,
2930
ReqTokenByResultId,
3031
ReqUpdateLedger,
3132
ReqUpdateTenant,
@@ -44,6 +45,7 @@ import {
4445
ResListLedgersByUser,
4546
ResListTenantsByUser,
4647
ResRedeemInvite,
48+
ResShareWithUser,
4749
ResTokenByResultId,
4850
ResUpdateLedger,
4951
ResUpdateTenant,
@@ -52,18 +54,18 @@ import {
5254
User,
5355
UserStatus,
5456
VerifiedAuth,
55-
FAPIMsgImpl,
5657
} from "@fireproof/core-protocols-dashboard";
58+
import { sts } from "@fireproof/core-runtime";
59+
import { FPCloudClaim, ReadWrite, Role, toReadWrite, toRole } from "@fireproof/core-types-protocols-cloud";
60+
import { jwtVerify } from "jose";
61+
import { FPTokenContext, createFPToken, getFPTokenContext } from "./create-fp-token.js";
62+
import { DashSqlite } from "./create-handler.js";
5763
import { prepareInviteTicket, sqlInviteTickets, sqlToInviteTickets } from "./invites.js";
5864
import { sqlLedgerUsers, sqlLedgers, sqlToLedgers } from "./ledgers.js";
5965
import { queryCondition, queryEmail, queryNick, toBoolean, toUndef } from "./sql-helper.js";
6066
import { sqlTenantUsers, sqlTenants } from "./tenants.js";
6167
import { sqlTokenByResultId } from "./token-by-result-id.js";
6268
import { UserNotFoundError, getUser, isUserNotFound, queryUser, upsetUserByProvider } from "./users.js";
63-
import { createFPToken, FPTokenContext, getFPTokenContext } from "./create-fp-token.js";
64-
import { Role, ReadWrite, toRole, toReadWrite, FPCloudClaim } from "@fireproof/core-types-protocols-cloud";
65-
import { sts } from "@fireproof/core-runtime";
66-
import { DashSqlite } from "./create-handler.js";
6769

6870
function sqlToOutTenantParams(sql: typeof sqlTenants.$inferSelect): OutTenantParams {
6971
return {
@@ -110,6 +112,7 @@ export interface FPApiInterface {
110112
listLedgersByUser(req: ReqListLedgersByUser): Promise<Result<ResListLedgersByUser>>;
111113
updateLedger(req: ReqUpdateLedger): Promise<Result<ResUpdateLedger>>;
112114
deleteLedger(req: ReqDeleteLedger): Promise<Result<ResDeleteLedger>>;
115+
shareWithUser(req: ReqShareWithUser): Promise<Result<ResShareWithUser>>;
113116

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

@@ -1709,6 +1712,109 @@ export class FPApiSQL implements FPApiInterface {
17091712
ledgerId: req.ledger.ledgerId,
17101713
});
17111714
}
1715+
1716+
async shareWithUser(req: ReqShareWithUser, ictx: Partial<FPTokenContext> = {}): Promise<Result<ResShareWithUser>> {
1717+
// 1. Get JWT context and verify token
1718+
const rCtx = await getFPTokenContext(this.sthis, ictx);
1719+
if (rCtx.isErr()) {
1720+
return Result.Err(rCtx.Err());
1721+
}
1722+
const ctx = rCtx.Ok();
1723+
1724+
const rPayload = await this.verifyFPToken(req.auth.token, ctx);
1725+
if (rPayload.isErr()) {
1726+
return Result.Err(rPayload.Err());
1727+
}
1728+
const payload = rPayload.Ok();
1729+
1730+
// 2. Extract user ID and ledger ID from token
1731+
if (!payload.userId) {
1732+
return Result.Err("No user ID in token");
1733+
}
1734+
1735+
if (!payload.selected?.ledger) {
1736+
return Result.Err("No ledger selected in token");
1737+
}
1738+
1739+
const userId = payload.userId;
1740+
const ledgerId = payload.selected.ledger;
1741+
1742+
// 3. Check if user is admin of the ledger
1743+
if (!(await this.isAdminOfLedger(userId, ledgerId))) {
1744+
return Result.Err("Not authorized to share this ledger. Admin access required.");
1745+
}
1746+
1747+
// 4. Get ledger details
1748+
const ledger = await this.db
1749+
.select()
1750+
.from(sqlLedgers)
1751+
.where(and(eq(sqlLedgers.ledgerId, ledgerId), eq(sqlLedgers.status, "active")))
1752+
.get();
1753+
1754+
if (!ledger) {
1755+
return Result.Err("Ledger not found or inactive");
1756+
}
1757+
1758+
// 5. Find user by email
1759+
const rUser = await queryUser(this.db, { byString: req.email });
1760+
if (rUser.isErr()) {
1761+
return Result.Err(rUser.Err());
1762+
}
1763+
1764+
const users = rUser.Ok();
1765+
if (users.length === 0) {
1766+
return Result.Err(`User with email ${req.email} not found. User must sign up first.`);
1767+
}
1768+
1769+
if (users.length > 1) {
1770+
return Result.Err(`Multiple users found for email ${req.email}`);
1771+
}
1772+
1773+
const targetUser = users[0];
1774+
1775+
// Prevent self-sharing
1776+
if (targetUser.userId === userId) {
1777+
return Result.Err("Cannot share ledger with yourself");
1778+
}
1779+
1780+
// 6. Add user to tenant first
1781+
const rAddUserToTenant = await this.addUserToTenant(this.db, {
1782+
userName: req.email,
1783+
tenantId: ledger.tenantId,
1784+
userId: targetUser.userId,
1785+
role: "member",
1786+
});
1787+
1788+
if (rAddUserToTenant.isErr()) {
1789+
return Result.Err(rAddUserToTenant.Err());
1790+
}
1791+
1792+
// 7. Add user to ledger
1793+
const rAddUser = await this.addUserToLedger(this.db, {
1794+
userName: req.email,
1795+
ledgerId: ledgerId,
1796+
tenantId: ledger.tenantId,
1797+
userId: targetUser.userId,
1798+
role: req.role || "member",
1799+
right: req.right || "read",
1800+
});
1801+
1802+
if (rAddUser.isErr()) {
1803+
return Result.Err(rAddUser.Err());
1804+
}
1805+
1806+
return Result.Ok({
1807+
type: "resShareWithUser",
1808+
success: true,
1809+
message: `Successfully shared ledger with ${req.email}`,
1810+
ledgerId: ledgerId,
1811+
userId: targetUser.userId,
1812+
email: req.email,
1813+
role: req.role || "member",
1814+
right: req.right || "write",
1815+
});
1816+
}
1817+
17121818
async listLedgersByUser(req: ReqListLedgersByUser): Promise<Result<ResListLedgersByUser>> {
17131819
const rAuth = await this.activeUser(req);
17141820
if (rAuth.isErr()) {
@@ -1899,21 +2005,10 @@ export class FPApiSQL implements FPApiInterface {
18992005
});
19002006
}
19012007

1902-
/**
1903-
* Extract token from request, validate it, and extend expiry by 1 day
1904-
*/
1905-
async extendToken(req: ReqExtendToken, ictx: Partial<FPTokenContext> = {}): Promise<Result<ResExtendToken>> {
1906-
const rCtx = await getFPTokenContext(this.sthis, ictx);
1907-
if (rCtx.isErr()) {
1908-
return Result.Err(rCtx.Err());
1909-
}
1910-
const ctx = rCtx.Ok();
2008+
private async verifyFPToken(token: string, ctx: FPTokenContext): Promise<Result<FPCloudClaim>> {
19112009
try {
1912-
// Get the public key for verification
19132010
const pubKey = await sts.env2jwk(ctx.publicToken, "ES256");
1914-
1915-
// Verify the token
1916-
const verifyResult = await jwtVerify(req.token, pubKey, {
2011+
const verifyResult = await jwtVerify(token, pubKey, {
19172012
issuer: ctx.issuer,
19182013
audience: ctx.audience,
19192014
});
@@ -1924,22 +2019,41 @@ export class FPApiSQL implements FPApiInterface {
19242019
if (!payload.exp || payload.exp * 1000 <= now) {
19252020
return Result.Err("Token is expired");
19262021
}
1927-
// Create new token with extended expiry using the private key
1928-
// JWT expects expiration time in seconds, not milliseconds
1929-
const newToken = await createFPToken(
1930-
{
1931-
...ctx,
1932-
validFor: ctx.extendValidFor,
1933-
},
1934-
payload,
1935-
);
1936-
return Result.Ok({
1937-
type: "resExtendToken",
1938-
token: newToken,
1939-
});
2022+
2023+
return Result.Ok(payload);
19402024
} catch (error) {
1941-
return Result.Err(`Token validation failed: ${error instanceof Error ? error.message : String(error)}`);
2025+
return Result.Err(`Token verification failed: ${error instanceof Error ? error.message : String(error)}`);
2026+
}
2027+
}
2028+
2029+
/**
2030+
* Extract token from request, validate it, and extend expiry by 1 day
2031+
*/
2032+
async extendToken(req: ReqExtendToken, ictx: Partial<FPTokenContext> = {}): Promise<Result<ResExtendToken>> {
2033+
const rCtx = await getFPTokenContext(this.sthis, ictx);
2034+
if (rCtx.isErr()) {
2035+
return Result.Err(rCtx.Err());
2036+
}
2037+
const ctx = rCtx.Ok();
2038+
2039+
const rPayload = await this.verifyFPToken(req.token, ctx);
2040+
if (rPayload.isErr()) {
2041+
return Result.Err(rPayload.Err());
19422042
}
2043+
2044+
// Create new token with extended expiry
2045+
const newToken = await createFPToken(
2046+
{
2047+
...ctx,
2048+
validFor: ctx.extendValidFor,
2049+
},
2050+
rPayload.Ok(),
2051+
);
2052+
2053+
return Result.Ok({
2054+
type: "resExtendToken",
2055+
token: newToken,
2056+
});
19432057
}
19442058
}
19452059

dashboard/backend/create-handler.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import { LoggerImpl, Result, exception2Result, param } from "@adviser/cement";
33
import { verifyToken } from "@clerk/backend";
44
import { verifyJwt } from "@clerk/backend/jwt";
55
import { SuperThis, SuperThisOpts } from "@fireproof/core";
6-
import { FPAPIMsg, FPApiSQL, FPApiToken } from "./api.js";
7-
import type { Env } from "./cf-serve.js";
86
import { VerifiedAuth } from "@fireproof/core-protocols-dashboard";
9-
import { ensureSuperThis, ensureLogger } from "@fireproof/core-runtime";
10-
import { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
7+
import { ensureLogger, ensureSuperThis } from "@fireproof/core-runtime";
118
import { ResultSet } from "@libsql/client";
9+
import { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core";
10+
import { FPAPIMsg, FPApiSQL, FPApiToken } from "./api.js";
11+
import type { Env } from "./cf-serve.js";
1212
// import { jwtVerify } from "jose/jwt/verify";
1313
// import { JWK } from "jose";
1414

@@ -272,6 +272,10 @@ export function createHandler<T extends DashSqlite>(db: T, env: Record<string, s
272272
res = fpApi.extendToken(jso);
273273
break;
274274

275+
case FPAPIMsg.isReqShareWithUser(jso):
276+
res = fpApi.shareWithUser(jso);
277+
break;
278+
275279
default:
276280
return new Response("Invalid request", { status: 400, headers: CORS });
277281
}

0 commit comments

Comments
 (0)