diff --git a/packages/backend/convex/authz.ts b/packages/backend/convex/authz.ts index fd92747..24c2642 100644 --- a/packages/backend/convex/authz.ts +++ b/packages/backend/convex/authz.ts @@ -1,5 +1,8 @@ import { ConvexError } from "convex/values"; +import type { Id } from "./_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; + export interface AuthContext { readonly auth: { readonly getUserIdentity: () => Promise<{ @@ -15,3 +18,25 @@ export const requireOwnerId = async (ctx: AuthContext): Promise => { } return identity.tokenIdentifier; }; + +/** + * Resolve the authenticated identity and prove it is a member of the given + * organization. Throws on unauthenticated or non-member access. Returns the + * canonical user ID (the Better Auth `tokenIdentifier`). + */ +export const requireOrganizationMember = async ( + ctx: QueryCtx | MutationCtx, + organizationId: Id<"organizations"> +): Promise => { + const userId = await requireOwnerId(ctx); + const membership = await ctx.db + .query("organizationMembers") + .withIndex("by_organizationId_and_userId", (q) => + q.eq("organizationId", organizationId).eq("userId", userId) + ) + .unique(); + if (!membership) { + throw new ConvexError("Organization membership required"); + } + return userId; +}; diff --git a/packages/backend/convex/organizations.test.ts b/packages/backend/convex/organizations.test.ts new file mode 100644 index 0000000..a7b9fd6 --- /dev/null +++ b/packages/backend/convex/organizations.test.ts @@ -0,0 +1,125 @@ +import { convexTest } from "convex-test"; +import { anyApi } from "convex/server"; +import { describe, expect, test } from "vitest"; + +import { requireOrganizationMember } from "./authz"; +import schema from "./schema"; + +// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it +// has no type definition under the Convex tsconfig (which scopes `types` to +// node), so declare the minimal shape the test relies on. +declare global { + interface ImportMeta { + readonly glob: (pattern: string) => Record Promise>; + } +} + +const modules = import.meta.glob("./**/*.ts"); +const api = anyApi; + +const ID_A = "https://convex.test|user-a"; +const ID_B = "https://convex.test|user-b"; + +const identityA = { tokenIdentifier: ID_A }; +const identityB = { tokenIdentifier: ID_B }; + +const newTest = () => convexTest({ schema, modules }); + +describe("organizations", () => { + test("first ensure creates one organization and owner membership", async () => { + const t = newTest(); + const org = await t + .withIdentity(identityA) + .mutation(api.organizations.ensurePersonalOrganization, {}); + + expect(org.kind).toBe("personal"); + expect(org.name).toBe("Personal"); + expect(org.createdBy).toBe(ID_A); + expect(org._id).toBeTruthy(); + + // Idempotent shape: a second ensure returns the same organization. + const again = await t + .withIdentity(identityA) + .mutation(api.organizations.ensurePersonalOrganization, {}); + expect(again._id).toBe(org._id); + + // The current-organization query reflects the ensured org. + const current = await t + .withIdentity(identityA) + .query(api.organizations.getCurrent, {}); + expect(current?._id).toBe(org._id); + }); + + test("repeated ensure returns the same organization without duplicates", async () => { + const t = newTest(); + + const first = await t + .withIdentity(identityA) + .mutation(api.organizations.ensurePersonalOrganization, {}); + + // Many subsequent ensures must all resolve to the single org. + const repeats = await Promise.all( + Array.from({ length: 5 }, () => + t + .withIdentity(identityA) + .mutation(api.organizations.ensurePersonalOrganization, {}) + ) + ); + + for (const org of repeats) { + expect(org._id).toBe(first._id); + } + + // The current-organization query still reports the single org. + const allForA = await t + .withIdentity(identityA) + .query(api.organizations.getCurrent, {}); + expect(allForA?._id).toBe(first._id); + }); + + test("unauthenticated access is denied", async () => { + const t = newTest(); + + await expect(t.query(api.organizations.getCurrent, {})).rejects.toThrow( + /Authentication required/u + ); + + await expect( + t.mutation(api.organizations.ensurePersonalOrganization, {}) + ).rejects.toThrow(/Authentication required/u); + }); + + test("a second identity cannot read or mutate the first organization", async () => { + const t = newTest(); + + const orgA = await t + .withIdentity(identityA) + .mutation(api.organizations.ensurePersonalOrganization, {}); + + // The second identity sees none of the first identity's organizations. + const currentForB = await t + .withIdentity(identityB) + .query(api.organizations.getCurrent, {}); + expect(currentForB).toBeNull(); + + // The second identity gets its own distinct organization. + const orgB = await t + .withIdentity(identityB) + .mutation(api.organizations.ensurePersonalOrganization, {}); + expect(orgB._id).not.toBe(orgA._id); + + // The membership boundary denies the second identity access to org A. + await expect( + t + .withIdentity(identityB) + .query((ctx) => requireOrganizationMember(ctx, orgA._id)) + ).rejects.toThrow(/Organization membership required/u); + + // But the first identity is a confirmed member of its own organization. + await expect( + t + .withIdentity(identityA) + .query((ctx) => requireOrganizationMember(ctx, orgA._id)) + ).resolves.toBe(ID_A); + }); +}); diff --git a/packages/backend/convex/organizations.ts b/packages/backend/convex/organizations.ts new file mode 100644 index 0000000..f5779c4 --- /dev/null +++ b/packages/backend/convex/organizations.ts @@ -0,0 +1,82 @@ +import type { Doc, Id } from "./_generated/dataModel"; +import { mutation, query } from "./_generated/server"; +import { requireOwnerId } from "./authz"; + +interface OrganizationView { + readonly _id: Id<"organizations">; + readonly _creationTime: number; + readonly name: string; + readonly kind: "personal" | "team"; + readonly createdBy: string; + readonly createdAt: number; +} + +const toView = (org: Doc<"organizations">): OrganizationView => ({ + _creationTime: org._creationTime, + _id: org._id, + createdAt: org.createdAt, + createdBy: org.createdBy, + kind: org.kind, + name: org.name, +}); + +/** + * Idempotently create (or return) the authenticated user's single personal + * organization plus an owner membership. Convex mutations are atomic + * transactions: if two concurrent calls both observe no existing org and both + * insert, the transaction's optimistic-concurrency check rejects one; the + * retried call re-reads and returns the now-existing org. The read+write is the + * whole transaction, so no duplicate personal org can persist. + */ +export const ensurePersonalOrganization = mutation({ + args: {}, + handler: async (ctx): Promise => { + const ownerId = await requireOwnerId(ctx); + const existing = await ctx.db + .query("organizations") + .withIndex("by_createdBy_and_kind", (q) => + q.eq("createdBy", ownerId).eq("kind", "personal") + ) + .unique(); + if (existing) { + return toView(existing); + } + const createdAt = Date.now(); + const organizationId = await ctx.db.insert("organizations", { + createdAt, + createdBy: ownerId, + kind: "personal", + name: "Personal", + }); + await ctx.db.insert("organizationMembers", { + createdAt, + organizationId, + role: "owner", + userId: ownerId, + }); + const created = await ctx.db.get(organizationId); + if (!created) { + throw new Error("Failed to create personal organization"); + } + return toView(created); + }, +}); + +/** + * Return the authenticated user's current organization. In the single personal + * organization phase this is the user's one personal org. Returns null when the + * user has not yet ensured one. Denies unauthenticated access. + */ +export const getCurrent = query({ + args: {}, + handler: async (ctx): Promise => { + const ownerId = await requireOwnerId(ctx); + const existing = await ctx.db + .query("organizations") + .withIndex("by_createdBy_and_kind", (q) => + q.eq("createdBy", ownerId).eq("kind", "personal") + ) + .unique(); + return existing ? toView(existing) : null; + }, +}); diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts index 4314d16..da7b3f8 100644 --- a/packages/backend/convex/schema.ts +++ b/packages/backend/convex/schema.ts @@ -147,6 +147,26 @@ export default defineSchema({ completed: v.boolean(), }), + // ----------------------------------------------------------------- + // Organization tenancy. Organizations are hard tenant boundaries; deny by + // default. The membership user ID is the Better Auth Convex identity + // `tokenIdentifier`. + // ----------------------------------------------------------------- + organizations: defineTable({ + name: v.string(), + kind: v.union(v.literal("personal"), v.literal("team")), + createdBy: v.string(), + createdAt: v.number(), + }).index("by_createdBy_and_kind", ["createdBy", "kind"]), + organizationMembers: defineTable({ + organizationId: v.id("organizations"), + userId: v.string(), + role: v.union(v.literal("owner"), v.literal("member")), + createdAt: v.number(), + }) + .index("by_userId", ["userId"]) + .index("by_organizationId_and_userId", ["organizationId", "userId"]), + // ----------------------------------------------------------------- // Flue persistence stores (schema/format version 4). // -----------------------------------------------------------------