From f072435024b0beda9592cff829a28f7c7dca37f3 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 8 May 2026 21:13:48 +0200 Subject: [PATCH] feat(core-realtime): authorize function (4 scope kinds) --- packages/core-realtime/src/authorize.test.ts | 60 ++++++++++++++++++++ packages/core-realtime/src/authorize.ts | 22 +++++++ 2 files changed, 82 insertions(+) create mode 100644 packages/core-realtime/src/authorize.test.ts create mode 100644 packages/core-realtime/src/authorize.ts diff --git a/packages/core-realtime/src/authorize.test.ts b/packages/core-realtime/src/authorize.test.ts new file mode 100644 index 0000000..1d0e900 --- /dev/null +++ b/packages/core-realtime/src/authorize.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { authorize } from "@/authorize"; +import { defineRealtimeChannel } from "@/realtime-channel"; + +const schema = z.object({}).strict(); + +describe("authorize", () => { + describe("public", () => { + const ch = defineRealtimeChannel("a", schema, { scope: "public" }); + it("allows anonymous", async () => { + expect(await authorize(ch, {}, null)).toBe(true); + }); + it("allows authenticated", async () => { + expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true); + }); + }); + + describe("authenticated", () => { + const ch = defineRealtimeChannel("a", schema, { scope: "authenticated" }); + it("rejects anonymous", async () => { + expect(await authorize(ch, {}, null)).toBe(false); + }); + it("allows any user", async () => { + expect(await authorize(ch, {}, { userId: "u1", roles: [] })).toBe(true); + }); + }); + + describe("{ role }", () => { + const ch = defineRealtimeChannel("a", schema, { scope: { role: "admin" } }); + it("rejects anonymous", async () => { + expect(await authorize(ch, {}, null)).toBe(false); + }); + it("rejects user without role", async () => { + expect(await authorize(ch, {}, { userId: "u1", roles: ["editor"] })).toBe(false); + }); + it("allows user with role", async () => { + expect(await authorize(ch, {}, { userId: "u1", roles: ["admin", "editor"] })).toBe(true); + }); + }); + + describe("{ userScoped }", () => { + const ch = defineRealtimeChannel("a", schema, { + scope: { userScoped: true, template: "notifications.user.{userId}" }, + }); + it("rejects anonymous", async () => { + expect(await authorize(ch, { userId: "u1" }, null)).toBe(false); + }); + it("rejects user requesting someone else's channel", async () => { + expect( + await authorize(ch, { userId: "u_other" }, { userId: "u1", roles: [] }), + ).toBe(false); + }); + it("allows user requesting own channel", async () => { + expect( + await authorize(ch, { userId: "u1" }, { userId: "u1", roles: [] }), + ).toBe(true); + }); + }); +}); diff --git a/packages/core-realtime/src/authorize.ts b/packages/core-realtime/src/authorize.ts new file mode 100644 index 0000000..5f6dd82 --- /dev/null +++ b/packages/core-realtime/src/authorize.ts @@ -0,0 +1,22 @@ +import type { z } from "zod"; +import type { RealtimeChannelDescriptor } from "./realtime-channel"; + +export async function authorize( + descriptor: RealtimeChannelDescriptor, + params: Record, + user: { userId: string; roles: string[] } | null, +): Promise { + const scope = descriptor.scope; + + if (scope === "public") return true; + if (scope === "authenticated") return user !== null; + + if (typeof scope === "object" && "role" in scope) { + return user !== null && user.roles.includes(scope.role); + } + if (typeof scope === "object" && "userScoped" in scope) { + return user !== null && params.userId === user.userId; + } + + return false; +}