@daltonr/authwrite-hateoas 0.1.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.
@@ -0,0 +1,80 @@
1
+ import type { AuthEvaluator, Subject, Resource, Action } from '@daltonr/authwrite-core';
2
+ export interface LinkTemplate {
3
+ /** The URL for this action. May be a plain string or a pre-resolved href. */
4
+ href: string;
5
+ /** HTTP method. Defaults to 'GET'. */
6
+ method?: string;
7
+ /** Human-readable label, useful for UI rendering. */
8
+ title?: string;
9
+ /** Any additional properties (templated, type, etc.) */
10
+ [key: string]: unknown;
11
+ }
12
+ /** A map of action name → link. Only permitted actions are present. */
13
+ export type LinkMap = Record<string, LinkTemplate>;
14
+ export interface BuildLinksConfig<S extends Subject = Subject, R extends Resource = Resource> {
15
+ engine: AuthEvaluator<S, R>;
16
+ subject: S;
17
+ resource?: R;
18
+ /**
19
+ * Map of action name → link template.
20
+ * Only entries whose action is permitted will appear in the result.
21
+ */
22
+ actions: Record<Action, LinkTemplate>;
23
+ }
24
+ /**
25
+ * Evaluates every action in `config.actions` and returns a `LinkMap`
26
+ * containing only the links the subject is permitted to follow.
27
+ *
28
+ * ```typescript
29
+ * const links = await buildLinks({
30
+ * engine,
31
+ * subject,
32
+ * resource,
33
+ * actions: {
34
+ * read: { href: `/documents/${id}`, method: 'GET' },
35
+ * write: { href: `/documents/${id}`, method: 'PUT' },
36
+ * delete: { href: `/documents/${id}`, method: 'DELETE' },
37
+ * archive: { href: `/documents/${id}/archive`, method: 'POST' },
38
+ * },
39
+ * })
40
+ * // → only links whose policy decision was allowed: true
41
+ * ```
42
+ */
43
+ export declare function buildLinks<S extends Subject = Subject, R extends Resource = Resource>(config: BuildLinksConfig<S, R>): Promise<LinkMap>;
44
+ export interface EmbedLinksConfig<S extends Subject = Subject, R extends Resource = Resource> extends BuildLinksConfig<S, R> {
45
+ /**
46
+ * A `self` link added unconditionally — it is not subject to policy
47
+ * evaluation since the resource has already been fetched and returned.
48
+ */
49
+ self?: LinkTemplate;
50
+ }
51
+ /**
52
+ * Builds permission-aware links and merges them into `data` as a `_links`
53
+ * property, following HAL (application/hal+json) conventions.
54
+ *
55
+ * ```typescript
56
+ * const body = await embedLinks(document, {
57
+ * engine, subject, resource: document,
58
+ * self: { href: `/documents/${document.id}`, method: 'GET' },
59
+ * actions: {
60
+ * write: { href: `/documents/${document.id}`, method: 'PUT' },
61
+ * delete: { href: `/documents/${document.id}`, method: 'DELETE' },
62
+ * },
63
+ * })
64
+ * // → { ...document, _links: { self: {...}, write: {...} } }
65
+ * // (delete absent — not permitted)
66
+ * ```
67
+ */
68
+ export declare function embedLinks<T extends object, S extends Subject = Subject, R extends Resource = Resource>(data: T, config: EmbedLinksConfig<S, R>): Promise<T & {
69
+ _links: LinkMap;
70
+ }>;
71
+ /**
72
+ * Synchronous variant for cases where you have already called `engine.permissions()`
73
+ * and want to build links without an additional async round-trip.
74
+ *
75
+ * ```typescript
76
+ * const perms = await engine.permissions(subject, resource, ['read', 'write', 'delete'])
77
+ * const links = linksFromDecisions(perms, actionTemplates)
78
+ * ```
79
+ */
80
+ export declare function linksFromDecisions(permissions: Record<string, boolean>, actions: Record<Action, LinkTemplate>): LinkMap;
package/dist/index.js ADDED
@@ -0,0 +1,78 @@
1
+ // ─── buildLinks ───────────────────────────────────────────────────────────────
2
+ /**
3
+ * Evaluates every action in `config.actions` and returns a `LinkMap`
4
+ * containing only the links the subject is permitted to follow.
5
+ *
6
+ * ```typescript
7
+ * const links = await buildLinks({
8
+ * engine,
9
+ * subject,
10
+ * resource,
11
+ * actions: {
12
+ * read: { href: `/documents/${id}`, method: 'GET' },
13
+ * write: { href: `/documents/${id}`, method: 'PUT' },
14
+ * delete: { href: `/documents/${id}`, method: 'DELETE' },
15
+ * archive: { href: `/documents/${id}/archive`, method: 'POST' },
16
+ * },
17
+ * })
18
+ * // → only links whose policy decision was allowed: true
19
+ * ```
20
+ */
21
+ export async function buildLinks(config) {
22
+ const actionNames = Object.keys(config.actions);
23
+ const permitted = config.resource !== undefined
24
+ ? await config.engine.permissions(config.subject, config.resource, actionNames)
25
+ : await config.engine.permissions(config.subject, actionNames);
26
+ const links = {};
27
+ for (const action of actionNames) {
28
+ if (permitted[action]) {
29
+ links[action] = config.actions[action];
30
+ }
31
+ }
32
+ return links;
33
+ }
34
+ /**
35
+ * Builds permission-aware links and merges them into `data` as a `_links`
36
+ * property, following HAL (application/hal+json) conventions.
37
+ *
38
+ * ```typescript
39
+ * const body = await embedLinks(document, {
40
+ * engine, subject, resource: document,
41
+ * self: { href: `/documents/${document.id}`, method: 'GET' },
42
+ * actions: {
43
+ * write: { href: `/documents/${document.id}`, method: 'PUT' },
44
+ * delete: { href: `/documents/${document.id}`, method: 'DELETE' },
45
+ * },
46
+ * })
47
+ * // → { ...document, _links: { self: {...}, write: {...} } }
48
+ * // (delete absent — not permitted)
49
+ * ```
50
+ */
51
+ export async function embedLinks(data, config) {
52
+ const permitted = await buildLinks(config);
53
+ const _links = {};
54
+ if (config.self)
55
+ _links['self'] = config.self;
56
+ Object.assign(_links, permitted);
57
+ return { ...data, _links };
58
+ }
59
+ // ─── Decision-based link filtering (sync, from pre-fetched decisions) ─────────
60
+ /**
61
+ * Synchronous variant for cases where you have already called `engine.permissions()`
62
+ * and want to build links without an additional async round-trip.
63
+ *
64
+ * ```typescript
65
+ * const perms = await engine.permissions(subject, resource, ['read', 'write', 'delete'])
66
+ * const links = linksFromDecisions(perms, actionTemplates)
67
+ * ```
68
+ */
69
+ export function linksFromDecisions(permissions, actions) {
70
+ const links = {};
71
+ for (const [action, template] of Object.entries(actions)) {
72
+ if (permissions[action]) {
73
+ links[action] = template;
74
+ }
75
+ }
76
+ return links;
77
+ }
78
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAkCA,iFAAiF;AAEjF;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAG9B,MAA8B;IAC9B,MAAM,WAAW,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IAE/C,MAAM,SAAS,GAAG,MAAM,CAAC,QAAQ,KAAK,SAAS;QAC7C,CAAC,CAAC,MAAM,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC;QAC/E,CAAC,CAAC,MAAM,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,OAAO,EAAE,WAAW,CAAC,CAAA;IAEhE,MAAM,KAAK,GAAY,EAAE,CAAA;IACzB,KAAK,MAAM,MAAM,IAAI,WAAW,EAAE,CAAC;QACjC,IAAI,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC;YACtB,KAAK,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAA;QACxC,CAAC;IACH,CAAC;IAED,OAAO,KAAK,CAAA;AACd,CAAC;AAeD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAI9B,IAAO,EAAE,MAA8B;IACvC,MAAM,SAAS,GAAG,MAAM,UAAU,CAAC,MAAM,CAAC,CAAA;IAE1C,MAAM,MAAM,GAAY,EAAE,CAAA;IAC1B,IAAI,MAAM,CAAC,IAAI;QAAE,MAAM,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,CAAA;IAC7C,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAA;IAEhC,OAAO,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,CAAA;AAC5B,CAAC;AAED,iFAAiF;AAEjF;;;;;;;;GAQG;AACH,MAAM,UAAU,kBAAkB,CAChC,WAAoC,EACpC,OAAyC;IAEzC,MAAM,KAAK,GAAY,EAAE,CAAA;IACzB,KAAK,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACzD,IAAI,WAAW,CAAC,MAAM,CAAC,EAAE,CAAC;YACxB,KAAK,CAAC,MAAM,CAAC,GAAG,QAAQ,CAAA;QAC1B,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAA;AACd,CAAC"}
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@daltonr/authwrite-hateoas",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "HATEOAS link building for Authwrite — permission-aware hypermedia links from policy decisions.",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/richardadalton/authwrite.git",
10
+ "directory": "packages/hateoas"
11
+ },
12
+ "keywords": ["authorization", "authz", "hateoas", "hal", "hypermedia", "links"],
13
+ "sideEffects": false,
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ }
19
+ },
20
+ "main": "dist/index.js",
21
+ "types": "dist/index.d.ts",
22
+ "files": ["dist", "src", "README.md", "LICENSE"],
23
+ "scripts": {
24
+ "build": "tsc -p tsconfig.json",
25
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
26
+ "prepublishOnly": "test -d dist && echo 'dist already built, skipping' || (npm run clean && npm run build)"
27
+ },
28
+ "dependencies": {
29
+ "@daltonr/authwrite-core": "*"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ }
34
+ }
package/src/index.ts ADDED
@@ -0,0 +1,142 @@
1
+ import type { AuthEvaluator, Subject, Resource, Action } from '@daltonr/authwrite-core'
2
+
3
+ // ─── Link types ───────────────────────────────────────────────────────────────
4
+
5
+ export interface LinkTemplate {
6
+ /** The URL for this action. May be a plain string or a pre-resolved href. */
7
+ href: string
8
+ /** HTTP method. Defaults to 'GET'. */
9
+ method?: string
10
+ /** Human-readable label, useful for UI rendering. */
11
+ title?: string
12
+ /** Any additional properties (templated, type, etc.) */
13
+ [key: string]: unknown
14
+ }
15
+
16
+ /** A map of action name → link. Only permitted actions are present. */
17
+ export type LinkMap = Record<string, LinkTemplate>
18
+
19
+ // ─── buildLinks config ────────────────────────────────────────────────────────
20
+
21
+ export interface BuildLinksConfig<
22
+ S extends Subject = Subject,
23
+ R extends Resource = Resource,
24
+ > {
25
+ engine: AuthEvaluator<S, R>
26
+ subject: S
27
+ resource?: R
28
+ /**
29
+ * Map of action name → link template.
30
+ * Only entries whose action is permitted will appear in the result.
31
+ */
32
+ actions: Record<Action, LinkTemplate>
33
+ }
34
+
35
+ // ─── buildLinks ───────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Evaluates every action in `config.actions` and returns a `LinkMap`
39
+ * containing only the links the subject is permitted to follow.
40
+ *
41
+ * ```typescript
42
+ * const links = await buildLinks({
43
+ * engine,
44
+ * subject,
45
+ * resource,
46
+ * actions: {
47
+ * read: { href: `/documents/${id}`, method: 'GET' },
48
+ * write: { href: `/documents/${id}`, method: 'PUT' },
49
+ * delete: { href: `/documents/${id}`, method: 'DELETE' },
50
+ * archive: { href: `/documents/${id}/archive`, method: 'POST' },
51
+ * },
52
+ * })
53
+ * // → only links whose policy decision was allowed: true
54
+ * ```
55
+ */
56
+ export async function buildLinks<
57
+ S extends Subject = Subject,
58
+ R extends Resource = Resource,
59
+ >(config: BuildLinksConfig<S, R>): Promise<LinkMap> {
60
+ const actionNames = Object.keys(config.actions)
61
+
62
+ const permitted = config.resource !== undefined
63
+ ? await config.engine.permissions(config.subject, config.resource, actionNames)
64
+ : await config.engine.permissions(config.subject, actionNames)
65
+
66
+ const links: LinkMap = {}
67
+ for (const action of actionNames) {
68
+ if (permitted[action]) {
69
+ links[action] = config.actions[action]
70
+ }
71
+ }
72
+
73
+ return links
74
+ }
75
+
76
+ // ─── embedLinks ───────────────────────────────────────────────────────────────
77
+
78
+ export interface EmbedLinksConfig<
79
+ S extends Subject = Subject,
80
+ R extends Resource = Resource,
81
+ > extends BuildLinksConfig<S, R> {
82
+ /**
83
+ * A `self` link added unconditionally — it is not subject to policy
84
+ * evaluation since the resource has already been fetched and returned.
85
+ */
86
+ self?: LinkTemplate
87
+ }
88
+
89
+ /**
90
+ * Builds permission-aware links and merges them into `data` as a `_links`
91
+ * property, following HAL (application/hal+json) conventions.
92
+ *
93
+ * ```typescript
94
+ * const body = await embedLinks(document, {
95
+ * engine, subject, resource: document,
96
+ * self: { href: `/documents/${document.id}`, method: 'GET' },
97
+ * actions: {
98
+ * write: { href: `/documents/${document.id}`, method: 'PUT' },
99
+ * delete: { href: `/documents/${document.id}`, method: 'DELETE' },
100
+ * },
101
+ * })
102
+ * // → { ...document, _links: { self: {...}, write: {...} } }
103
+ * // (delete absent — not permitted)
104
+ * ```
105
+ */
106
+ export async function embedLinks<
107
+ T extends object,
108
+ S extends Subject = Subject,
109
+ R extends Resource = Resource,
110
+ >(data: T, config: EmbedLinksConfig<S, R>): Promise<T & { _links: LinkMap }> {
111
+ const permitted = await buildLinks(config)
112
+
113
+ const _links: LinkMap = {}
114
+ if (config.self) _links['self'] = config.self
115
+ Object.assign(_links, permitted)
116
+
117
+ return { ...data, _links }
118
+ }
119
+
120
+ // ─── Decision-based link filtering (sync, from pre-fetched decisions) ─────────
121
+
122
+ /**
123
+ * Synchronous variant for cases where you have already called `engine.permissions()`
124
+ * and want to build links without an additional async round-trip.
125
+ *
126
+ * ```typescript
127
+ * const perms = await engine.permissions(subject, resource, ['read', 'write', 'delete'])
128
+ * const links = linksFromDecisions(perms, actionTemplates)
129
+ * ```
130
+ */
131
+ export function linksFromDecisions(
132
+ permissions: Record<string, boolean>,
133
+ actions: Record<Action, LinkTemplate>,
134
+ ): LinkMap {
135
+ const links: LinkMap = {}
136
+ for (const [action, template] of Object.entries(actions)) {
137
+ if (permissions[action]) {
138
+ links[action] = template
139
+ }
140
+ }
141
+ return links
142
+ }