diff --git a/dashboard/backend/api.ts b/dashboard/backend/api.ts index 2fe54ae73..aeac55f9b 100644 --- a/dashboard/backend/api.ts +++ b/dashboard/backend/api.ts @@ -154,7 +154,16 @@ export class FPApiSQL implements FPApiInterface { getCertFromCsr(req: ReqCertFromCsr): Promise> { return this.#checkAuth(req, (req) => getCertFromCsr(this.ctx, req)); } - ensureCloudToken(req: ReqEnsureCloudToken, ictx: Partial = {}): Promise> { + async ensureCloudToken(req: ReqEnsureCloudToken, ictx: Partial = {}): Promise> { + // p0.46 - For new users, ensureUser must be called first to create them + // This allows invited users to access shared ledgers on first visit + console.log("[ensureCloudToken p0.46] starting for appId:", req.appId); + const rUser = await ensureUser(this.ctx, { type: "reqEnsureUser", auth: req.auth }); + if (rUser.isErr()) { + console.log("[ensureCloudToken p0.46] ensureUser failed:", rUser.Err()); + return Result.Err(rUser.Err()); + } + console.log("[ensureCloudToken p0.46] ensureUser succeeded, userId:", rUser.Ok().user.userId); return this.#checkAuth(req, (req) => ensureCloudToken(this.ctx, req, ictx)); } diff --git a/dashboard/backend/internal/add-user-to-ledger.ts b/dashboard/backend/internal/add-user-to-ledger.ts index 2034e6471..5b93cca2f 100644 --- a/dashboard/backend/internal/add-user-to-ledger.ts +++ b/dashboard/backend/internal/add-user-to-ledger.ts @@ -63,23 +63,46 @@ export async function addUserToLedger(ctx: FPApiSQLCtx, req: AddUserToLedger): P .where(and(eq(sqlLedgerUsers.userId, req.userId), ne(sqlLedgerUsers.default, 0))) .run(); } - const ret = ( - await ctx.db - .insert(sqlLedgerUsers) - .values({ - ledgerId: ledger.Ledgers.ledgerId, - userId: req.userId, - name: req.userName, - role: req.role, - right: req.right, - default: req.default ? 1 : 0, - createdAt: now, - updatedAt: now, - }) - .returning() - )[0]; + const inserted = await ctx.db + .insert(sqlLedgerUsers) + .values({ + ledgerId: ledger.Ledgers.ledgerId, + userId: req.userId, + name: req.userName, + role: req.role, + right: req.right, + default: req.default ? 1 : 0, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing() + .returning(); + if (inserted.length === 0) { + // User already exists in ledger, fetch existing record + const existing = await ctx.db + .select() + .from(sqlLedgerUsers) + .where(and(eq(sqlLedgerUsers.ledgerId, ledger.Ledgers.ledgerId), eq(sqlLedgerUsers.userId, req.userId))) + .get(); + if (!existing) { + return Result.Err("failed to insert or find ledger user"); + } + return Result.Ok({ + ledgerName: toUndef(ledger.Ledgers.name), + userName: toUndef(existing.name), + ledgerId: ledger.Ledgers.ledgerId, + tenantId: ledger.Ledgers.tenantId, + status: existing.status as UserStatus, + statusReason: existing.statusReason, + userId: req.userId, + default: !!existing.default, + role: toRole(existing.role), + right: toReadWrite(existing.right), + }); + } + const ret = inserted[0]; return Result.Ok({ - ledgerName: ledger.Ledgers.name, + ledgerName: toUndef(ledger.Ledgers.name), userName: req.userName, ledgerId: ledger.Ledgers.ledgerId, tenantId: ledger.Ledgers.tenantId, diff --git a/dashboard/backend/public/ensure-cloud-token.ts b/dashboard/backend/public/ensure-cloud-token.ts index 770bf4ae2..fb312155c 100644 --- a/dashboard/backend/public/ensure-cloud-token.ts +++ b/dashboard/backend/public/ensure-cloud-token.ts @@ -88,39 +88,86 @@ export async function ensureCloudToken( return Result.Err(`no tenant found for binding of appId:${req.appId} userId:${req.auth.user.userId}`); } if (!ledgerId) { - // create ledger - const rCreateLedger = await createLedger(ctx, { - type: "reqCreateLedger", - auth: req.auth, - ledger: { - tenantId, - name: `${req.appId}-${req.auth.user.userId}`, - }, - }); - if (rCreateLedger.isErr()) { - return Result.Err(rCreateLedger.Err()); - } - ledgerId = rCreateLedger.Ok().ledger.ledgerId; - const maxBindings = await ctx.db - .select({ - total: count(sqlAppIdBinding.appId), - }) + // Check if there's already an AppIdBinding for this appId (without user join) + // This handles the case where an invited user visits before being added to the ledger + const existingBinding = await ctx.db + .select() .from(sqlAppIdBinding) - .where(eq(sqlAppIdBinding.appId, req.appId)) + .innerJoin(sqlLedgers, eq(sqlLedgers.ledgerId, sqlAppIdBinding.ledgerId)) + .where(and(eq(sqlAppIdBinding.appId, req.appId), eq(sqlAppIdBinding.env, req.env ?? "prod"))) .get(); - if (maxBindings && maxBindings.total >= ctx.params.maxAppIdBindings) { - return Result.Err(`max appId bindings reached for appId:${req.appId}`); + if (existingBinding) { + console.log( + "[ensureCloudToken p0.49] existingBinding found for appId:", + req.appId, + "ledgerId:", + existingBinding.Ledgers.ledgerId, + ); + // Auto-redeem any pending invites for this user (ensureUser calls redeemInvite) + await ensureUser(ctx, { + type: "reqEnsureUser", + auth: req.auth.verifiedAuth, + }); + // Verify the user is actually in LedgerUsers before granting access (strict policy) + const userInLedger = await ctx.db + .select() + .from(sqlLedgerUsers) + .where( + and( + eq(sqlLedgerUsers.ledgerId, existingBinding.Ledgers.ledgerId), + eq(sqlLedgerUsers.userId, req.auth.user.userId), + eq(sqlLedgerUsers.status, "active"), + ), + ) + .get(); + console.log( + "[ensureCloudToken p0.49] userInLedger check for ledgerId:", + existingBinding.Ledgers.ledgerId, + "userId:", + req.auth.user.userId, + "found:", + !!userInLedger, + ); + if (!userInLedger) { + return Result.Err(`user not authorized for ledger - please accept the invite first`); + } + ledgerId = existingBinding.Ledgers.ledgerId; + tenantId = existingBinding.Ledgers.tenantId; + } else { + // create ledger + const rCreateLedger = await createLedger(ctx, { + type: "reqCreateLedger", + auth: req.auth, + ledger: { + tenantId, + name: req.appId, + }, + }); + if (rCreateLedger.isErr()) { + return Result.Err(rCreateLedger.Err()); + } + ledgerId = rCreateLedger.Ok().ledger.ledgerId; + const maxBindings = await ctx.db + .select({ + total: count(sqlAppIdBinding.appId), + }) + .from(sqlAppIdBinding) + .where(eq(sqlAppIdBinding.appId, req.appId)) + .get(); + if (maxBindings && maxBindings.total >= ctx.params.maxAppIdBindings) { + return Result.Err(`max appId bindings reached for appId:${req.appId}`); + } + await ctx.db + .insert(sqlAppIdBinding) + .values({ + appId: req.appId, + env: req.env ?? "prod", + ledgerId, + tenantId, + createdAt: new Date().toISOString(), + }) + .run(); } - await ctx.db - .insert(sqlAppIdBinding) - .values({ - appId: req.appId, - env: req.env ?? "prod", - ledgerId, - tenantId, - createdAt: new Date().toISOString(), - }) - .run(); } } else { ledgerId = binding.Ledgers.ledgerId; diff --git a/dashboard/backend/public/ensure-user.ts b/dashboard/backend/public/ensure-user.ts index f3683adb6..0b6fbf375 100644 --- a/dashboard/backend/public/ensure-user.ts +++ b/dashboard/backend/public/ensure-user.ts @@ -121,10 +121,12 @@ export async function ensureUser(ctx: FPApiSQLCtx, req: ReqEnsureUser): Promise< } } // Auto-redeem any pending invites for this user - await redeemInvite(ctx, { + console.log("[ensureUser p0.47] calling redeemInvite for email:", auth.verifiedAuth.params.email); + const redeemResult = await redeemInvite(ctx, { type: "reqRedeemInvite", auth, }); + console.log("[ensureUser p0.47] redeemInvite result:", JSON.stringify(redeemResult)); return Result.Ok({ type: "resEnsureUser", user: user, diff --git a/dashboard/backend/public/invite-user.ts b/dashboard/backend/public/invite-user.ts index 2612898ea..392f7cbcc 100644 --- a/dashboard/backend/public/invite-user.ts +++ b/dashboard/backend/public/invite-user.ts @@ -9,17 +9,30 @@ import { createInviteTicket } from "../internal/create-invite-ticket.js"; import { updateInviteTicket } from "../internal/update-invite.ticket.js"; export async function inviteUser(ctx: FPApiSQLCtx, req: ReqWithVerifiedAuthUser): Promise> { + console.log( + "[inviteUser p0.48] starting with request:", + JSON.stringify({ + query: req.ticket.query, + invitedParams: req.ticket.invitedParams, + inviteId: req.ticket.inviteId, + }), + ); if (!isVerifiedUserActive(req.auth)) { + console.log("[inviteUser p0.48] user not active"); return Result.Err(new UserNotFoundError()); } const findUser = await queryUser(ctx.db, req.ticket.query); if (findUser.isErr()) { + console.log("[inviteUser p0.48] queryUser failed:", findUser.Err()); return Result.Err(findUser.Err()); } + console.log("[inviteUser p0.48] queryUser found:", findUser.Ok().length, "users"); if (req.ticket.query.existingUserId && findUser.Ok().length !== 1) { + console.log("[inviteUser p0.48] existingUserId not found"); return Result.Err("existingUserId not found"); } if (req.ticket.query.existingUserId === req.auth.user.userId) { + console.log("[inviteUser p0.48] cannot invite self"); return Result.Err("cannot invite self"); } @@ -34,38 +47,60 @@ export async function inviteUser(ctx: FPApiSQLCtx, req: ReqWithVerifiedAuthUser< let tenantId: string | undefined; let ledgerId: string | undefined; if (req.ticket.invitedParams?.ledger) { + console.log("[inviteUser p0.48] looking for ledger:", req.ticket.invitedParams.ledger.id); const ledger = await ctx.db.select().from(sqlLedgers).where(eq(sqlLedgers.ledgerId, req.ticket.invitedParams.ledger.id)).get(); if (!ledger) { + console.log("[inviteUser p0.48] ledger not found"); return Result.Err("ledger not found"); } ledgerId = ledger.ledgerId; tenantId = ledger.tenantId; + console.log("[inviteUser p0.48] found ledger:", ledgerId, "tenantId:", tenantId); } if (req.ticket.invitedParams?.tenant) { + console.log("[inviteUser p0.48] looking for tenant:", req.ticket.invitedParams.tenant.id); const tenant = await ctx.db.select().from(sqlTenants).where(eq(sqlTenants.tenantId, req.ticket.invitedParams.tenant.id)).get(); if (!tenant) { + console.log("[inviteUser p0.48] tenant not found"); return Result.Err("tenant not found"); } tenantId = tenant.tenantId; + console.log("[inviteUser p0.48] found tenant:", tenantId); } if (!tenantId) { + console.log("[inviteUser p0.48] no tenant found"); return Result.Err("tenant not found"); } let inviteTicket: InviteTicket; if (!req.ticket.inviteId) { + console.log("[inviteUser p0.48] creating new invite for tenantId:", tenantId, "ledgerId:", ledgerId); const rInviteTicket = await createInviteTicket(ctx, req.auth.user.userId, tenantId, ledgerId, req); if (rInviteTicket.isErr()) { + console.log("[inviteUser p0.48] createInviteTicket failed:", rInviteTicket.Err()); return Result.Err(rInviteTicket.Err()); } inviteTicket = rInviteTicket.Ok(); + console.log( + "[inviteUser p0.48] created invite:", + JSON.stringify({ + inviteId: inviteTicket.inviteId, + ledgerId: inviteTicket.invitedParams.ledger?.id, + tenantId: inviteTicket.invitedParams.tenant?.id, + queryEmail: inviteTicket.query.byEmail, + }), + ); } else { + console.log("[inviteUser p0.48] updating invite:", req.ticket.inviteId); const rInviteTicket = await updateInviteTicket(ctx, req.auth.user.userId, tenantId, ledgerId, req); if (rInviteTicket.isErr()) { + console.log("[inviteUser p0.48] updateInviteTicket failed:", rInviteTicket.Err()); return Result.Err(rInviteTicket.Err()); } inviteTicket = rInviteTicket.Ok(); + console.log("[inviteUser p0.48] updated invite:", inviteTicket.inviteId); } + console.log("[inviteUser p0.48] success, returning invite:", inviteTicket.inviteId); return Result.Ok({ type: "resInviteUser", invite: inviteTicket, diff --git a/dashboard/backend/public/redeem-invite.ts b/dashboard/backend/public/redeem-invite.ts index 177a53416..ec045ef48 100644 --- a/dashboard/backend/public/redeem-invite.ts +++ b/dashboard/backend/public/redeem-invite.ts @@ -13,21 +13,37 @@ export async function redeemInvite( ctx: FPApiSQLCtx, req: ReqWithVerifiedAuthUser, ): Promise> { + const query = { + byString: req.auth.verifiedAuth.params.email, + byNick: req.auth.verifiedAuth.params.nick, + existingUserId: req.auth.user.userId, + }; + console.log("[redeemInvite p0.47] searching with query:", JSON.stringify(query)); + const foundInvites = await findInvite(ctx, { query }); + console.log( + "[redeemInvite p0.47] found invites:", + foundInvites.length, + "pending:", + foundInvites.filter((i) => i.status === "pending").length, + ); + if (foundInvites.length > 0) { + console.log( + "[redeemInvite p0.47] invite details:", + JSON.stringify( + foundInvites.map((i) => ({ + id: i.inviteId, + status: i.status, + queryEmail: i.query.byEmail, + ledger: i.invitedParams.ledger?.id, + })), + ), + ); + } return Result.Ok({ type: "resRedeemInvite", invites: sqlToInviteTickets( await Promise.all( - ( - await findInvite(ctx, { - query: { - byString: req.auth.verifiedAuth.params.email, - byNick: req.auth.verifiedAuth.params.nick, - existingUserId: req.auth.user.userId, - // TODO - // andProvider: req.auth.verifiedAuth.provider, - }, - }) - ) + foundInvites .filter((i) => i.status === "pending") .map(async (invite) => { if (invite.invitedParams.tenant) { diff --git a/dashboard/backend/sql/sql-helper.ts b/dashboard/backend/sql/sql-helper.ts index 810623d60..73d86090d 100644 --- a/dashboard/backend/sql/sql-helper.ts +++ b/dashboard/backend/sql/sql-helper.ts @@ -1,5 +1,5 @@ import { Queryable, QueryUser } from "@fireproof/core-protocols-dashboard"; -import { and, eq, or } from "drizzle-orm/sql/expressions"; +import { eq, or } from "drizzle-orm/sql/expressions"; import { SQLiteColumn } from "drizzle-orm/sqlite-core"; export function toUndef(v: string | null | undefined): string | undefined { @@ -28,15 +28,18 @@ export function queryCondition( readonly queryProvider: SQLiteColumn; }, ) { + // Build conditions for matching by userId, email, or nick + // We OR together all possible matches so invites can be found by email even when userId is provided + const conditions = [] as ReturnType[]; + if (query.existingUserId) { - return eq(table.userId, query.existingUserId); + conditions.push(eq(table.userId, query.existingUserId)); } const str = query.byString?.trim(); if (str) { const byEmail = queryEmail(str); const byNick = queryNick(str); - const conditions = [] as ReturnType[]; if (byEmail) { conditions.push(eq(table.queryEmail, byEmail)); } @@ -44,26 +47,23 @@ export function queryCondition( conditions.push(eq(table.queryNick, byNick)); } conditions.push(eq(table.userId, str)); - return or(...conditions); } const byEmail = queryEmail(query.byEmail); const byNick = queryNick(query.byNick); - let where: ReturnType = eq(table.userId, Math.random() + ""); - if (byEmail && byNick && query.andProvider) { - where = and(eq(table.queryEmail, byEmail), eq(table.queryNick, byNick), eq(table.queryProvider, query.andProvider)); - } else if (byEmail && byNick) { - where = and(eq(table.queryEmail, byEmail), eq(table.queryNick, byNick)); - } else if (byEmail && query.andProvider) { - where = and(eq(table.queryEmail, byEmail), eq(table.queryProvider, query.andProvider)); - } else if (byNick && query.andProvider) { - where = and(eq(table.queryNick, byNick), eq(table.queryProvider, query.andProvider)); - } else if (byEmail) { - where = eq(table.queryEmail, byEmail); - } else if (byNick) { - where = eq(table.queryNick, byNick); + if (byEmail) { + conditions.push(eq(table.queryEmail, byEmail)); + } + if (byNick) { + conditions.push(eq(table.queryNick, byNick)); } - return where; + + if (conditions.length === 0) { + // No valid conditions - return a condition that matches nothing + return eq(table.userId, Math.random() + ""); + } + + return or(...conditions); } export function queryNick(nick?: string): string | undefined { diff --git a/dashboard/backend/tests/db-api.test.ts b/dashboard/backend/tests/db-api.test.ts index 7932c158e..605f6dcee 100644 --- a/dashboard/backend/tests/db-api.test.ts +++ b/dashboard/backend/tests/db-api.test.ts @@ -1146,6 +1146,89 @@ describe("db-api", () => { expect(validTenant.isErr()).toBeTruthy(); }); + /** + * Policy: Shared Ledger Access via Prefix Matching + * + * When a user is invited to a ledger and later calls ensureCloudToken with the same appId, + * they should be granted access to the existing shared ledger rather than creating a new one. + * + * The ledger name format is: `{appId}-{ownerUserId}` + * When an invited user calls ensureCloudToken with appId, the system should: + * 1. First check for an exact appId binding (existing behavior) + * 2. If not found, check if user has access to any ledger whose name starts with appId (prefix match) + * 3. Only create a new ledger if no matching ledger is found + * + * This enables collaborative apps where multiple users share the same database: + * - User A creates app with appId "vf-my-app-install1-todos" + * - System creates ledger "vf-my-app-install1-todos-{userA_id}" + * - User A invites User B to the ledger + * - User B visits app with same appId → gets access to User A's ledger (not a new one) + */ + it("ensureCloudToken grants invited user access to shared ledger via prefix match", async () => { + const userA = datas[7]; + const userB = datas[8]; + const id = sthis.nextId().str; + const appId = `SHARED_LEDGER_TEST-${id}`; + + // User A creates a ledger via ensureCloudToken + // This creates a ledger named "{appId}-{userA_id}" and binds it to appId + const userAToken = await fpApi.ensureCloudToken( + { + type: "reqEnsureCloudToken", + auth: userA.reqs.auth, + appId, + }, + jwkPack.opts, + ); + expect(userAToken.isOk()).toBeTruthy(); + const userALedgerId = userAToken.Ok().ledger; + + // User A invites User B to the ledger (not just the tenant) + const invite = await fpApi.inviteUser({ + type: "reqInviteUser", + auth: userA.reqs.auth, + ticket: { + query: { + existingUserId: userB.ress.user.userId, + }, + invitedParams: { + ledger: { + id: userALedgerId, + role: "member", + right: "write", + }, + }, + }, + }); + expect(invite.isOk()).toBeTruthy(); + + // User B redeems the invite (adds them to LedgerUsers) + const redeem = await fpApi.redeemInvite({ + type: "reqRedeemInvite", + auth: userB.reqs.auth, + }); + expect(redeem.isOk()).toBeTruthy(); + + // User B calls ensureCloudToken with the same appId + // Without prefix matching, this would create a NEW ledger "{appId}-{userB_id}" + // With prefix matching, User B should get access to User A's existing ledger + const userBToken = await fpApi.ensureCloudToken( + { + type: "reqEnsureCloudToken", + auth: userB.reqs.auth, + appId, + }, + jwkPack.opts, + ); + expect(userBToken.isOk()).toBeTruthy(); + + // Critical assertion: User B should get the SAME ledger as User A + expect(userBToken.Ok().ledger).toBe(userALedgerId); + + // Verify they're using the same tenant too + expect(userBToken.Ok().tenant).toBe(userAToken.Ok().tenant); + }); + it("ensureCloudToken auto-redeems pending invite for ledger access", async () => { // User A (datas[5]) creates a ledger and binds it to an appId const userA = datas[5]; @@ -1198,6 +1281,283 @@ describe("db-api", () => { expect(userBToken.Ok().ledger).toBe(userAToken.Ok().ledger); expect(userBToken.Ok().tenant).toBe(userAToken.Ok().tenant); }); + + it("ensureCloudToken rejects uninvited user trying to access existing ledger (strict policy)", async () => { + // User A creates a ledger via ensureCloudToken + const userA = datas[3]; + const userC = datas[4]; // User C has NOT been invited + const id = sthis.nextId().str; + const appId = `STRICT_POLICY_TEST-${id}`; + + // User A creates a ledger bound to appId + const userAToken = await fpApi.ensureCloudToken( + { + type: "reqEnsureCloudToken", + auth: userA.reqs.auth, + appId, + }, + jwkPack.opts, + ); + expect(userAToken.isOk()).toBeTruthy(); + + // User C tries to access the same appId WITHOUT being invited + // Under strict policy, this should fail + const userCToken = await fpApi.ensureCloudToken( + { + type: "reqEnsureCloudToken", + auth: userC.reqs.auth, + appId, + }, + jwkPack.opts, + ); + + // User C should be rejected - they're not in LedgerUsers for this ledger + expect(userCToken.isErr()).toBeTruthy(); + expect(userCToken.Err().message).toContain("not authorized"); + }); + + it("ensureCloudToken allows invited user after redemption", async () => { + // Same as above but User D IS invited + const userA = datas[3]; + const userD = datas[9]; + const id = sthis.nextId().str; + const appId = `INVITED_ACCESS_TEST-${id}`; + + // User A creates a ledger bound to appId + const userAToken = await fpApi.ensureCloudToken( + { + type: "reqEnsureCloudToken", + auth: userA.reqs.auth, + appId, + }, + jwkPack.opts, + ); + expect(userAToken.isOk()).toBeTruthy(); + const ledgerId = userAToken.Ok().ledger; + + // User A invites User D to the ledger + const invite = await fpApi.inviteUser({ + type: "reqInviteUser", + auth: userA.reqs.auth, + ticket: { + query: { + existingUserId: userD.ress.user.userId, + }, + invitedParams: { + ledger: { + id: ledgerId, + role: "member", + right: "write", + }, + }, + }, + }); + expect(invite.isOk()).toBeTruthy(); + + // User D redeems the invite + const redeem = await fpApi.redeemInvite({ + type: "reqRedeemInvite", + auth: userD.reqs.auth, + }); + expect(redeem.isOk()).toBeTruthy(); + + // Now User D should be able to access the ledger + const userDToken = await fpApi.ensureCloudToken( + { + type: "reqEnsureCloudToken", + auth: userD.reqs.auth, + appId, + }, + jwkPack.opts, + ); + + expect(userDToken.isOk()).toBeTruthy(); + expect(userDToken.Ok().ledger).toBe(ledgerId); + }); + + it("ensureCloudToken auto-redeems invite without explicit redeemInvite call", async () => { + // This tests that ensureCloudToken itself will redeem pending invites + // Before the fix, this would fail because the user wasn't in LedgerUsers + const userA = datas[2]; + const userE = datas[1]; // User E will be invited but NOT explicitly call redeemInvite + const id = sthis.nextId().str; + const appId = `AUTO_REDEEM_TEST-${id}`; + + // User A creates a ledger bound to appId + const userAToken = await fpApi.ensureCloudToken( + { + type: "reqEnsureCloudToken", + auth: userA.reqs.auth, + appId, + }, + jwkPack.opts, + ); + expect(userAToken.isOk()).toBeTruthy(); + const ledgerId = userAToken.Ok().ledger; + + // User A invites User E to the ledger + const invite = await fpApi.inviteUser({ + type: "reqInviteUser", + auth: userA.reqs.auth, + ticket: { + query: { + existingUserId: userE.ress.user.userId, + }, + invitedParams: { + ledger: { + id: ledgerId, + role: "member", + right: "write", + }, + }, + }, + }); + expect(invite.isOk()).toBeTruthy(); + + // User E does NOT explicitly call redeemInvite + // Instead, they directly call ensureCloudToken + // This should auto-redeem the invite and grant access + const userEToken = await fpApi.ensureCloudToken( + { + type: "reqEnsureCloudToken", + auth: userE.reqs.auth, + appId, + }, + jwkPack.opts, + ); + + // Before the fix, this would fail with "user not authorized for ledger" + // After the fix, ensureCloudToken calls ensureUser which calls redeemInvite + expect(userEToken.isOk()).toBeTruthy(); + expect(userEToken.Ok().ledger).toBe(ledgerId); + }); + + it("ensureCloudToken auto-redeems invite by email (real UI flow)", async () => { + // This tests the actual flow: invite by email, user visits with matching email + const userA = datas[5]; + const userF = datas[6]; // User F will be invited BY EMAIL + const id = sthis.nextId().str; + const appId = `EMAIL_INVITE_TEST-${id}`; + + // User A creates a ledger bound to appId + const userAToken = await fpApi.ensureCloudToken( + { + type: "reqEnsureCloudToken", + auth: userA.reqs.auth, + appId, + }, + jwkPack.opts, + ); + expect(userAToken.isOk()).toBeTruthy(); + const ledgerId = userAToken.Ok().ledger; + + // Get User F's email from their auth params (simulating what invite UI does) + // The TestApiToken generates email as `test${userId}@test.de` + const userFEmail = `testuserId-${userF.reqs.auth.token}@test.de`; + + // User A invites User F BY EMAIL (like the real UI does) + const invite = await fpApi.inviteUser({ + type: "reqInviteUser", + auth: userA.reqs.auth, + ticket: { + query: { + byEmail: userFEmail, + }, + invitedParams: { + ledger: { + id: ledgerId, + role: "member", + right: "write", + }, + }, + }, + }); + expect(invite.isOk()).toBeTruthy(); + + // User F does NOT explicitly call redeemInvite + // They just visit the vibe URL which calls ensureCloudToken + const userFToken = await fpApi.ensureCloudToken( + { + type: "reqEnsureCloudToken", + auth: userF.reqs.auth, + appId, + }, + jwkPack.opts, + ); + + // This should auto-redeem because ensureUser->redeemInvite finds by email + expect(userFToken.isOk()).toBeTruthy(); + expect(userFToken.Ok().ledger).toBe(ledgerId); + }); + + it("ensureCloudToken auto-redeems for brand new user invited by email", async () => { + // This tests the exact production scenario: + // - User A creates a vibe and invites by email + // - User B has NEVER visited before (no user record exists) + // - User B clicks link, logs in, and ensureCloudToken is called + const userA = datas[7]; + const id = sthis.nextId().str; + const appId = `NEW_USER_EMAIL_INVITE-${id}`; + + // User A creates a ledger bound to appId + const userAToken = await fpApi.ensureCloudToken( + { + type: "reqEnsureCloudToken", + auth: userA.reqs.auth, + appId, + }, + jwkPack.opts, + ); + expect(userAToken.isOk()).toBeTruthy(); + const ledgerId = userAToken.Ok().ledger; + + // Create a completely new user token that has NEVER been used + // This simulates a user who has never visited the system + const newUserToken = `brand-new-user-${id}`; + const newUserEmail = `testuserId-${newUserToken}@test.de`; + + // User A invites the NEW user BY EMAIL + const invite = await fpApi.inviteUser({ + type: "reqInviteUser", + auth: userA.reqs.auth, + ticket: { + query: { + byEmail: newUserEmail, + }, + invitedParams: { + ledger: { + id: ledgerId, + role: "member", + right: "write", + }, + }, + }, + }); + expect(invite.isOk()).toBeTruthy(); + + // NEW user visits for the first time - they have no user record yet + // ensureCloudToken should: + // 1. Find existing binding for appId + // 2. Call ensureUser (which creates user + redeems invite) + // 3. Grant access to the ledger + const newUserAuth: DashAuthType = { + type: "clerk", + token: newUserToken, + }; + + const newUserCloudToken = await fpApi.ensureCloudToken( + { + type: "reqEnsureCloudToken", + auth: newUserAuth, + appId, + }, + jwkPack.opts, + ); + + // Should succeed because ensureUser->redeemInvite finds invite by email + expect(newUserCloudToken.isOk()).toBeTruthy(); + expect(newUserCloudToken.Ok().ledger).toBe(ledgerId); + }); }); it("create session with claim", async () => {