@suluk/hono 0.1.2 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/hono",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "The Suluk derivation engine: minimal Hono+Zod RouteContracts in; v4 doc (dynamic per principal+time), validation, contract tests, and doc-coverage audit out. CANDIDATE tooling.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -19,7 +19,7 @@
19
19
  ".": "./src/index.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@suluk/core": "^0.1.7",
22
+ "@suluk/core": "^0.1.11",
23
23
  "@suluk/zod": "^0.1.2",
24
24
  "ajv": "^8.20.0",
25
25
  "ajv-formats": "^3.0.1"
package/src/access.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The ROW-LEVEL authorization engine that pairs with {@link enforceAccess} (the wire-level facet enforcer). A
3
+ * generic CRUD app declares each entity's access MODE; this maps mode → per-operation Rule → a decision: may the
4
+ * caller run it, and is the query SCOPED to their own rows. Pure (no Hono Context — takes the resolved
5
+ * `{ isAdmin, principal }`), so it is testable and reusable. `ruleToRequires` projects a Rule to the wire
6
+ * `requires` level so ONE declaration drives BOTH the CRUD gate AND the `x-suluk-access` facet/docs.
7
+ */
8
+ import type { AccessRequires } from "./enforce";
9
+
10
+ /** A CRUD operation's authorization rule. */
11
+ export type Rule = "any" | "owner" | "admin" | "none";
12
+ /** The five CRUD operations' rules for one access mode. */
13
+ export interface Policy { list: Rule; get: Rule; create: Rule; update: Rule; delete: Rule }
14
+ /** The built-in access modes (a sensible SaaS/commerce default set; override via `policyFor`'s `policies` arg). */
15
+ export type AccessMode = "public" | "admin" | "submit" | "owned" | "ownedAppend" | "ownedReadonly" | "review";
16
+
17
+ /** The opt-in default mode→policy preset. Adopt by reference, or pass your own matrix to {@link policyFor}. */
18
+ export const DEFAULT_POLICIES: Record<AccessMode, Policy> = {
19
+ // catalog + content: world-readable, admin-writable
20
+ public: { list: "any", get: "any", create: "admin", update: "admin", delete: "admin" },
21
+ // sensitive (e.g. discount codes): admin-only — even reads (listing all is a leak)
22
+ admin: { list: "admin", get: "admin", create: "admin", update: "admin", delete: "admin" },
23
+ // public submissions (contact, newsletter): anyone may create; only admins read/modify
24
+ submit: { list: "admin", get: "admin", create: "any", update: "admin", delete: "admin" },
25
+ // user-owned: each caller only sees/mutates their own rows (admin sees all)
26
+ owned: { list: "owner", get: "owner", create: "owner", update: "owner", delete: "owner" },
27
+ // owned + append-only to the user — place + read your own, but only the system/admin mutates (no self-mark-paid)
28
+ ownedAppend: { list: "owner", get: "owner", create: "owner", update: "admin", delete: "admin" },
29
+ // owned but READ-ONLY to the user — the system/admin even creates it (e.g. billing rows)
30
+ ownedReadonly: { list: "owner", get: "owner", create: "admin", update: "admin", delete: "admin" },
31
+ // public-read, owner-write (e.g. product reviews): everyone reads; you only edit your own
32
+ review: { list: "any", get: "any", create: "owner", update: "owner", delete: "owner" },
33
+ };
34
+
35
+ /** The policy for an access mode (default: owned when an ownerCol is present, else public). `policies` overrides the preset. */
36
+ export function policyFor(access: AccessMode | undefined, ownerCol?: string, policies: Record<AccessMode, Policy> = DEFAULT_POLICIES): Policy {
37
+ return policies[access ?? (ownerCol ? "owned" : "public")];
38
+ }
39
+
40
+ /** The resolved caller identity a gate decision needs (compute from your Hono Context: isAdmin flag + principal id). */
41
+ export interface GateIdentity { isAdmin: boolean; principal: string | null }
42
+ /** A gate decision: may the op run, scope the query to the owner, and — when denied — the honest status. */
43
+ export interface GateDecision { ok: boolean; scopeOwner: boolean; status?: 401 | 403 }
44
+
45
+ /**
46
+ * Decide whether a caller may run an op (per the rule), whether to scope the query to their own rows, and the honest
47
+ * deny status. FAIL-CLOSED: an `owner` op with no principal is 401 (the wire must enforce what `x-suluk-access`
48
+ * claims — a null-scoped empty 200 would let the facet lie); `admin` with no principal is 401, signed-in-non-admin is
49
+ * 403; `none` hard-denies 403. A signed-in owner is scoped to their rows; an admin sees all.
50
+ */
51
+ export function gate(rule: Rule, id: GateIdentity): GateDecision {
52
+ switch (rule) {
53
+ case "any": return { ok: true, scopeOwner: false };
54
+ case "owner":
55
+ if (id.isAdmin) return { ok: true, scopeOwner: false }; // admin sees all
56
+ if (!id.principal) return { ok: false, scopeOwner: false, status: 401 }; // owner op needs a verified caller
57
+ return { ok: true, scopeOwner: true }; // signed-in: scoped to own rows
58
+ case "admin":
59
+ if (!id.principal) return { ok: false, scopeOwner: false, status: 401 }; // authenticate first (RFC 7235)
60
+ return id.isAdmin ? { ok: true, scopeOwner: false } : { ok: false, scopeOwner: false, status: 403 }; // signed-in non-admin → 403
61
+ default: return { ok: false, scopeOwner: false, status: 403 };
62
+ }
63
+ }
64
+
65
+ /** Project a CRUD Rule to the wire-level `requires` (so one rule drives BOTH the gate AND the x-suluk-access facet). */
66
+ const RULE_TO_REQUIRES: Record<Rule, AccessRequires> = { any: "anyone", owner: "authenticated", admin: "admin", none: "admin" };
67
+ export function ruleToRequires(rule: Rule): AccessRequires { return RULE_TO_REQUIRES[rule]; }
package/src/index.ts CHANGED
@@ -10,6 +10,8 @@ export { contractChecks, runContractChecks, type Check, type CheckRun } from "./
10
10
  export { validateSchema2020, type SchemaCheck } from "./schema-check";
