@nwire/rbac 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Gefter / 200apps Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # @nwire/rbac
2
+
3
+ > Declarative permissions powered by [CASL](https://casl.js.org) — `defineAbility` + middleware + `can()` resolver.
4
+
5
+ ## What it does
6
+
7
+ Wraps CASL behind a small Nwire surface. `defineAbility((user, { allow, deny }) => {...})` declares what each role can do; `rbacPlugin` auto-enforces tuple `action.policy`; `can("update", "Post")` gates resolvers declaratively; `ctx.ability()` works inside any handler for instance-level checks. Pairs with `@nwire/auth` so `ctx.envelope.user` is the input.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @nwire/rbac @nwire/auth
13
+ ```
14
+
15
+ ## Quick start
16
+
17
+ ```ts
18
+ import { defineAbility, rbacPlugin, can } from "@nwire/rbac";
19
+ import { identityPlugin } from "@nwire/auth";
20
+ import { defineApp, defineAction } from "@nwire/forge";
21
+ import { httpInterface, post } from "@nwire/http";
22
+
23
+ export const buildAbility = defineAbility((user, { allow, deny }) => {
24
+ if (!user) return;
25
+ if (user.roles?.includes("admin")) {
26
+ allow("manage", "all");
27
+ return;
28
+ }
29
+ allow("read", "Post");
30
+ allow("create", "Post");
31
+ allow("update", "Post", { authorId: user.id });
32
+ allow("delete", "Post", { authorId: user.id });
33
+ });
34
+
35
+ defineApp("my-app", {
36
+ plugins: [identityPlugin({ adapter }), rbacPlugin({ buildAbility })],
37
+ });
38
+
39
+ httpInterface()
40
+ .wire(post("/posts/:id", { policy: ["update", "Post"] }), async ({ input }) => {
41
+ /* ... */
42
+ })
43
+ .run();
44
+ ```
45
+
46
+ ## API surface
47
+
48
+ - `defineAbility(setup)` — declare permissions per user.
49
+ - `rbacPlugin({ buildAbility })` — plug into `createApp`.
50
+ - `can(action, subject)` / `cannot(action, subject)` — handler middleware.
51
+ - `abilityFromCtx(ctx)` / `subject(type, obj)` — programmatic checks.
52
+ - `conditionsFor(ability, action, type)` — get Mongo-style conditions for query filters.
53
+
54
+ ## When to use
55
+
56
+ Any app that needs per-role authorization beyond `action.policy` strings. Fits L3 and up. Pair with `@nwire/auth` so users come from a real IdP.
57
+
58
+ ## Standalone use
59
+
60
+ For developers using `@nwire/rbac` **without the rest of Nwire** — pair it with any TypeScript project, any container, any HTTP framework.
61
+
62
+ ```ts
63
+ // See the package's main entry (src/) for the standalone surface.
64
+ // The exports below work without @nwire/app or @nwire/forge.
65
+ import {} from /* ...standalone exports... */ "@nwire/rbac";
66
+ ```
67
+
68
+ ## Within nwire-app
69
+
70
+ For developers using this package as part of the Nwire stack — register it via `app.use(...)` or it auto-wires when you compose `createApp({ modules })`.
71
+
72
+ ```ts
73
+ import { createApp } from "@nwire/forge";
74
+
75
+ const app = createApp({
76
+ /* ...config... */
77
+ });
78
+ // Adapter/plugin wiring happens here when applicable.
79
+ ```
80
+
81
+ ## See also
82
+
83
+ - [Architecture sketch §05 — Adapters tier](../../architecture-sketch.html#packages)
84
+ - Sibling packages: [@nwire/auth](../nwire-auth), [@nwire/auth-better-auth](../nwire-auth-better-auth), [@nwire/auth-logto](../nwire-auth-logto)
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Tests for the Phase 62.5 polish layer:
3
+ * - typed permission strings (autocomplete-friendly)
4
+ * - composable resolvers (.or, .and, .not, ownedBy, sameTenant)
5
+ * - structured errors (OwnershipMismatchError, ScopeMissingError, ...)
6
+ * - conditionsFor (CASL rulesToQuery surface)
7
+ */
8
+ export {};
9
+ //# sourceMappingURL=polish.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"polish.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/polish.test.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG"}
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Tests for the Phase 62.5 polish layer:
3
+ * - typed permission strings (autocomplete-friendly)
4
+ * - composable resolvers (.or, .and, .not, ownedBy, sameTenant)
5
+ * - structured errors (OwnershipMismatchError, ScopeMissingError, ...)
6
+ * - conditionsFor (CASL rulesToQuery surface)
7
+ */
8
+ import { describe, it, expect } from "vitest";
9
+ import { permission, parsePermission, resolver, ownedBy, sameTenant, authorize, conditionsFor, defineAbility, ActionDeniedError, OwnershipMismatchError, RoleMissingError, isOwnershipMismatchError, } from "../rbac";
10
+ describe("permission() — typed strings", () => {
11
+ it("produces resource:action format", () => {
12
+ expect(permission("update", "Post")).toBe("update:Post");
13
+ });
14
+ it("parses back to a tuple", () => {
15
+ expect(parsePermission("update:Post")).toEqual(["update", "Post"]);
16
+ });
17
+ });
18
+ describe("resolver — composable predicates", () => {
19
+ const alice = { id: "u-a", email: "a@x" };
20
+ const bob = { id: "u-b", email: "b@x" };
21
+ it("base predicate works", async () => {
22
+ const isOwner = resolver((u, p) => u.id === p.authorId);
23
+ expect(await isOwner(alice, { id: "1", authorId: alice.id })).toBe(true);
24
+ expect(await isOwner(alice, { id: "1", authorId: bob.id })).toBe(false);
25
+ });
26
+ it(".or combines with disjunction", async () => {
27
+ const isOwner = resolver((u, p) => u.id === p.authorId);
28
+ const isAdmin = resolver((u) => u.roles?.includes("admin") ?? false);
29
+ const canEdit = isOwner.or(isAdmin);
30
+ const admin = { id: "u-x", email: "x@x", roles: ["admin"] };
31
+ expect(await canEdit(alice, { id: "1", authorId: bob.id })).toBe(false);
32
+ expect(await canEdit(admin, { id: "1", authorId: bob.id })).toBe(true);
33
+ });
34
+ it(".and combines with conjunction", async () => {
35
+ const isOwner = resolver((u, p) => u.id === p.authorId);
36
+ const inTeam = resolver((u, p) => Boolean(p.teamId) && u.tenant === p.teamId);
37
+ const both = isOwner.and(inTeam);
38
+ const alice2 = { ...alice, tenant: "t-1" };
39
+ expect(await both(alice2, { id: "1", authorId: alice.id, teamId: "t-1" })).toBe(true);
40
+ expect(await both(alice2, { id: "1", authorId: alice.id, teamId: "t-2" })).toBe(false);
41
+ expect(await both(alice2, { id: "1", authorId: bob.id, teamId: "t-1" })).toBe(false);
42
+ });
43
+ it(".not negates the predicate", async () => {
44
+ const isOwner = resolver((u, p) => u.id === p.authorId);
45
+ const isNotOwner = isOwner.not();
46
+ expect(await isNotOwner(alice, { id: "1", authorId: bob.id })).toBe(true);
47
+ expect(await isNotOwner(alice, { id: "1", authorId: alice.id })).toBe(false);
48
+ });
49
+ it("ownedBy shorthand reads from configurable field", async () => {
50
+ const own = ownedBy("authorId");
51
+ expect(await own(alice, { id: "1", authorId: alice.id })).toBe(true);
52
+ expect(await own(alice, { id: "1", authorId: bob.id })).toBe(false);
53
+ });
54
+ it("sameTenant shorthand reads tenant/tenantId", async () => {
55
+ const same = sameTenant();
56
+ const u = { id: "u", email: "u@x", tenant: "t-1" };
57
+ expect(await same(u, { tenantId: "t-1" })).toBe(true);
58
+ expect(await same(u, { tenantId: "t-2" })).toBe(false);
59
+ });
60
+ it("authorize() throws OwnershipMismatchError when rule denies", async () => {
61
+ const own = ownedBy();
62
+ await expect(authorize(alice, "update", { authorId: bob.id }, own, { subjectName: "Post" })).rejects.toBeInstanceOf(OwnershipMismatchError);
63
+ });
64
+ it("authorize() throws for null user", async () => {
65
+ const own = ownedBy();
66
+ await expect(authorize(null, "update", { authorId: "x" }, own)).rejects.toBeInstanceOf(OwnershipMismatchError);
67
+ });
68
+ it("authorize() resolves when rule allows", async () => {
69
+ const own = ownedBy();
70
+ await expect(authorize(alice, "update", { authorId: alice.id }, own)).resolves.toBeUndefined();
71
+ });
72
+ });
73
+ describe("structured errors", () => {
74
+ it("ActionDeniedError carries action + subject + reason", () => {
75
+ const e = new ActionDeniedError({ action: "delete", subject: "Post", userId: "u" });
76
+ expect(e.action).toBe("delete");
77
+ expect(e.subject).toBe("Post");
78
+ expect(e.denialReason).toBe("denied");
79
+ expect(e.message).toMatch(/delete Post/);
80
+ });
81
+ it("OwnershipMismatchError narrows denialReason", () => {
82
+ const e = new OwnershipMismatchError({ action: "edit", subject: "Post" });
83
+ expect(e.denialReason).toBe("ownership-mismatch");
84
+ expect(isOwnershipMismatchError(e)).toBe(true);
85
+ });
86
+ it("RoleMissingError carries the required role", () => {
87
+ const e = new RoleMissingError({ action: "view", subject: "Admin", requiredRole: "admin" });
88
+ expect(e.requiredRole).toBe("admin");
89
+ expect(e.denialReason).toBe("role-missing");
90
+ });
91
+ it("all subclasses are catchable as ActionDeniedError", () => {
92
+ const e = new OwnershipMismatchError({ action: "x", subject: "Y" });
93
+ expect(e instanceof ActionDeniedError).toBe(true);
94
+ });
95
+ });
96
+ describe("conditionsFor — CASL rulesToQuery surface", () => {
97
+ const buildAbility = defineAbility((user, { allow }) => {
98
+ if (!user)
99
+ return;
100
+ if (user.roles?.includes("admin")) {
101
+ allow("manage", "all");
102
+ return;
103
+ }
104
+ allow("read", "Post");
105
+ allow("update", "Post", { authorId: user.id });
106
+ });
107
+ it("returns an empty object when an unrestricted rule allows", () => {
108
+ // CASL's rulesToQuery returns `{}` (no conditions) when ANY rule
109
+ // is unrestricted — i.e. the user may read every row.
110
+ const ability = buildAbility({ id: "alice", email: "a@x" });
111
+ const q = conditionsFor(ability, "read", "Post");
112
+ expect(q).toEqual({});
113
+ });
114
+ it("returns conditional query when only some rows allowed", () => {
115
+ const ability = buildAbility({ id: "alice", email: "a@x" });
116
+ const q = conditionsFor(ability, "update", "Post");
117
+ expect(q).toEqual({ $or: [{ authorId: "alice" }] });
118
+ });
119
+ it("returns null when no rules allow", () => {
120
+ const ability = buildAbility(null);
121
+ const q = conditionsFor(ability, "read", "Post");
122
+ expect(q).toBeNull();
123
+ });
124
+ it("admin gets unrestricted query via manage:all", () => {
125
+ const ability = buildAbility({ id: "x", email: "x@x", roles: ["admin"] });
126
+ const q = conditionsFor(ability, "delete", "Post");
127
+ expect(q).toEqual({});
128
+ });
129
+ });
130
+ //# sourceMappingURL=polish.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"polish.test.js","sourceRoot":"","sources":["../../src/__tests__/polish.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * RBAC integration — proves CASL is wired correctly through the framework:
3
+ *
4
+ * - `defineAbility` builds an Ability factory.
5
+ * - `rbacPlugin` registers it + installs dispatch middleware.
6
+ * - Actions with tuple `policy: [verb, subject]` get auto-enforced.
7
+ * - Programmatic `abilityFromCtx(ctx)` works inside handlers.
8
+ * - `subject(name, instance)` lets us check instance-level conditions.
9
+ *
10
+ * The Koa per-route gate (`canKoa`) has its own end-to-end coverage in
11
+ * @nwire/auth's `routes.test.ts` — the http builder lifts the container
12
+ * onto `ctx.state._container` so `canKoa` can resolve `buildAbility`.
13
+ */
14
+ export {};
15
+ //# sourceMappingURL=rbac.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rbac.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/rbac.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG"}
@@ -0,0 +1,139 @@
1
+ /**
2
+ * RBAC integration — proves CASL is wired correctly through the framework:
3
+ *
4
+ * - `defineAbility` builds an Ability factory.
5
+ * - `rbacPlugin` registers it + installs dispatch middleware.
6
+ * - Actions with tuple `policy: [verb, subject]` get auto-enforced.
7
+ * - Programmatic `abilityFromCtx(ctx)` works inside handlers.
8
+ * - `subject(name, instance)` lets us check instance-level conditions.
9
+ *
10
+ * The Koa per-route gate (`canKoa`) has its own end-to-end coverage in
11
+ * @nwire/auth's `routes.test.ts` — the http builder lifts the container
12
+ * onto `ctx.state._container` so `canKoa` can resolve `buildAbility`.
13
+ */
14
+ import { describe, it, expect } from "vitest";
15
+ import { z } from "zod";
16
+ import { createApp, defineAction, defineHandler, defineModule, seedEnvelope, } from "@nwire/forge";
17
+ import { ForbiddenError } from "@nwire/auth";
18
+ import { defineAbility, rbacPlugin, abilityFor, abilityFromCtx, subject, } from "../rbac";
19
+ // ─── Shared fixtures ─────────────────────────────────────────────
20
+ const buildAbility = defineAbility((user, { allow }) => {
21
+ if (!user)
22
+ return; // anonymous: nothing allowed
23
+ if (user.roles?.includes("admin")) {
24
+ allow("manage", "all");
25
+ return;
26
+ }
27
+ allow("read", "Post");
28
+ allow("create", "Post");
29
+ allow("update", "Post", { authorId: user.id });
30
+ allow("delete", "Post", { authorId: user.id });
31
+ });
32
+ const admin = { id: "u-admin", email: "a@x", roles: ["admin"] };
33
+ const alice = { id: "u-alice", email: "alice@x" };
34
+ const bob = { id: "u-bob", email: "bob@x" };
35
+ describe("@nwire/rbac — pure ability layer", () => {
36
+ it("anonymous user gets no permissions", () => {
37
+ const a = abilityFor(buildAbility, null);
38
+ expect(a.can("read", "Post")).toBe(false);
39
+ });
40
+ it("admin can manage all subjects", () => {
41
+ const a = abilityFor(buildAbility, admin);
42
+ expect(a.can("delete", "Post")).toBe(true);
43
+ expect(a.can("manage", "Anything")).toBe(true);
44
+ });
45
+ it("author can update their own posts but not someone else's", () => {
46
+ const a = abilityFor(buildAbility, alice);
47
+ expect(a.can("create", "Post")).toBe(true);
48
+ const alicesPost = subject("Post", { id: "p1", authorId: alice.id });
49
+ const bobsPost = subject("Post", { id: "p2", authorId: bob.id });
50
+ expect(a.can("update", alicesPost)).toBe(true);
51
+ expect(a.can("update", bobsPost)).toBe(false);
52
+ expect(a.cannot("delete", bobsPost)).toBe(true);
53
+ });
54
+ });
55
+ // ─── Plugin integration ──────────────────────────────────────────
56
+ const empty = defineModule("empty", {});
57
+ describe("@nwire/rbac — rbacPlugin enforces action.policy", () => {
58
+ it("allows action when user has the ability", async () => {
59
+ const created = defineAction({
60
+ name: "posts.create",
61
+ schema: z.object({ title: z.string() }),
62
+ policy: ["create", "Post"],
63
+ });
64
+ const createdHandler = defineHandler(created, async () => undefined);
65
+ const m = defineModule("posts", { actions: [created], handlers: [createdHandler] });
66
+ const app = createApp({
67
+ modules: [m],
68
+ plugins: [rbacPlugin({ buildAbility })],
69
+ });
70
+ await app.start();
71
+ // alice has create-Post permission → resolves
72
+ await expect(app.runtime.dispatch(created, { title: "hello" }, seedEnvelope({ user: alice, userId: alice.id }))).resolves.not.toThrow();
73
+ await app.stop();
74
+ });
75
+ it("denies action when user lacks the ability", async () => {
76
+ const deleted = defineAction({
77
+ name: "posts.delete",
78
+ schema: z.object({ id: z.string() }),
79
+ policy: ["delete", "Post"],
80
+ });
81
+ const deletedHandler = defineHandler(deleted, async () => undefined);
82
+ const m = defineModule("posts", { actions: [deleted], handlers: [deletedHandler] });
83
+ const app = createApp({
84
+ modules: [m],
85
+ plugins: [rbacPlugin({ buildAbility })],
86
+ });
87
+ await app.start();
88
+ // anonymous has no permissions → throws ForbiddenError
89
+ await expect(app.runtime.dispatch(deleted, { id: "p1" })).rejects.toThrow(ForbiddenError);
90
+ await app.stop();
91
+ });
92
+ it("allows action when policy is omitted entirely", async () => {
93
+ const open = defineAction({
94
+ name: "posts.list",
95
+ schema: z.object({}),
96
+ });
97
+ const openHandler = defineHandler(open, async () => undefined);
98
+ const m = defineModule("posts", { actions: [open], handlers: [openHandler] });
99
+ const app = createApp({
100
+ modules: [m],
101
+ plugins: [rbacPlugin({ buildAbility })],
102
+ });
103
+ await app.start();
104
+ // anonymous + no policy → allowed
105
+ await expect(app.runtime.dispatch(open, {})).resolves.not.toThrow();
106
+ await app.stop();
107
+ });
108
+ });
109
+ // ─── Programmatic ctx.ability access ─────────────────────────────
110
+ describe("@nwire/rbac — abilityFromCtx for programmatic checks", () => {
111
+ it("handler can call abilityFromCtx and check an instance with subject()", async () => {
112
+ let observed = {};
113
+ const inspect = defineAction({
114
+ name: "posts.inspect",
115
+ schema: z.object({ postId: z.string() }),
116
+ });
117
+ const inspectHandler = defineHandler(inspect, async (_input, ctx) => {
118
+ const ability = abilityFromCtx(ctx);
119
+ const alicesPost = subject("Post", { id: "p1", authorId: alice.id });
120
+ const bobsPost = subject("Post", { id: "p2", authorId: bob.id });
121
+ observed = {
122
+ canUpdateAlices: ability.can("update", alicesPost),
123
+ canUpdateBobs: ability.can("update", bobsPost),
124
+ };
125
+ return undefined;
126
+ });
127
+ const m = defineModule("posts", { actions: [inspect], handlers: [inspectHandler] });
128
+ const app = createApp({
129
+ modules: [m],
130
+ plugins: [rbacPlugin({ buildAbility })],
131
+ });
132
+ await app.start();
133
+ await app.runtime.dispatch(inspect, { postId: "p1" }, seedEnvelope({ user: alice, userId: alice.id }));
134
+ expect(observed.canUpdateAlices).toBe(true);
135
+ expect(observed.canUpdateBobs).toBe(false);
136
+ await app.stop();
137
+ });
138
+ });
139
+ //# sourceMappingURL=rbac.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rbac.test.js","sourceRoot":"","sources":["../../src/__tests__/rbac.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Structured permission errors — the synthesis's "Why behind the No".
3
+ *
4
+ * Generic `Forbidden` is fine for a 403, but tells the UI nothing about
5
+ * WHY. With structured errors the API can surface "you don't own this
6
+ * post" vs "your plan doesn't allow this" vs "your role lacks the
7
+ * permission" — much better UX than a flat "denied".
8
+ *
9
+ * try {
10
+ * await authorize(user, "delete", post);
11
+ * } catch (e) {
12
+ * if (e instanceof OwnershipMismatchError) showOwnerNotice();
13
+ * else if (e instanceof RoleMissingError) showRoleNotice(e.requiredRole);
14
+ * else throw e;
15
+ * }
16
+ *
17
+ * All extend `ActionDeniedError` so a single catch handles them uniformly
18
+ * when the UI doesn't care about the reason.
19
+ */
20
+ import { ForbiddenError } from "@nwire/auth";
21
+ export interface ActionDeniedFields {
22
+ readonly action: string;
23
+ readonly subject: string;
24
+ readonly userId?: string;
25
+ readonly reason?: string;
26
+ }
27
+ /** Discriminator field on subclasses; lets `switch (e.denialReason)` work. */
28
+ export type DenialReason = "denied" | "ownership-mismatch" | "scope-missing" | "role-missing";
29
+ /**
30
+ * The base — every permission denial extends this so a single
31
+ * `catch (e instanceof ActionDeniedError)` works uniformly. The parent
32
+ * `ForbiddenError` keeps its `$kind: "auth.forbidden"` (HTTP 403); we
33
+ * add `denialReason` so subclasses can narrow without conflicting.
34
+ */
35
+ export declare class ActionDeniedError extends ForbiddenError {
36
+ readonly denialReason: DenialReason;
37
+ readonly action: string;
38
+ readonly subject: string;
39
+ readonly userId?: string;
40
+ readonly reason?: string;
41
+ constructor(fields: ActionDeniedFields);
42
+ }
43
+ /**
44
+ * Specialized — the user has the right role/scope but isn't related to
45
+ * the instance (e.g., not the owner of this Post). UI hint: "this is
46
+ * someone else's resource."
47
+ */
48
+ export declare class OwnershipMismatchError extends ActionDeniedError {
49
+ readonly denialReason: "ownership-mismatch";
50
+ readonly expectedOwnerId?: string;
51
+ constructor(fields: ActionDeniedFields & {
52
+ readonly expectedOwnerId?: string;
53
+ });
54
+ }
55
+ /**
56
+ * Specialized — the user lacks the required scope (OAuth-style fine-
57
+ * grained capability). UI hint: "your app integration needs to request
58
+ * this scope".
59
+ */
60
+ export declare class ScopeMissingError extends ActionDeniedError {
61
+ readonly denialReason: "scope-missing";
62
+ readonly requiredScope: string;
63
+ constructor(fields: ActionDeniedFields & {
64
+ readonly requiredScope: string;
65
+ });
66
+ }
67
+ /**
68
+ * Specialized — the user lacks the required role (RBAC). UI hint:
69
+ * "ask your admin to elevate your role".
70
+ */
71
+ export declare class RoleMissingError extends ActionDeniedError {
72
+ readonly denialReason: "role-missing";
73
+ readonly requiredRole: string;
74
+ constructor(fields: ActionDeniedFields & {
75
+ readonly requiredRole: string;
76
+ });
77
+ }
78
+ export declare const isOwnershipMismatchError: (x: unknown) => x is OwnershipMismatchError;
79
+ export declare const isScopeMissingError: (x: unknown) => x is ScopeMissingError;
80
+ export declare const isRoleMissingError: (x: unknown) => x is RoleMissingError;
81
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7C,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,MAAM,EAAG,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,8EAA8E;AAC9E,MAAM,MAAM,YAAY,GACpB,QAAQ,GACR,oBAAoB,GACpB,eAAe,GACf,cAAc,CAAC;AAEnB;;;;;GAKG;AACH,qBAAa,iBAAkB,SAAQ,cAAc;IACnD,QAAQ,CAAC,YAAY,EAAE,YAAY,CAAY;IAC/C,QAAQ,CAAC,MAAM,EAAG,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;gBAEb,MAAM,EAAE,kBAAkB;CAQvC;AAED;;;;GAIG;AACH,qBAAa,sBAAuB,SAAQ,iBAAiB;IAC3D,SAAkB,YAAY,EAAG,oBAAoB,CAAU;IAC/D,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;gBACtB,MAAM,EAAE,kBAAkB,GAAG;QAAE,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAA;KAAE;CAK/E;AAED;;;;GAIG;AACH,qBAAa,iBAAkB,SAAQ,iBAAiB;IACtD,SAAkB,YAAY,EAAG,eAAe,CAAU;IAC1D,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;gBACnB,MAAM,EAAE,kBAAkB,GAAG;QAAE,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;KAAE;CAK5E;AAED;;;GAGG;AACH,qBAAa,gBAAiB,SAAQ,iBAAiB;IACrD,SAAkB,YAAY,EAAG,cAAc,CAAU;IACzD,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;gBAClB,MAAM,EAAE,kBAAkB,GAAG;QAAE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;KAAE;CAK3E;AAED,eAAO,MAAM,wBAAwB,GAAI,GAAG,OAAO,KAAG,CAAC,IAAI,sBACtB,CAAC;AACtC,eAAO,MAAM,mBAAmB,GAAI,GAAG,OAAO,KAAG,CAAC,IAAI,iBACtB,CAAC;AACjC,eAAO,MAAM,kBAAkB,GAAI,GAAG,OAAO,KAAG,CAAC,IAAI,gBACtB,CAAC"}
package/dist/errors.js ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Structured permission errors — the synthesis's "Why behind the No".
3
+ *
4
+ * Generic `Forbidden` is fine for a 403, but tells the UI nothing about
5
+ * WHY. With structured errors the API can surface "you don't own this
6
+ * post" vs "your plan doesn't allow this" vs "your role lacks the
7
+ * permission" — much better UX than a flat "denied".
8
+ *
9
+ * try {
10
+ * await authorize(user, "delete", post);
11
+ * } catch (e) {
12
+ * if (e instanceof OwnershipMismatchError) showOwnerNotice();
13
+ * else if (e instanceof RoleMissingError) showRoleNotice(e.requiredRole);
14
+ * else throw e;
15
+ * }
16
+ *
17
+ * All extend `ActionDeniedError` so a single catch handles them uniformly
18
+ * when the UI doesn't care about the reason.
19
+ */
20
+ import { ForbiddenError } from "@nwire/auth";
21
+ /**
22
+ * The base — every permission denial extends this so a single
23
+ * `catch (e instanceof ActionDeniedError)` works uniformly. The parent
24
+ * `ForbiddenError` keeps its `$kind: "auth.forbidden"` (HTTP 403); we
25
+ * add `denialReason` so subclasses can narrow without conflicting.
26
+ */
27
+ export class ActionDeniedError extends ForbiddenError {
28
+ denialReason = "denied";
29
+ action;
30
+ subject;
31
+ userId;
32
+ reason;
33
+ constructor(fields) {
34
+ super(fields.reason ?? `Not allowed to ${fields.action} ${fields.subject}`);
35
+ this.name = "ActionDeniedError";
36
+ this.action = fields.action;
37
+ this.subject = fields.subject;
38
+ this.userId = fields.userId;
39
+ this.reason = fields.reason;
40
+ }
41
+ }
42
+ /**
43
+ * Specialized — the user has the right role/scope but isn't related to
44
+ * the instance (e.g., not the owner of this Post). UI hint: "this is
45
+ * someone else's resource."
46
+ */
47
+ export class OwnershipMismatchError extends ActionDeniedError {
48
+ denialReason = "ownership-mismatch";
49
+ expectedOwnerId;
50
+ constructor(fields) {
51
+ super({ ...fields, reason: fields.reason ?? "Not the owner of this resource" });
52
+ this.name = "OwnershipMismatchError";
53
+ this.expectedOwnerId = fields.expectedOwnerId;
54
+ }
55
+ }
56
+ /**
57
+ * Specialized — the user lacks the required scope (OAuth-style fine-
58
+ * grained capability). UI hint: "your app integration needs to request
59
+ * this scope".
60
+ */
61
+ export class ScopeMissingError extends ActionDeniedError {
62
+ denialReason = "scope-missing";
63
+ requiredScope;
64
+ constructor(fields) {
65
+ super({ ...fields, reason: fields.reason ?? `Missing scope: ${fields.requiredScope}` });
66
+ this.name = "ScopeMissingError";
67
+ this.requiredScope = fields.requiredScope;
68
+ }
69
+ }
70
+ /**
71
+ * Specialized — the user lacks the required role (RBAC). UI hint:
72
+ * "ask your admin to elevate your role".
73
+ */
74
+ export class RoleMissingError extends ActionDeniedError {
75
+ denialReason = "role-missing";
76
+ requiredRole;
77
+ constructor(fields) {
78
+ super({ ...fields, reason: fields.reason ?? `Missing role: ${fields.requiredRole}` });
79
+ this.name = "RoleMissingError";
80
+ this.requiredRole = fields.requiredRole;
81
+ }
82
+ }
83
+ export const isOwnershipMismatchError = (x) => x instanceof OwnershipMismatchError;
84
+ export const isScopeMissingError = (x) => x instanceof ScopeMissingError;
85
+ export const isRoleMissingError = (x) => x instanceof RoleMissingError;
86
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAgB7C;;;;;GAKG;AACH,MAAM,OAAO,iBAAkB,SAAQ,cAAc;IAC1C,YAAY,GAAiB,QAAQ,CAAC;IACtC,MAAM,CAAU;IAChB,OAAO,CAAS;IAChB,MAAM,CAAU;IAChB,MAAM,CAAU;IAEzB,YAAY,MAA0B;QACpC,KAAK,CAAC,MAAM,CAAC,MAAM,IAAI,kBAAkB,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,OAAO,EAAE,CAAC,CAAC;QAC5E,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAC;QAChC,IAAI,CAAC,MAAM,GAAI,MAAM,CAAC,MAAM,CAAC;QAC7B,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;QAC9B,IAAI,CAAC,MAAM,GAAI,MAAM,CAAC,MAAM,CAAC;QAC7B,IAAI,CAAC,MAAM,GAAI,MAAM,CAAC,MAAM,CAAC;IAC/B,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,OAAO,sBAAuB,SAAQ,iBAAiB;IACzC,YAAY,GAAG,oBAA6B,CAAC;IACtD,eAAe,CAAU;IAClC,YAAY,MAAkE;QAC5E,KAAK,CAAC,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,gCAAgC,EAAE,CAAC,CAAC;QAChF,IAAI,CAAC,IAAI,GAAG,wBAAwB,CAAC;QACrC,IAAI,CAAC,eAAe,GAAG,MAAM,CAAC,eAAe,CAAC;IAChD,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,OAAO,iBAAkB,SAAQ,iBAAiB;IACpC,YAAY,GAAG,eAAwB,CAAC;IACjD,aAAa,CAAS;IAC/B,YAAY,MAA+D;QACzE,KAAK,CAAC,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,kBAAkB,MAAM,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC;QACxF,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAC;QAChC,IAAI,CAAC,aAAa,GAAG,MAAM,CAAC,aAAa,CAAC;IAC5C,CAAC;CACF;AAED;;;GAGG;AACH,MAAM,OAAO,gBAAiB,SAAQ,iBAAiB;IACnC,YAAY,GAAG,cAAuB,CAAC;IAChD,YAAY,CAAS;IAC9B,YAAY,MAA8D;QACxE,KAAK,CAAC,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,iBAAiB,MAAM,CAAC,YAAY,EAAE,EAAE,CAAC,CAAC;QACtF,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;QAC/B,IAAI,CAAC,YAAY,GAAG,MAAM,CAAC,YAAY,CAAC;IAC1C,CAAC;CACF;AAED,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,CAAU,EAA+B,EAAE,CAClF,CAAC,YAAY,sBAAsB,CAAC;AACtC,MAAM,CAAC,MAAM,mBAAmB,GAAG,CAAC,CAAU,EAA0B,EAAE,CACxE,CAAC,YAAY,iBAAiB,CAAC;AACjC,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAU,EAAyB,EAAE,CACtE,CAAC,YAAY,gBAAgB,CAAC"}