@dwk/wac 0.1.0-beta.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,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 David W. Keith
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # `@dwk/wac`
2
+
3
+ Web Access Control (WAC) evaluation for Solid Pods. A pure, dependency-light
4
+ library consumed by [`@dwk/solid-pod`](../solid-pod): it takes ACL graphs (as
5
+ plain quads, compatible with [`@dwk/rdf`](../rdf) terms) plus request facts and
6
+ returns an authorization decision. No Cloudflare bindings; unit-testable without
7
+ a Workers runtime.
8
+
9
+ See the [spec](../../spec/packages/wac.md) for the authoritative requirements,
10
+ and the [Solid WAC specification](https://solidproject.org/TR/wac) for the model.
11
+
12
+ ## What it does
13
+
14
+ - **Effective-ACL walk** — resolves the nearest applicable `.acl`, honoring
15
+ `acl:default` on ancestor containers. A resource's own `acl:accessTo` ACL
16
+ takes precedence over inherited `acl:default` ACLs.
17
+ - **Modes** — `acl:Read`, `acl:Write`, `acl:Append`, `acl:Control`.
18
+ - **Subjects** — `acl:agent`, `acl:agentGroup`, and `acl:agentClass`
19
+ (including `foaf:Agent` for public access and `acl:AuthenticatedAgent`).
20
+ - **Origin** — `acl:origin` acts as a per-authorization allow-list.
21
+ - **Append vs Write** — an `append` request is satisfied by `acl:Append` _or_
22
+ `acl:Write`; a delete must be requested as `write`, which `acl:Append` alone
23
+ never grants.
24
+
25
+ > Decisions MUST NOT be memoized in eventually-consistent stores (e.g. KV); the
26
+ > walk is cheap and is meant to run against strongly-consistent ACL state.
27
+
28
+ ## Usage
29
+
30
+ ```ts
31
+ import { evaluateAccess, type AclResource } from "@dwk/wac";
32
+
33
+ // Build the effective-ACL chain, nearest first. The requested resource's own
34
+ // ACL uses scope "accessTo"; ancestor container ACLs use scope "default".
35
+ const chain: AclResource[] = [
36
+ { target: "https://alice.example/notes/secret.ttl", scope: "accessTo", quads: resourceAclQuads },
37
+ { target: "https://alice.example/notes/", scope: "default", quads: containerAclQuads },
38
+ ];
39
+
40
+ const decision = evaluateAccess(
41
+ { mode: "write", agent: "https://bob.example/card#me", origin: "https://app.example" },
42
+ chain,
43
+ );
44
+
45
+ if (!decision.granted) {
46
+ // 401 if unauthenticated, otherwise 403.
47
+ }
48
+ ```
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `@dwk/wac` — Web Access Control (WAC) evaluation for Solid Pods.
3
+ *
4
+ * @remarks
5
+ * Pure library: takes ACL graphs and request facts as plain data and returns an
6
+ * authorization decision. Tied to the Solid/WAC model by design, but carries no
7
+ * Cloudflare bindings and is unit-testable without a Workers runtime.
8
+ *
9
+ * The evaluator performs the effective-ACL walk (resolving the nearest
10
+ * applicable `.acl`, honoring `acl:default` on ancestor containers), evaluates
11
+ * `acl:Read`, `acl:Write`, `acl:Append`, and `acl:Control`, and supports
12
+ * `acl:agent`, `acl:agentGroup`, `acl:agentClass` (including `foaf:Agent` and
13
+ * `acl:AuthenticatedAgent`), and `acl:origin`.
14
+ *
15
+ * Callers MUST NOT memoize decisions in eventually-consistent stores; the walk
16
+ * is cheap and is meant to run against strongly-consistent ACL state.
17
+ *
18
+ * @see {@link https://solidproject.org/TR/wac | Solid WAC specification}
19
+ * @see spec/packages/wac.md
20
+ * @packageDocumentation
21
+ */
22
+ import type { StoredTerm } from "@dwk/rdf";
23
+ /**
24
+ * A plain RDF triple for ACL evaluation.
25
+ *
26
+ * @remarks
27
+ * Subject and predicate are always IRIs; the object may be an IRI (named node)
28
+ * shorthand string or a full {@link StoredTerm} from `@dwk/rdf`. WAC only
29
+ * matches named-node objects, so literals and blank nodes never match.
30
+ */
31
+ export interface AclQuad {
32
+ /** Subject IRI. */
33
+ subject: string;
34
+ /** Predicate IRI. */
35
+ predicate: string;
36
+ /** Object: an IRI string, or a `@dwk/rdf` term. */
37
+ object: string | StoredTerm;
38
+ }
39
+ /**
40
+ * An access mode that may be requested or granted.
41
+ *
42
+ * @remarks
43
+ * `append` is implied by `write`: a request for `append` is satisfied by either
44
+ * an `acl:Append` or an `acl:Write` grant. A delete (which is not insert-only)
45
+ * MUST be requested as `write`, never `append`.
46
+ */
47
+ export type AccessMode = "read" | "write" | "append" | "control";
48
+ /** How an ACL document's authorizations attach to a resource. */
49
+ export type AclScope = "accessTo" | "default";
50
+ /**
51
+ * A single ACL document in the effective-ACL chain, as plain RDF quads.
52
+ *
53
+ * @remarks
54
+ * The chain is ordered nearest-first: the requested resource's own ACL (with
55
+ * {@link AclScope | scope} `"accessTo"`) comes first, followed by ancestor
56
+ * container ACLs (scope `"default"`) from closest to farthest. **Only ACL
57
+ * documents that exist (have a representation) are included.**
58
+ *
59
+ * Per WAC §5.1 the effective ACL is the *first* ancestor whose ACL resource
60
+ * exists, "regardless of whether it contains matching authorizations". Because
61
+ * every chain entry represents an existing ACL document, the **first entry is
62
+ * the effective ACL and is authoritative**: {@link evaluateAccess} decides from
63
+ * it alone — granted or denied — and never falls through (fail open) to a
64
+ * farther ancestor's `acl:default`. Selecting *which* ancestor's ACL exists is
65
+ * the caller's job; this library does not climb the hierarchy by inspecting
66
+ * authorization content. Entries after the first are not consulted, so callers
67
+ * SHOULD pass exactly the single effective ACL.
68
+ */
69
+ export interface AclResource {
70
+ /**
71
+ * The IRI this ACL document governs: the requested resource for an
72
+ * `accessTo` entry, or the container for a `default` entry. Authorizations
73
+ * are matched against this IRI via the scope predicate.
74
+ */
75
+ target: string;
76
+ /** Which predicate links authorizations in this document to {@link target}. */
77
+ scope: AclScope;
78
+ /** The ACL document's statements, consumed from `@dwk/rdf`. */
79
+ quads: AclQuad[];
80
+ }
81
+ /** Facts about the agent and request being authorized. */
82
+ export interface AccessRequest {
83
+ /** The requested access mode. */
84
+ mode: AccessMode;
85
+ /** The authenticated agent's WebID, if any. Absence means unauthenticated. */
86
+ agent?: string;
87
+ /** Group IRIs the agent is a member of, matched against `acl:agentGroup`. */
88
+ groups?: string[];
89
+ /** The request `Origin`, matched against `acl:origin` when present. */
90
+ origin?: string;
91
+ }
92
+ /** The outcome of an authorization evaluation. */
93
+ export interface AccessDecision {
94
+ /** Whether the requested mode is granted. */
95
+ granted: boolean;
96
+ /**
97
+ * The modes granted to the agent by the effective ACL. Reports the modes
98
+ * literally asserted (`acl:Read`/`Write`/`Append`/`Control`); note that a
99
+ * `write` grant also satisfies an `append` request even when `append` is not
100
+ * listed here.
101
+ */
102
+ modes: AccessMode[];
103
+ /** The {@link AclResource.target} of the ACL that decided the request, if any. */
104
+ effectiveAcl?: string;
105
+ }
106
+ /**
107
+ * Evaluates a Web Access Control decision against an effective-ACL chain.
108
+ *
109
+ * @remarks
110
+ * Per WAC §5.1 the effective ACL is the *first* ancestor whose ACL resource
111
+ * exists, "regardless of whether it contains matching authorizations". The
112
+ * {@link AclResource | chain} lists existing ACL documents nearest-first, so its
113
+ * **first entry is the effective ACL and is authoritative**: the decision is
114
+ * made from that document alone — granted or denied — and never falls through
115
+ * (fail open) to a farther ancestor's `acl:default`, even when the document
116
+ * grants nothing for the target. This holds equally for a resource's own `.acl`
117
+ * (scope `"accessTo"`) and for an ancestor container's `acl:default`.
118
+ *
119
+ * Selecting *which* ancestor's ACL exists is the caller's responsibility (it is
120
+ * a function of resource existence, not authorization content); this library
121
+ * does not climb the hierarchy by inspecting authorizations, which would invert
122
+ * §5.1's stop condition. Entries after the first are not consulted.
123
+ *
124
+ * @param request - The agent and request facts to authorize.
125
+ * @param chain - The effective ACL as a one-element chain (nearest first).
126
+ * @returns The decision, including the granted modes and the effective ACL.
127
+ */
128
+ export declare function evaluateAccess(request: AccessRequest, chain: AclResource[]): AccessDecision;
129
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAE3C;;;;;;;GAOG;AACH,MAAM,WAAW,OAAO;IACtB,mBAAmB;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,qBAAqB;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,mDAAmD;IACnD,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC;CAC7B;AA4BD;;;;;;;GAOG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;AAEjE,iEAAiE;AACjE,MAAM,MAAM,QAAQ,GAAG,UAAU,GAAG,SAAS,CAAC;AAE9C;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,+EAA+E;IAC/E,KAAK,EAAE,QAAQ,CAAC;IAChB,+DAA+D;IAC/D,KAAK,EAAE,OAAO,EAAE,CAAC;CAClB;AAED,0DAA0D;AAC1D,MAAM,WAAW,aAAa;IAC5B,iCAAiC;IACjC,IAAI,EAAE,UAAU,CAAC;IACjB,8EAA8E;IAC9E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,kDAAkD;AAClD,MAAM,WAAW,cAAc;IAC7B,6CAA6C;IAC7C,OAAO,EAAE,OAAO,CAAC;IACjB;;;;;OAKG;IACH,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,kFAAkF;IAClF,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAgKD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,aAAa,EACtB,KAAK,EAAE,WAAW,EAAE,GACnB,cAAc,CA0BhB"}
package/dist/index.js ADDED
@@ -0,0 +1,220 @@
1
+ /**
2
+ * `@dwk/wac` — Web Access Control (WAC) evaluation for Solid Pods.
3
+ *
4
+ * @remarks
5
+ * Pure library: takes ACL graphs and request facts as plain data and returns an
6
+ * authorization decision. Tied to the Solid/WAC model by design, but carries no
7
+ * Cloudflare bindings and is unit-testable without a Workers runtime.
8
+ *
9
+ * The evaluator performs the effective-ACL walk (resolving the nearest
10
+ * applicable `.acl`, honoring `acl:default` on ancestor containers), evaluates
11
+ * `acl:Read`, `acl:Write`, `acl:Append`, and `acl:Control`, and supports
12
+ * `acl:agent`, `acl:agentGroup`, `acl:agentClass` (including `foaf:Agent` and
13
+ * `acl:AuthenticatedAgent`), and `acl:origin`.
14
+ *
15
+ * Callers MUST NOT memoize decisions in eventually-consistent stores; the walk
16
+ * is cheap and is meant to run against strongly-consistent ACL state.
17
+ *
18
+ * @see {@link https://solidproject.org/TR/wac | Solid WAC specification}
19
+ * @see spec/packages/wac.md
20
+ * @packageDocumentation
21
+ */
22
+ /** WAC vocabulary base IRI. */
23
+ const ACL = "http://www.w3.org/ns/auth/acl#";
24
+ /** `rdf:type`. */
25
+ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
26
+ /** `foaf:Agent` — the class of all agents (public access). */
27
+ const FOAF_AGENT = "http://xmlns.com/foaf/0.1/Agent";
28
+ const ACL_AUTHORIZATION = `${ACL}Authorization`;
29
+ const ACL_ACCESS_TO = `${ACL}accessTo`;
30
+ const ACL_DEFAULT = `${ACL}default`;
31
+ /** Legacy alias for `acl:default`, still seen in the wild. */
32
+ const ACL_DEFAULT_FOR_NEW = `${ACL}defaultForNew`;
33
+ const ACL_AGENT = `${ACL}agent`;
34
+ const ACL_AGENT_GROUP = `${ACL}agentGroup`;
35
+ const ACL_AGENT_CLASS = `${ACL}agentClass`;
36
+ const ACL_MODE = `${ACL}mode`;
37
+ const ACL_ORIGIN = `${ACL}origin`;
38
+ const ACL_AUTHENTICATED_AGENT = `${ACL}AuthenticatedAgent`;
39
+ const MODE_IRI = {
40
+ read: `${ACL}Read`,
41
+ write: `${ACL}Write`,
42
+ append: `${ACL}Append`,
43
+ control: `${ACL}Control`,
44
+ };
45
+ /**
46
+ * Returns the IRI of a quad object, or `undefined` if it is not a named node.
47
+ *
48
+ * Literals and blank nodes never match the named-node terms WAC cares about.
49
+ */
50
+ function iri(object) {
51
+ if (typeof object === "string") {
52
+ return object;
53
+ }
54
+ return object?.termType === "NamedNode" ? object.value : undefined;
55
+ }
56
+ /** Collects the named-node object IRIs for all `(subject, predicate, *)` quads. */
57
+ function collect(quads, subject, predicate) {
58
+ const values = [];
59
+ for (const q of quads) {
60
+ if (q.subject === subject && q.predicate === predicate) {
61
+ const value = iri(q.object);
62
+ if (value !== undefined) {
63
+ values.push(value);
64
+ }
65
+ }
66
+ }
67
+ return values;
68
+ }
69
+ /**
70
+ * Finds the authorizations in an ACL document that apply to its scoped target.
71
+ */
72
+ function findApplicableAuthorizations(acl) {
73
+ if (!acl || !acl.quads) {
74
+ return [];
75
+ }
76
+ const { quads, scope, target } = acl;
77
+ const subjects = new Set();
78
+ for (const q of quads) {
79
+ if (q.predicate === RDF_TYPE && iri(q.object) === ACL_AUTHORIZATION) {
80
+ subjects.add(q.subject);
81
+ }
82
+ }
83
+ const authorizations = [];
84
+ for (const subject of subjects) {
85
+ const scoped = quads.some((q) => {
86
+ if (q.subject !== subject || iri(q.object) !== target) {
87
+ return false;
88
+ }
89
+ if (scope === "accessTo") {
90
+ return q.predicate === ACL_ACCESS_TO;
91
+ }
92
+ return q.predicate === ACL_DEFAULT || q.predicate === ACL_DEFAULT_FOR_NEW;
93
+ });
94
+ if (!scoped) {
95
+ continue;
96
+ }
97
+ authorizations.push({
98
+ subject,
99
+ modes: collect(quads, subject, ACL_MODE),
100
+ agents: collect(quads, subject, ACL_AGENT),
101
+ agentGroups: collect(quads, subject, ACL_AGENT_GROUP),
102
+ agentClasses: collect(quads, subject, ACL_AGENT_CLASS),
103
+ origins: collect(quads, subject, ACL_ORIGIN),
104
+ });
105
+ }
106
+ return authorizations;
107
+ }
108
+ /** Whether an authorization applies to the requesting agent. */
109
+ function agentMatches(auth, request) {
110
+ // `foaf:Agent` is the public class — everyone, authenticated or not.
111
+ if (auth.agentClasses.includes(FOAF_AGENT)) {
112
+ return true;
113
+ }
114
+ // A non-empty WebID means authenticated; an empty string is not a valid
115
+ // identity and MUST NOT satisfy `acl:AuthenticatedAgent` or match a
116
+ // (malformed) empty `acl:agent`.
117
+ if (request.agent) {
118
+ if (auth.agentClasses.includes(ACL_AUTHENTICATED_AGENT)) {
119
+ return true;
120
+ }
121
+ if (auth.agents.includes(request.agent)) {
122
+ return true;
123
+ }
124
+ }
125
+ if (request.groups &&
126
+ auth.agentGroups.some((g) => request.groups.includes(g))) {
127
+ return true;
128
+ }
129
+ return false;
130
+ }
131
+ /**
132
+ * Normalizes an origin to its `scheme://host[:port]` form so that comparisons
133
+ * ignore case and trailing-slash differences. Falls back to the raw value when
134
+ * it is not a parseable absolute URL, keeping the match fail-closed.
135
+ */
136
+ function normalizeOrigin(value) {
137
+ try {
138
+ return new URL(value).origin;
139
+ }
140
+ catch {
141
+ return value;
142
+ }
143
+ }
144
+ /**
145
+ * Whether an authorization's origin restriction admits the request.
146
+ *
147
+ * An authorization with no `acl:origin` applies regardless of origin; one with
148
+ * origins acts as an allow-list. Both sides are normalized via {@link URL} so a
149
+ * correctly-configured allow-list is not defeated by case or a trailing slash.
150
+ */
151
+ function originMatches(auth, request) {
152
+ if (auth.origins.length === 0) {
153
+ return true;
154
+ }
155
+ if (request.origin === undefined) {
156
+ return false;
157
+ }
158
+ const requestOrigin = normalizeOrigin(request.origin);
159
+ return auth.origins.some((o) => normalizeOrigin(o) === requestOrigin);
160
+ }
161
+ /** Whether the granted mode IRIs satisfy the requested mode. */
162
+ function isModeSatisfied(required, granted) {
163
+ if (required === "append") {
164
+ // Append authorizes insert-only patches and is implied by Write.
165
+ return granted.has(MODE_IRI.append) || granted.has(MODE_IRI.write);
166
+ }
167
+ return granted.has(MODE_IRI[required]);
168
+ }
169
+ /** Maps the granted mode IRIs back to {@link AccessMode} values. */
170
+ function toAccessModes(granted) {
171
+ return Object.keys(MODE_IRI).filter((mode) => granted.has(MODE_IRI[mode]));
172
+ }
173
+ /**
174
+ * Evaluates a Web Access Control decision against an effective-ACL chain.
175
+ *
176
+ * @remarks
177
+ * Per WAC §5.1 the effective ACL is the *first* ancestor whose ACL resource
178
+ * exists, "regardless of whether it contains matching authorizations". The
179
+ * {@link AclResource | chain} lists existing ACL documents nearest-first, so its
180
+ * **first entry is the effective ACL and is authoritative**: the decision is
181
+ * made from that document alone — granted or denied — and never falls through
182
+ * (fail open) to a farther ancestor's `acl:default`, even when the document
183
+ * grants nothing for the target. This holds equally for a resource's own `.acl`
184
+ * (scope `"accessTo"`) and for an ancestor container's `acl:default`.
185
+ *
186
+ * Selecting *which* ancestor's ACL exists is the caller's responsibility (it is
187
+ * a function of resource existence, not authorization content); this library
188
+ * does not climb the hierarchy by inspecting authorizations, which would invert
189
+ * §5.1's stop condition. Entries after the first are not consulted.
190
+ *
191
+ * @param request - The agent and request facts to authorize.
192
+ * @param chain - The effective ACL as a one-element chain (nearest first).
193
+ * @returns The decision, including the granted modes and the effective ACL.
194
+ */
195
+ export function evaluateAccess(request, chain) {
196
+ if (!request || !chain || !Array.isArray(chain)) {
197
+ return { granted: false, modes: [] };
198
+ }
199
+ // The first chain entry is an existing ACL document, hence the effective ACL
200
+ // per §5.1; it is authoritative whether or not it carries a matching
201
+ // authorization, so the decision is taken from it alone (fail closed).
202
+ const acl = chain[0];
203
+ if (!acl) {
204
+ return { granted: false, modes: [] };
205
+ }
206
+ const granted = new Set();
207
+ for (const auth of findApplicableAuthorizations(acl)) {
208
+ if (agentMatches(auth, request) && originMatches(auth, request)) {
209
+ for (const mode of auth.modes) {
210
+ granted.add(mode);
211
+ }
212
+ }
213
+ }
214
+ return {
215
+ granted: isModeSatisfied(request.mode, granted),
216
+ modes: toAccessModes(granted),
217
+ effectiveAcl: acl.target,
218
+ };
219
+ }
220
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@dwk/wac",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "Web Access Control evaluation (effective-ACL walk, Append vs Write). Solid-specific helper.",
5
+ "keywords": [
6
+ "web-access-control",
7
+ "wac",
8
+ "solid",
9
+ "authorization",
10
+ "acl",
11
+ "linked-data"
12
+ ],
13
+ "type": "module",
14
+ "license": "ISC",
15
+ "author": "David W. Keith <me@dwk.io>",
16
+ "homepage": "https://github.com/davidwkeith/workers/tree/main/packages/wac#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/davidwkeith/workers.git",
20
+ "directory": "packages/wac"
21
+ },
22
+ "sideEffects": false,
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "src",
34
+ "!src/**/*.test.ts"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {
40
+ "@dwk/rdf": "0.1.0-beta.0"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.build.json",
44
+ "typecheck": "tsc -p tsconfig.json",
45
+ "clean": "rm -rf dist"
46
+ }
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,349 @@
1
+ /**
2
+ * `@dwk/wac` — Web Access Control (WAC) evaluation for Solid Pods.
3
+ *
4
+ * @remarks
5
+ * Pure library: takes ACL graphs and request facts as plain data and returns an
6
+ * authorization decision. Tied to the Solid/WAC model by design, but carries no
7
+ * Cloudflare bindings and is unit-testable without a Workers runtime.
8
+ *
9
+ * The evaluator performs the effective-ACL walk (resolving the nearest
10
+ * applicable `.acl`, honoring `acl:default` on ancestor containers), evaluates
11
+ * `acl:Read`, `acl:Write`, `acl:Append`, and `acl:Control`, and supports
12
+ * `acl:agent`, `acl:agentGroup`, `acl:agentClass` (including `foaf:Agent` and
13
+ * `acl:AuthenticatedAgent`), and `acl:origin`.
14
+ *
15
+ * Callers MUST NOT memoize decisions in eventually-consistent stores; the walk
16
+ * is cheap and is meant to run against strongly-consistent ACL state.
17
+ *
18
+ * @see {@link https://solidproject.org/TR/wac | Solid WAC specification}
19
+ * @see spec/packages/wac.md
20
+ * @packageDocumentation
21
+ */
22
+
23
+ import type { StoredTerm } from "@dwk/rdf";
24
+
25
+ /**
26
+ * A plain RDF triple for ACL evaluation.
27
+ *
28
+ * @remarks
29
+ * Subject and predicate are always IRIs; the object may be an IRI (named node)
30
+ * shorthand string or a full {@link StoredTerm} from `@dwk/rdf`. WAC only
31
+ * matches named-node objects, so literals and blank nodes never match.
32
+ */
33
+ export interface AclQuad {
34
+ /** Subject IRI. */
35
+ subject: string;
36
+ /** Predicate IRI. */
37
+ predicate: string;
38
+ /** Object: an IRI string, or a `@dwk/rdf` term. */
39
+ object: string | StoredTerm;
40
+ }
41
+
42
+ /** WAC vocabulary base IRI. */
43
+ const ACL = "http://www.w3.org/ns/auth/acl#";
44
+ /** `rdf:type`. */
45
+ const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
46
+ /** `foaf:Agent` — the class of all agents (public access). */
47
+ const FOAF_AGENT = "http://xmlns.com/foaf/0.1/Agent";
48
+
49
+ const ACL_AUTHORIZATION = `${ACL}Authorization`;
50
+ const ACL_ACCESS_TO = `${ACL}accessTo`;
51
+ const ACL_DEFAULT = `${ACL}default`;
52
+ /** Legacy alias for `acl:default`, still seen in the wild. */
53
+ const ACL_DEFAULT_FOR_NEW = `${ACL}defaultForNew`;
54
+ const ACL_AGENT = `${ACL}agent`;
55
+ const ACL_AGENT_GROUP = `${ACL}agentGroup`;
56
+ const ACL_AGENT_CLASS = `${ACL}agentClass`;
57
+ const ACL_MODE = `${ACL}mode`;
58
+ const ACL_ORIGIN = `${ACL}origin`;
59
+ const ACL_AUTHENTICATED_AGENT = `${ACL}AuthenticatedAgent`;
60
+
61
+ const MODE_IRI: Record<AccessMode, string> = {
62
+ read: `${ACL}Read`,
63
+ write: `${ACL}Write`,
64
+ append: `${ACL}Append`,
65
+ control: `${ACL}Control`,
66
+ };
67
+
68
+ /**
69
+ * An access mode that may be requested or granted.
70
+ *
71
+ * @remarks
72
+ * `append` is implied by `write`: a request for `append` is satisfied by either
73
+ * an `acl:Append` or an `acl:Write` grant. A delete (which is not insert-only)
74
+ * MUST be requested as `write`, never `append`.
75
+ */
76
+ export type AccessMode = "read" | "write" | "append" | "control";
77
+
78
+ /** How an ACL document's authorizations attach to a resource. */
79
+ export type AclScope = "accessTo" | "default";
80
+
81
+ /**
82
+ * A single ACL document in the effective-ACL chain, as plain RDF quads.
83
+ *
84
+ * @remarks
85
+ * The chain is ordered nearest-first: the requested resource's own ACL (with
86
+ * {@link AclScope | scope} `"accessTo"`) comes first, followed by ancestor
87
+ * container ACLs (scope `"default"`) from closest to farthest. **Only ACL
88
+ * documents that exist (have a representation) are included.**
89
+ *
90
+ * Per WAC §5.1 the effective ACL is the *first* ancestor whose ACL resource
91
+ * exists, "regardless of whether it contains matching authorizations". Because
92
+ * every chain entry represents an existing ACL document, the **first entry is
93
+ * the effective ACL and is authoritative**: {@link evaluateAccess} decides from
94
+ * it alone — granted or denied — and never falls through (fail open) to a
95
+ * farther ancestor's `acl:default`. Selecting *which* ancestor's ACL exists is
96
+ * the caller's job; this library does not climb the hierarchy by inspecting
97
+ * authorization content. Entries after the first are not consulted, so callers
98
+ * SHOULD pass exactly the single effective ACL.
99
+ */
100
+ export interface AclResource {
101
+ /**
102
+ * The IRI this ACL document governs: the requested resource for an
103
+ * `accessTo` entry, or the container for a `default` entry. Authorizations
104
+ * are matched against this IRI via the scope predicate.
105
+ */
106
+ target: string;
107
+ /** Which predicate links authorizations in this document to {@link target}. */
108
+ scope: AclScope;
109
+ /** The ACL document's statements, consumed from `@dwk/rdf`. */
110
+ quads: AclQuad[];
111
+ }
112
+
113
+ /** Facts about the agent and request being authorized. */
114
+ export interface AccessRequest {
115
+ /** The requested access mode. */
116
+ mode: AccessMode;
117
+ /** The authenticated agent's WebID, if any. Absence means unauthenticated. */
118
+ agent?: string;
119
+ /** Group IRIs the agent is a member of, matched against `acl:agentGroup`. */
120
+ groups?: string[];
121
+ /** The request `Origin`, matched against `acl:origin` when present. */
122
+ origin?: string;
123
+ }
124
+
125
+ /** The outcome of an authorization evaluation. */
126
+ export interface AccessDecision {
127
+ /** Whether the requested mode is granted. */
128
+ granted: boolean;
129
+ /**
130
+ * The modes granted to the agent by the effective ACL. Reports the modes
131
+ * literally asserted (`acl:Read`/`Write`/`Append`/`Control`); note that a
132
+ * `write` grant also satisfies an `append` request even when `append` is not
133
+ * listed here.
134
+ */
135
+ modes: AccessMode[];
136
+ /** The {@link AclResource.target} of the ACL that decided the request, if any. */
137
+ effectiveAcl?: string;
138
+ }
139
+
140
+ /** A normalized authorization extracted from an ACL document. */
141
+ interface Authorization {
142
+ subject: string;
143
+ modes: string[];
144
+ agents: string[];
145
+ agentGroups: string[];
146
+ agentClasses: string[];
147
+ origins: string[];
148
+ }
149
+
150
+ /**
151
+ * Returns the IRI of a quad object, or `undefined` if it is not a named node.
152
+ *
153
+ * Literals and blank nodes never match the named-node terms WAC cares about.
154
+ */
155
+ function iri(object: AclQuad["object"]): string | undefined {
156
+ if (typeof object === "string") {
157
+ return object;
158
+ }
159
+ return object?.termType === "NamedNode" ? object.value : undefined;
160
+ }
161
+
162
+ /** Collects the named-node object IRIs for all `(subject, predicate, *)` quads. */
163
+ function collect(
164
+ quads: AclQuad[],
165
+ subject: string,
166
+ predicate: string,
167
+ ): string[] {
168
+ const values: string[] = [];
169
+ for (const q of quads) {
170
+ if (q.subject === subject && q.predicate === predicate) {
171
+ const value = iri(q.object);
172
+ if (value !== undefined) {
173
+ values.push(value);
174
+ }
175
+ }
176
+ }
177
+ return values;
178
+ }
179
+
180
+ /**
181
+ * Finds the authorizations in an ACL document that apply to its scoped target.
182
+ */
183
+ function findApplicableAuthorizations(acl: AclResource): Authorization[] {
184
+ if (!acl || !acl.quads) {
185
+ return [];
186
+ }
187
+ const { quads, scope, target } = acl;
188
+
189
+ const subjects = new Set<string>();
190
+ for (const q of quads) {
191
+ if (q.predicate === RDF_TYPE && iri(q.object) === ACL_AUTHORIZATION) {
192
+ subjects.add(q.subject);
193
+ }
194
+ }
195
+
196
+ const authorizations: Authorization[] = [];
197
+ for (const subject of subjects) {
198
+ const scoped = quads.some((q) => {
199
+ if (q.subject !== subject || iri(q.object) !== target) {
200
+ return false;
201
+ }
202
+ if (scope === "accessTo") {
203
+ return q.predicate === ACL_ACCESS_TO;
204
+ }
205
+ return q.predicate === ACL_DEFAULT || q.predicate === ACL_DEFAULT_FOR_NEW;
206
+ });
207
+ if (!scoped) {
208
+ continue;
209
+ }
210
+ authorizations.push({
211
+ subject,
212
+ modes: collect(quads, subject, ACL_MODE),
213
+ agents: collect(quads, subject, ACL_AGENT),
214
+ agentGroups: collect(quads, subject, ACL_AGENT_GROUP),
215
+ agentClasses: collect(quads, subject, ACL_AGENT_CLASS),
216
+ origins: collect(quads, subject, ACL_ORIGIN),
217
+ });
218
+ }
219
+ return authorizations;
220
+ }
221
+
222
+ /** Whether an authorization applies to the requesting agent. */
223
+ function agentMatches(auth: Authorization, request: AccessRequest): boolean {
224
+ // `foaf:Agent` is the public class — everyone, authenticated or not.
225
+ if (auth.agentClasses.includes(FOAF_AGENT)) {
226
+ return true;
227
+ }
228
+ // A non-empty WebID means authenticated; an empty string is not a valid
229
+ // identity and MUST NOT satisfy `acl:AuthenticatedAgent` or match a
230
+ // (malformed) empty `acl:agent`.
231
+ if (request.agent) {
232
+ if (auth.agentClasses.includes(ACL_AUTHENTICATED_AGENT)) {
233
+ return true;
234
+ }
235
+ if (auth.agents.includes(request.agent)) {
236
+ return true;
237
+ }
238
+ }
239
+ if (
240
+ request.groups &&
241
+ auth.agentGroups.some((g) => request.groups!.includes(g))
242
+ ) {
243
+ return true;
244
+ }
245
+ return false;
246
+ }
247
+
248
+ /**
249
+ * Normalizes an origin to its `scheme://host[:port]` form so that comparisons
250
+ * ignore case and trailing-slash differences. Falls back to the raw value when
251
+ * it is not a parseable absolute URL, keeping the match fail-closed.
252
+ */
253
+ function normalizeOrigin(value: string): string {
254
+ try {
255
+ return new URL(value).origin;
256
+ } catch {
257
+ return value;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Whether an authorization's origin restriction admits the request.
263
+ *
264
+ * An authorization with no `acl:origin` applies regardless of origin; one with
265
+ * origins acts as an allow-list. Both sides are normalized via {@link URL} so a
266
+ * correctly-configured allow-list is not defeated by case or a trailing slash.
267
+ */
268
+ function originMatches(auth: Authorization, request: AccessRequest): boolean {
269
+ if (auth.origins.length === 0) {
270
+ return true;
271
+ }
272
+ if (request.origin === undefined) {
273
+ return false;
274
+ }
275
+ const requestOrigin = normalizeOrigin(request.origin);
276
+ return auth.origins.some((o) => normalizeOrigin(o) === requestOrigin);
277
+ }
278
+
279
+ /** Whether the granted mode IRIs satisfy the requested mode. */
280
+ function isModeSatisfied(
281
+ required: AccessMode,
282
+ granted: ReadonlySet<string>,
283
+ ): boolean {
284
+ if (required === "append") {
285
+ // Append authorizes insert-only patches and is implied by Write.
286
+ return granted.has(MODE_IRI.append) || granted.has(MODE_IRI.write);
287
+ }
288
+ return granted.has(MODE_IRI[required]);
289
+ }
290
+
291
+ /** Maps the granted mode IRIs back to {@link AccessMode} values. */
292
+ function toAccessModes(granted: ReadonlySet<string>): AccessMode[] {
293
+ return (Object.keys(MODE_IRI) as AccessMode[]).filter((mode) =>
294
+ granted.has(MODE_IRI[mode]),
295
+ );
296
+ }
297
+
298
+ /**
299
+ * Evaluates a Web Access Control decision against an effective-ACL chain.
300
+ *
301
+ * @remarks
302
+ * Per WAC §5.1 the effective ACL is the *first* ancestor whose ACL resource
303
+ * exists, "regardless of whether it contains matching authorizations". The
304
+ * {@link AclResource | chain} lists existing ACL documents nearest-first, so its
305
+ * **first entry is the effective ACL and is authoritative**: the decision is
306
+ * made from that document alone — granted or denied — and never falls through
307
+ * (fail open) to a farther ancestor's `acl:default`, even when the document
308
+ * grants nothing for the target. This holds equally for a resource's own `.acl`
309
+ * (scope `"accessTo"`) and for an ancestor container's `acl:default`.
310
+ *
311
+ * Selecting *which* ancestor's ACL exists is the caller's responsibility (it is
312
+ * a function of resource existence, not authorization content); this library
313
+ * does not climb the hierarchy by inspecting authorizations, which would invert
314
+ * §5.1's stop condition. Entries after the first are not consulted.
315
+ *
316
+ * @param request - The agent and request facts to authorize.
317
+ * @param chain - The effective ACL as a one-element chain (nearest first).
318
+ * @returns The decision, including the granted modes and the effective ACL.
319
+ */
320
+ export function evaluateAccess(
321
+ request: AccessRequest,
322
+ chain: AclResource[],
323
+ ): AccessDecision {
324
+ if (!request || !chain || !Array.isArray(chain)) {
325
+ return { granted: false, modes: [] };
326
+ }
327
+ // The first chain entry is an existing ACL document, hence the effective ACL
328
+ // per §5.1; it is authoritative whether or not it carries a matching
329
+ // authorization, so the decision is taken from it alone (fail closed).
330
+ const acl = chain[0];
331
+ if (!acl) {
332
+ return { granted: false, modes: [] };
333
+ }
334
+
335
+ const granted = new Set<string>();
336
+ for (const auth of findApplicableAuthorizations(acl)) {
337
+ if (agentMatches(auth, request) && originMatches(auth, request)) {
338
+ for (const mode of auth.modes) {
339
+ granted.add(mode);
340
+ }
341
+ }
342
+ }
343
+
344
+ return {
345
+ granted: isModeSatisfied(request.mode, granted),
346
+ modes: toAccessModes(granted),
347
+ effectiveAcl: acl.target,
348
+ };
349
+ }