11
11
  export { mount } from "./mount";
12
12
  export { enforceAccess, createGuard, type EnforceAccessConfig, type IdentityConfig, type Guard, type AccessFacet, type AccessRequires } from "./enforce";
13
+ // the row-level CRUD authorization engine (mode→policy→rule→decision + owner-scoping) that pairs with enforceAccess.
14
+ export { gate, policyFor, ruleToRequires, DEFAULT_POLICIES, type Rule, type Policy, type AccessMode, type GateIdentity, type GateDecision } from "./access";
13
15
  export { SulukHttpError, HttpErrors, type SulukHttpErrorInit } from "./errors";
14
16
  export { onError, type OnErrorOptions } from "./on-error";
15
17
  export {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The row-level CRUD authorization engine — gate() decisions (fail-closed 401/403 + owner-scoping), policyFor
3
+ * preset resolution + override, and ruleToRequires (the rule→wire-requires projection that keeps the CRUD gate and
4
+ * the x-suluk-access facet in lockstep).
5
+ */
6
+ import { test, expect, describe } from "bun:test";
7
+ import { gate, policyFor, ruleToRequires, DEFAULT_POLICIES, type Rule } from "../src/index";
8
+
9
+ describe("gate", () => {
10
+ test("any → open, no scope", () => {
11
+ expect(gate("any", { isAdmin: false, principal: null })).toEqual({ ok: true, scopeOwner: false });
12
+ });
13
+ test("owner: anon → 401, signed-in → scoped, admin → all", () => {
14
+ expect(gate("owner", { isAdmin: false, principal: null })).toEqual({ ok: false, scopeOwner: false, status: 401 });
15
+ expect(gate("owner", { isAdmin: false, principal: "u1" })).toEqual({ ok: true, scopeOwner: true });
16
+ expect(gate("owner", { isAdmin: true, principal: "u1" })).toEqual({ ok: true, scopeOwner: false }); // admin sees all
17
+ });
18
+ test("admin: anon → 401 (authenticate first), signed-in-non-admin → 403, admin → ok", () => {
19
+ expect(gate("admin", { isAdmin: false, principal: null })).toEqual({ ok: false, scopeOwner: false, status: 401 });
20
+ expect(gate("admin", { isAdmin: false, principal: "u1" })).toEqual({ ok: false, scopeOwner: false, status: 403 });
21
+ expect(gate("admin", { isAdmin: true, principal: "u1" })).toEqual({ ok: true, scopeOwner: false });
22
+ });
23
+ test("none → hard 403", () => {
24
+ expect(gate("none", { isAdmin: true, principal: "u1" })).toEqual({ ok: false, scopeOwner: false, status: 403 });
25
+ });
26
+ });
27
+
28
+ describe("policyFor", () => {
29
+ test("resolves modes, defaults owned-with-ownerCol / public-without, honors an override matrix", () => {
30
+ expect(policyFor("owned").update).toBe("owner");
31
+ expect(policyFor("ownedAppend").update).toBe("admin"); // no self-mutate
32
+ expect(policyFor(undefined, "customerId")).toEqual(DEFAULT_POLICIES.owned);
33
+ expect(policyFor(undefined)).toEqual(DEFAULT_POLICIES.public);
34
+ const custom = { ...DEFAULT_POLICIES, public: { ...DEFAULT_POLICIES.public, list: "admin" as Rule } };
35
+ expect(policyFor("public", undefined, custom).list).toBe("admin");
36
+ });
37
+ });
38
+
39
+ describe("ruleToRequires", () => {
40
+ test("projects a CRUD rule to the wire requires level", () => {
41
+ expect(ruleToRequires("any")).toBe("anyone");
42
+ expect(ruleToRequires("owner")).toBe("authenticated");
43
+ expect(ruleToRequires("admin")).toBe("admin");
44
+ expect(ruleToRequires("none")).toBe("admin");
45
+ });
46
+ });