@cfast/actions 0.0.1
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 +21 -0
- package/README.md +183 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.js +72 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +141 -0
- package/dist/types-CNqDBI0X.d.ts +49 -0
- package/dist/types-Dolh-eut.d.ts +220 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Schmidt
|
|
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,183 @@
|
|
|
1
|
+
# @cfast/actions
|
|
2
|
+
|
|
3
|
+
Reusable, type-safe, permission-aware actions for React Router. Define operations once — permissions and execution come from the same place.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
Create a factory that provides context (db, user, grants) for all actions:
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// app/actions.server.ts
|
|
11
|
+
import { createActions } from "@cfast/actions";
|
|
12
|
+
|
|
13
|
+
export const { createAction, composeActions } = createActions({
|
|
14
|
+
getContext: async ({ request }) => {
|
|
15
|
+
const ctx = await requireAuthContext(request);
|
|
16
|
+
const db = createCfDb(env.DB, ctx);
|
|
17
|
+
return { db, user: ctx.user, grants: ctx.grants };
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`createActions` returns two functions scoped to your context provider: `createAction` and `composeActions`.
|
|
23
|
+
|
|
24
|
+
## Defining Actions
|
|
25
|
+
|
|
26
|
+
`createAction<TInput, TResult>(operationsFn)` takes an operations function and returns an action definition with four facets:
|
|
27
|
+
|
|
28
|
+
- `.action` — React Router action handler
|
|
29
|
+
- `.loader(loaderFn)` — wraps a loader to inject `_actionPermissions`
|
|
30
|
+
- `.client` — descriptor for the `useActions` hook
|
|
31
|
+
- `.buildOperation(db, input, ctx)` — builds the raw `Operation` (for composition)
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
// app/actions/posts.ts
|
|
35
|
+
import { compose } from "@cfast/db";
|
|
36
|
+
import { eq } from "drizzle-orm";
|
|
37
|
+
import { createAction } from "~/actions.server";
|
|
38
|
+
import { posts, auditLogs } from "~/db/schema";
|
|
39
|
+
|
|
40
|
+
export const deletePost = createAction<{ postId: string }, Response>(
|
|
41
|
+
(db, input, ctx) =>
|
|
42
|
+
compose(
|
|
43
|
+
[
|
|
44
|
+
db.delete(posts).where(eq(posts.id, input.postId)),
|
|
45
|
+
db.insert(auditLogs).values({
|
|
46
|
+
id: nanoid(),
|
|
47
|
+
userId: ctx.user.id,
|
|
48
|
+
action: "post.deleted",
|
|
49
|
+
targetType: "post",
|
|
50
|
+
targetId: input.postId,
|
|
51
|
+
metadata: JSON.stringify({ title: input.title }),
|
|
52
|
+
}),
|
|
53
|
+
],
|
|
54
|
+
async (runDelete, runAudit) => {
|
|
55
|
+
await runDelete({});
|
|
56
|
+
await runAudit({});
|
|
57
|
+
return redirect("/");
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The operations function receives `(db, input, ctx)` where `ctx` has `{ db, user, grants }`. It returns an `Operation<TResult>` from `@cfast/db` — either a single operation or a `compose()`'d workflow.
|
|
64
|
+
|
|
65
|
+
## Composing Multiple Actions
|
|
66
|
+
|
|
67
|
+
When a route needs multiple actions, use `composeActions` with a named object:
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
// app/routes/posts.$slug.tsx
|
|
71
|
+
import { composeActions } from "~/actions.server";
|
|
72
|
+
import { deletePost, publishPost, unpublishPost, addComment } from "~/actions/posts";
|
|
73
|
+
|
|
74
|
+
const composed = composeActions({
|
|
75
|
+
deletePost,
|
|
76
|
+
publishPost,
|
|
77
|
+
unpublishPost,
|
|
78
|
+
addComment,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const action = composed.action;
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The object keys become the action discriminators. When a form submits with `<input type="hidden" name="_action" value="deletePost" />`, `composeActions` routes to the correct handler. JSON requests use `{ _action: "deletePost", ...input }`.
|
|
85
|
+
|
|
86
|
+
`composeActions` returns the same four facets as `createAction`: `.action`, `.loader()`, `.client`, and `.actions` (the original definitions).
|
|
87
|
+
|
|
88
|
+
## Loader Integration
|
|
89
|
+
|
|
90
|
+
Wrap your loader with `.loader()` to inject permission status for the client:
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
// Single action
|
|
94
|
+
export const loader = deletePost.loader(async ({ request, params }) => {
|
|
95
|
+
// ... your normal loader logic
|
|
96
|
+
return { post, author };
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Composed actions
|
|
100
|
+
export const loader = composed.loader(async ({ request, params }) => {
|
|
101
|
+
return { post, author };
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The wrapper calls `getContext`, builds each action's `Operation` to extract permission descriptors, checks them against the user's grants, and merges `_actionPermissions` into the loader data. The client never receives raw permission descriptors.
|
|
106
|
+
|
|
107
|
+
## Client Usage
|
|
108
|
+
|
|
109
|
+
The `useActions` hook reads `_actionPermissions` from loader data and provides submission controls per action:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
import { useActions } from "@cfast/actions/client";
|
|
113
|
+
|
|
114
|
+
function PostActions({ postId }: { postId: string }) {
|
|
115
|
+
const actions = useActions(composed.client);
|
|
116
|
+
|
|
117
|
+
const remove = actions.deletePost({ postId });
|
|
118
|
+
const publish = actions.publishPost({ postId });
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<>
|
|
122
|
+
<button
|
|
123
|
+
onClick={publish.submit}
|
|
124
|
+
disabled={!publish.permitted || publish.pending}
|
|
125
|
+
hidden={publish.invisible}
|
|
126
|
+
>
|
|
127
|
+
Publish
|
|
128
|
+
</button>
|
|
129
|
+
<button
|
|
130
|
+
onClick={remove.submit}
|
|
131
|
+
disabled={!remove.permitted || remove.pending}
|
|
132
|
+
>
|
|
133
|
+
Delete
|
|
134
|
+
</button>
|
|
135
|
+
</>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Each action function returns:
|
|
141
|
+
|
|
142
|
+
| Property | Type | Description |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `permitted` | `boolean` | Whether the user has the required structural permissions |
|
|
145
|
+
| `invisible` | `boolean` | `true` when the user lacks all permissions (hide the UI entirely) |
|
|
146
|
+
| `reason` | `string \| null` | Human-readable explanation when `permitted` is `false` |
|
|
147
|
+
| `submit` | `() => void` | Submits the action via fetcher with `_action` discriminator |
|
|
148
|
+
| `pending` | `boolean` | `true` while the fetcher is in flight |
|
|
149
|
+
| `data` | `unknown` | The action's return value after execution |
|
|
150
|
+
| `error` | `unknown` | Error if the action failed |
|
|
151
|
+
|
|
152
|
+
## Input Parsing
|
|
153
|
+
|
|
154
|
+
Actions accept input from both FormData and JSON. The `_action` field is stripped automatically:
|
|
155
|
+
|
|
156
|
+
- **FormData**: `<input name="_action" value="deletePost" />` + other fields
|
|
157
|
+
- **JSON**: `{ _action: "deletePost", postId: "123" }`
|
|
158
|
+
|
|
159
|
+
## Exports
|
|
160
|
+
|
|
161
|
+
Server (`@cfast/actions`):
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
export { createActions, checkPermissionStatus } from "./create-actions.js";
|
|
165
|
+
export type {
|
|
166
|
+
Serializable, ActionContext, RequestArgs, ActionsConfig,
|
|
167
|
+
OperationsFn, ActionPermissionStatus, ActionPermissionsMap,
|
|
168
|
+
ClientDescriptor, ActionDefinition, ComposedActions,
|
|
169
|
+
} from "./types.js";
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Client (`@cfast/actions/client`):
|
|
173
|
+
|
|
174
|
+
```typescript
|
|
175
|
+
export { useActions } from "./client/use-actions.js";
|
|
176
|
+
export type { ActionHookResult } from "./client/use-actions.js";
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Integration
|
|
180
|
+
|
|
181
|
+
- **`@cfast/db`** — Actions use `Operation` and `compose()` from `@cfast/db`. Permission descriptors are extracted from operations.
|
|
182
|
+
- **`@cfast/permissions`** — Grants flow through `getContext`. `checkPermissionStatus` matches grants against operation permission descriptors.
|
|
183
|
+
- **`@cfast/ui`** — UI components can consume `useActions()` for automatic permission-aware rendering.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { C as ClientDescriptor, S as Serializable } from './types-Dolh-eut.js';
|
|
2
|
+
import '@cfast/db';
|
|
3
|
+
import '@cfast/permissions';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a ClientDescriptor for use in client code without importing
|
|
7
|
+
* server modules. The action names must match the keys passed to
|
|
8
|
+
* `composeActions` or the single action name from `createAction`.
|
|
9
|
+
*/
|
|
10
|
+
declare function clientDescriptor(actionNames: readonly string[]): ClientDescriptor;
|
|
11
|
+
type ActionHookResult = {
|
|
12
|
+
permitted: boolean;
|
|
13
|
+
invisible: boolean;
|
|
14
|
+
reason: string | null;
|
|
15
|
+
submit: () => void;
|
|
16
|
+
pending: boolean;
|
|
17
|
+
data: unknown | undefined;
|
|
18
|
+
error: unknown | undefined;
|
|
19
|
+
};
|
|
20
|
+
declare function useActions(descriptor: ClientDescriptor): Record<string, (input?: Serializable) => ActionHookResult>;
|
|
21
|
+
|
|
22
|
+
export { type ActionHookResult, ClientDescriptor, clientDescriptor, useActions };
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// src/client/use-actions.ts
|
|
2
|
+
import { useLoaderData, useFetcher } from "react-router";
|
|
3
|
+
function clientDescriptor(actionNames) {
|
|
4
|
+
return {
|
|
5
|
+
_brand: "ActionClientDescriptor",
|
|
6
|
+
actionNames,
|
|
7
|
+
permissionsKey: "_actionPermissions"
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function asRecord(value) {
|
|
11
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
function isPermissionStatus(v) {
|
|
17
|
+
if (v === null || typeof v !== "object") return false;
|
|
18
|
+
const obj = v;
|
|
19
|
+
return typeof obj.permitted === "boolean" && typeof obj.invisible === "boolean" && (obj.reason === null || typeof obj.reason === "string");
|
|
20
|
+
}
|
|
21
|
+
function extractPermissions(loaderData) {
|
|
22
|
+
const raw = loaderData._actionPermissions;
|
|
23
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
const map = {};
|
|
27
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
28
|
+
if (isPermissionStatus(value)) {
|
|
29
|
+
map[key] = value;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return map;
|
|
33
|
+
}
|
|
34
|
+
function useActions(descriptor) {
|
|
35
|
+
const loaderData = asRecord(useLoaderData());
|
|
36
|
+
const permissions = extractPermissions(loaderData);
|
|
37
|
+
const fetchers = descriptor.actionNames.map(() => useFetcher());
|
|
38
|
+
const result = {};
|
|
39
|
+
for (let i = 0; i < descriptor.actionNames.length; i++) {
|
|
40
|
+
const name = descriptor.actionNames[i];
|
|
41
|
+
const fetcher = fetchers[i];
|
|
42
|
+
const status = permissions[name] ?? {
|
|
43
|
+
permitted: true,
|
|
44
|
+
invisible: false,
|
|
45
|
+
reason: null
|
|
46
|
+
};
|
|
47
|
+
result[name] = (input) => ({
|
|
48
|
+
...status,
|
|
49
|
+
submit: () => {
|
|
50
|
+
const formData = new FormData();
|
|
51
|
+
formData.set("_action", name);
|
|
52
|
+
if (input && typeof input === "object" && !Array.isArray(input)) {
|
|
53
|
+
const entries = Object.entries(input);
|
|
54
|
+
for (const [key, value] of entries) {
|
|
55
|
+
if (value !== null && value !== void 0) {
|
|
56
|
+
formData.set(key, String(value));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
fetcher.submit(formData, { method: "POST" });
|
|
61
|
+
},
|
|
62
|
+
pending: fetcher.state !== "idle",
|
|
63
|
+
data: fetcher.data,
|
|
64
|
+
error: void 0
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
export {
|
|
70
|
+
clientDescriptor,
|
|
71
|
+
useActions
|
|
72
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Grant, PermissionDescriptor } from '@cfast/permissions';
|
|
2
|
+
import { A as ActionPermissionStatus, a as ActionsConfig, O as OperationsFn, b as ActionDefinition, c as ComposedActions } from './types-Dolh-eut.js';
|
|
3
|
+
export { d as ActionContext, e as ActionPermissionsMap, C as ClientDescriptor, R as RequestArgs, S as Serializable } from './types-Dolh-eut.js';
|
|
4
|
+
import '@cfast/db';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Checks a user's {@link Grant | grants} against a set of permission descriptors
|
|
8
|
+
* and returns an {@link ActionPermissionStatus}.
|
|
9
|
+
*
|
|
10
|
+
* If no descriptors are provided the action is unconditionally permitted.
|
|
11
|
+
* When some descriptors are denied, `permitted` is `false` and `reason`
|
|
12
|
+
* lists the missing permissions. When *all* descriptors are denied,
|
|
13
|
+
* `invisible` is also `true` (indicating the UI should hide the control entirely).
|
|
14
|
+
*
|
|
15
|
+
* @param grants - The user's resolved permission grants.
|
|
16
|
+
* @param descriptors - Permission descriptors extracted from an operation.
|
|
17
|
+
* @returns The resolved {@link ActionPermissionStatus} for the action.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { checkPermissionStatus } from "@cfast/actions";
|
|
22
|
+
*
|
|
23
|
+
* const status = checkPermissionStatus(user.grants, operation.permissions);
|
|
24
|
+
* if (!status.permitted) {
|
|
25
|
+
* console.log(status.reason); // "Missing permissions: delete on posts"
|
|
26
|
+
* }
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
declare function checkPermissionStatus(grants: Grant[], descriptors: PermissionDescriptor[]): ActionPermissionStatus;
|
|
30
|
+
/**
|
|
31
|
+
* Creates a scoped action factory bound to a shared context provider.
|
|
32
|
+
*
|
|
33
|
+
* Returns two functions — `createAction` and `composeActions` — that share the
|
|
34
|
+
* same `getContext` callback. This ensures every action in the application resolves
|
|
35
|
+
* its database, user, and grants consistently.
|
|
36
|
+
*
|
|
37
|
+
* @typeParam TUser - The shape of the authenticated user object.
|
|
38
|
+
* @param config - The {@link ActionsConfig} providing the `getContext` callback.
|
|
39
|
+
* @returns An object with `createAction` and `composeActions` functions.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { createActions } from "@cfast/actions";
|
|
44
|
+
*
|
|
45
|
+
* export const { createAction, composeActions } = createActions({
|
|
46
|
+
* getContext: async ({ request }) => {
|
|
47
|
+
* const ctx = await requireAuthContext(request);
|
|
48
|
+
* const db = createCfDb(env.DB, ctx);
|
|
49
|
+
* return { db, user: ctx.user, grants: ctx.grants };
|
|
50
|
+
* },
|
|
51
|
+
* });
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
declare function createActions<TUser = any>(config: ActionsConfig<TUser>): {
|
|
55
|
+
createAction: <TInput, TResult>(operationsFn: OperationsFn<TInput, TResult, TUser>) => ActionDefinition<TInput, TResult, TUser>;
|
|
56
|
+
composeActions: <TActions extends Record<string, ActionDefinition<any, any, any>>>(actions: TActions) => ComposedActions<TActions>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export { ActionDefinition, ActionPermissionStatus, ActionsConfig, ComposedActions, OperationsFn, checkPermissionStatus, createActions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// src/create-actions.ts
|
|
2
|
+
import { getTableName } from "@cfast/permissions";
|
|
3
|
+
|
|
4
|
+
// src/parse-input.ts
|
|
5
|
+
async function parseBody(request) {
|
|
6
|
+
const contentType = request.headers.get("Content-Type") ?? "";
|
|
7
|
+
if (contentType.includes("application/json")) {
|
|
8
|
+
const body = await request.clone().json();
|
|
9
|
+
const { _action, ...input2 } = body;
|
|
10
|
+
return { actionName: typeof _action === "string" ? _action : null, input: input2 };
|
|
11
|
+
}
|
|
12
|
+
const formData = await request.clone().formData();
|
|
13
|
+
const input = {};
|
|
14
|
+
let actionName = null;
|
|
15
|
+
for (const [key, value] of formData.entries()) {
|
|
16
|
+
if (key === "_action") {
|
|
17
|
+
actionName = typeof value === "string" ? value : null;
|
|
18
|
+
} else {
|
|
19
|
+
input[key] = value;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return { actionName, input };
|
|
23
|
+
}
|
|
24
|
+
async function parseInput(request) {
|
|
25
|
+
const { input } = await parseBody(request);
|
|
26
|
+
return input;
|
|
27
|
+
}
|
|
28
|
+
async function extractActionName(request) {
|
|
29
|
+
const { actionName } = await parseBody(request);
|
|
30
|
+
return actionName;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// src/create-actions.ts
|
|
34
|
+
function checkPermissionStatus(grants, descriptors) {
|
|
35
|
+
if (descriptors.length === 0) {
|
|
36
|
+
return { permitted: true, invisible: false, reason: null };
|
|
37
|
+
}
|
|
38
|
+
const denied = [];
|
|
39
|
+
for (const desc of descriptors) {
|
|
40
|
+
const matched = grants.some((grant) => {
|
|
41
|
+
const actionMatch = grant.action === "manage" || grant.action === desc.action;
|
|
42
|
+
const subjectMatch = grant.subject === "all" || typeof grant.subject === "object" && getTableName(grant.subject) === getTableName(desc.table);
|
|
43
|
+
return actionMatch && subjectMatch;
|
|
44
|
+
});
|
|
45
|
+
if (!matched) {
|
|
46
|
+
denied.push(desc);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (denied.length === 0) {
|
|
50
|
+
return { permitted: true, invisible: false, reason: null };
|
|
51
|
+
}
|
|
52
|
+
const reason = denied.map((d) => `${d.action} on ${getTableName(d.table)}`).join(", ");
|
|
53
|
+
return {
|
|
54
|
+
permitted: false,
|
|
55
|
+
invisible: denied.length === descriptors.length,
|
|
56
|
+
reason: `Missing permissions: ${reason}`
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function createActions(config) {
|
|
60
|
+
let counter = 0;
|
|
61
|
+
function createAction(operationsFn) {
|
|
62
|
+
const actionId = `action_${++counter}`;
|
|
63
|
+
const action = async (args) => {
|
|
64
|
+
const ctx = await config.getContext(args);
|
|
65
|
+
const input = await parseInput(args.request);
|
|
66
|
+
const operation = operationsFn(ctx.db, input, ctx);
|
|
67
|
+
return operation.run({});
|
|
68
|
+
};
|
|
69
|
+
const loader = (loaderFn) => {
|
|
70
|
+
return async (args) => {
|
|
71
|
+
const [loaderData, ctx] = await Promise.all([
|
|
72
|
+
loaderFn(args),
|
|
73
|
+
config.getContext(args)
|
|
74
|
+
]);
|
|
75
|
+
const operation = operationsFn(ctx.db, {}, ctx);
|
|
76
|
+
const status = checkPermissionStatus(ctx.grants, operation.permissions);
|
|
77
|
+
const permissions = {
|
|
78
|
+
[actionId]: status
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
...loaderData,
|
|
82
|
+
_actionPermissions: permissions
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
const client = {
|
|
87
|
+
_brand: "ActionClientDescriptor",
|
|
88
|
+
actionNames: [actionId],
|
|
89
|
+
permissionsKey: "_actionPermissions"
|
|
90
|
+
};
|
|
91
|
+
const buildOperation = (db, input, ctx) => {
|
|
92
|
+
return operationsFn(db, input, ctx);
|
|
93
|
+
};
|
|
94
|
+
return { action, loader, client, buildOperation };
|
|
95
|
+
}
|
|
96
|
+
function composeActions(actions) {
|
|
97
|
+
const actionNames = Object.keys(actions);
|
|
98
|
+
const composedAction = async (args) => {
|
|
99
|
+
const actionName = await extractActionName(args.request);
|
|
100
|
+
if (!actionName || !(actionName in actions)) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`Unknown action: "${actionName}". Available actions: ${actionNames.join(", ")}`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return actions[actionName].action(args);
|
|
106
|
+
};
|
|
107
|
+
const composedLoader = (loaderFn) => {
|
|
108
|
+
return async (args) => {
|
|
109
|
+
const [loaderData, ctx] = await Promise.all([
|
|
110
|
+
loaderFn(args),
|
|
111
|
+
config.getContext(args)
|
|
112
|
+
]);
|
|
113
|
+
const permissions = {};
|
|
114
|
+
for (const [name, actionDef] of Object.entries(actions)) {
|
|
115
|
+
const operation = actionDef.buildOperation(ctx.db, {}, ctx);
|
|
116
|
+
permissions[name] = checkPermissionStatus(ctx.grants, operation.permissions);
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
...loaderData,
|
|
120
|
+
_actionPermissions: permissions
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
const client = {
|
|
125
|
+
_brand: "ActionClientDescriptor",
|
|
126
|
+
actionNames,
|
|
127
|
+
permissionsKey: "_actionPermissions"
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
action: composedAction,
|
|
131
|
+
loader: composedLoader,
|
|
132
|
+
client,
|
|
133
|
+
actions
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return { createAction, composeActions };
|
|
137
|
+
}
|
|
138
|
+
export {
|
|
139
|
+
checkPermissionStatus,
|
|
140
|
+
createActions
|
|
141
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Db, Operation } from '@cfast/db';
|
|
2
|
+
import { Grant } from '@cfast/permissions';
|
|
3
|
+
|
|
4
|
+
type Serializable = string | number | boolean | null | Serializable[] | {
|
|
5
|
+
[key: string]: Serializable;
|
|
6
|
+
};
|
|
7
|
+
type ActionContext<TUser> = {
|
|
8
|
+
db: Db;
|
|
9
|
+
user: TUser;
|
|
10
|
+
grants: Grant[];
|
|
11
|
+
};
|
|
12
|
+
type RequestArgs = {
|
|
13
|
+
request: Request;
|
|
14
|
+
params: Record<string, string | undefined>;
|
|
15
|
+
context?: unknown;
|
|
16
|
+
};
|
|
17
|
+
type ActionsConfig<TUser> = {
|
|
18
|
+
getContext: (args: RequestArgs) => Promise<ActionContext<TUser>>;
|
|
19
|
+
};
|
|
20
|
+
type OperationsFn<TInput, TResult, TUser> = (db: Db, input: TInput, ctx: ActionContext<TUser>) => Operation<TResult>;
|
|
21
|
+
type ActionPermissionStatus = {
|
|
22
|
+
permitted: boolean;
|
|
23
|
+
invisible: boolean;
|
|
24
|
+
reason: string | null;
|
|
25
|
+
};
|
|
26
|
+
type ActionPermissionsMap = Record<string, ActionPermissionStatus>;
|
|
27
|
+
type ClientDescriptor = {
|
|
28
|
+
_brand: "ActionClientDescriptor";
|
|
29
|
+
actionNames: readonly string[];
|
|
30
|
+
permissionsKey: string;
|
|
31
|
+
};
|
|
32
|
+
type ActionDefinition<TInput, TResult, TUser> = {
|
|
33
|
+
action: (args: RequestArgs) => Promise<TResult>;
|
|
34
|
+
loader: <TLoaderData extends Record<string, Serializable>>(loaderFn: (args: RequestArgs) => Promise<TLoaderData>) => (args: RequestArgs) => Promise<TLoaderData & {
|
|
35
|
+
_actionPermissions: ActionPermissionsMap;
|
|
36
|
+
}>;
|
|
37
|
+
client: ClientDescriptor;
|
|
38
|
+
buildOperation: (db: Db, input: TInput, ctx: ActionContext<TUser>) => Operation<TResult>;
|
|
39
|
+
};
|
|
40
|
+
type ComposedActions<TActions extends Record<string, ActionDefinition<any, any, any>>> = {
|
|
41
|
+
action: (args: RequestArgs) => Promise<unknown>;
|
|
42
|
+
loader: <TLoaderData extends Record<string, Serializable>>(loaderFn: (args: RequestArgs) => Promise<TLoaderData>) => (args: RequestArgs) => Promise<TLoaderData & {
|
|
43
|
+
_actionPermissions: ActionPermissionsMap;
|
|
44
|
+
}>;
|
|
45
|
+
client: ClientDescriptor;
|
|
46
|
+
actions: TActions;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type { ActionPermissionStatus as A, ClientDescriptor as C, OperationsFn as O, RequestArgs as R, Serializable as S, ActionsConfig as a, ActionDefinition as b, ComposedActions as c, ActionContext as d, ActionPermissionsMap as e };
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { Db, Operation } from '@cfast/db';
|
|
2
|
+
import { Grant } from '@cfast/permissions';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A JSON-serializable value that can safely cross the server/client boundary.
|
|
6
|
+
*
|
|
7
|
+
* Used to constrain loader data so that {@link ActionDefinition.loader} and
|
|
8
|
+
* {@link ComposedActions.loader} can merge `_actionPermissions` into it.
|
|
9
|
+
*/
|
|
10
|
+
type Serializable = string | number | boolean | null | Serializable[] | {
|
|
11
|
+
[key: string]: Serializable;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Context provided to every action's {@link OperationsFn}.
|
|
15
|
+
*
|
|
16
|
+
* Created by the `getContext` callback in {@link ActionsConfig} and passed
|
|
17
|
+
* alongside the database instance and parsed input to each action.
|
|
18
|
+
*
|
|
19
|
+
* @typeParam TUser - The shape of the authenticated user object.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* const ctx: ActionContext<{ id: string; role: string }> = {
|
|
24
|
+
* db,
|
|
25
|
+
* user: { id: "u_1", role: "author" },
|
|
26
|
+
* grants: [{ action: "manage", subject: "all" }],
|
|
27
|
+
* };
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
type ActionContext<TUser> = {
|
|
31
|
+
/** The Drizzle database instance from `@cfast/db`. */
|
|
32
|
+
db: Db;
|
|
33
|
+
/** The authenticated user for the current request. */
|
|
34
|
+
user: TUser;
|
|
35
|
+
/** The user's permission {@link Grant | grants}, used for permission checking. */
|
|
36
|
+
grants: Grant[];
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Subset of React Router loader/action arguments consumed by `@cfast/actions`.
|
|
40
|
+
*
|
|
41
|
+
* Mirrors the shape React Router passes to `loader` and `action` exports,
|
|
42
|
+
* trimmed to only the fields the actions system needs.
|
|
43
|
+
*/
|
|
44
|
+
type RequestArgs = {
|
|
45
|
+
/** The incoming HTTP request. */
|
|
46
|
+
request: Request;
|
|
47
|
+
/** URL parameters from the route pattern (e.g., `{ postId: "abc" }`). */
|
|
48
|
+
params: Record<string, string | undefined>;
|
|
49
|
+
/** Optional context object (e.g., Cloudflare Workers env via `context.cloudflare.env`). */
|
|
50
|
+
context?: unknown;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Configuration for the {@link createActions} factory.
|
|
54
|
+
*
|
|
55
|
+
* Provides a `getContext` callback that resolves the per-request
|
|
56
|
+
* {@link ActionContext} (database, user, grants) for every action invocation.
|
|
57
|
+
*
|
|
58
|
+
* @typeParam TUser - The shape of the authenticated user object.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* const config: ActionsConfig<AppUser> = {
|
|
63
|
+
* getContext: async ({ request }) => {
|
|
64
|
+
* const ctx = await requireAuthContext(request);
|
|
65
|
+
* const db = createCfDb(env.DB, ctx);
|
|
66
|
+
* return { db, user: ctx.user, grants: ctx.grants };
|
|
67
|
+
* },
|
|
68
|
+
* };
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
type ActionsConfig<TUser> = {
|
|
72
|
+
/** Resolves the per-request action context from the route handler arguments. */
|
|
73
|
+
getContext: (args: RequestArgs) => Promise<ActionContext<TUser>>;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* A function that builds a database {@link Operation} for an action.
|
|
77
|
+
*
|
|
78
|
+
* Receives the Drizzle database, the parsed input, and the full
|
|
79
|
+
* {@link ActionContext}. Returns an `Operation` (from `@cfast/db`) that
|
|
80
|
+
* encapsulates both the query/mutation and its permission descriptors.
|
|
81
|
+
*
|
|
82
|
+
* @typeParam TInput - The expected input shape for this action.
|
|
83
|
+
* @typeParam TResult - The return type of the operation.
|
|
84
|
+
* @typeParam TUser - The shape of the authenticated user object.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* const deletePostOps: OperationsFn<{ postId: string }, Response, AppUser> =
|
|
89
|
+
* (db, input, ctx) =>
|
|
90
|
+
* compose(
|
|
91
|
+
* [db.delete(posts).where(eq(posts.id, input.postId))],
|
|
92
|
+
* async (runDelete) => {
|
|
93
|
+
* await runDelete({});
|
|
94
|
+
* return redirect("/");
|
|
95
|
+
* },
|
|
96
|
+
* );
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
type OperationsFn<TInput, TResult, TUser> = (db: Db, input: TInput, ctx: ActionContext<TUser>) => Operation<TResult>;
|
|
100
|
+
/**
|
|
101
|
+
* The resolved permission status for a single action.
|
|
102
|
+
*
|
|
103
|
+
* Computed by {@link checkPermissionStatus} and sent to the client
|
|
104
|
+
* via `_actionPermissions` in loader data.
|
|
105
|
+
*/
|
|
106
|
+
type ActionPermissionStatus = {
|
|
107
|
+
/** Whether the user has all required permissions for this action. */
|
|
108
|
+
permitted: boolean;
|
|
109
|
+
/** `true` when the user lacks every permission — the UI should hide the control entirely. */
|
|
110
|
+
invisible: boolean;
|
|
111
|
+
/** Human-readable explanation when `permitted` is `false`, otherwise `null`. */
|
|
112
|
+
reason: string | null;
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* A map from action name to its {@link ActionPermissionStatus}.
|
|
116
|
+
*
|
|
117
|
+
* Injected into loader data under the `_actionPermissions` key by
|
|
118
|
+
* {@link ActionDefinition.loader} or {@link ComposedActions.loader}.
|
|
119
|
+
*/
|
|
120
|
+
type ActionPermissionsMap = Record<string, ActionPermissionStatus>;
|
|
121
|
+
/**
|
|
122
|
+
* An opaque descriptor passed to the client to configure the `useActions` hook.
|
|
123
|
+
*
|
|
124
|
+
* Created by {@link ActionDefinition.client} or {@link ComposedActions.client}.
|
|
125
|
+
* Contains the action names and the key used to read permission data from loader results.
|
|
126
|
+
*/
|
|
127
|
+
type ClientDescriptor = {
|
|
128
|
+
/** Brand field to distinguish this type at the type level. */
|
|
129
|
+
_brand: "ActionClientDescriptor";
|
|
130
|
+
/** The list of action names this descriptor covers. */
|
|
131
|
+
actionNames: readonly string[];
|
|
132
|
+
/** The loader-data key where {@link ActionPermissionsMap} is stored. */
|
|
133
|
+
permissionsKey: string;
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* A single action definition returned by `createAction()`.
|
|
137
|
+
*
|
|
138
|
+
* Provides four facets: a React Router action handler, a loader wrapper
|
|
139
|
+
* that injects permission status, a client descriptor for `useActions`,
|
|
140
|
+
* and a `buildOperation` method for advanced composition.
|
|
141
|
+
*
|
|
142
|
+
* @typeParam TInput - The expected input shape for this action.
|
|
143
|
+
* @typeParam TResult - The return type of the action handler.
|
|
144
|
+
* @typeParam TUser - The shape of the authenticated user object.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* ```ts
|
|
148
|
+
* const deletePost = createAction<{ postId: string }, Response>(
|
|
149
|
+
* (db, input, ctx) =>
|
|
150
|
+
* compose(
|
|
151
|
+
* [db.delete(posts).where(eq(posts.id, input.postId))],
|
|
152
|
+
* async (runDelete) => { await runDelete({}); return redirect("/"); },
|
|
153
|
+
* ),
|
|
154
|
+
* );
|
|
155
|
+
*
|
|
156
|
+
* // Use as a route action
|
|
157
|
+
* export const action = deletePost.action;
|
|
158
|
+
* // Wrap a loader to inject permissions
|
|
159
|
+
* export const loader = deletePost.loader(myLoader);
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
type ActionDefinition<TInput, TResult, TUser> = {
|
|
163
|
+
/** React Router action handler. Parses input, resolves context, and runs the operation. */
|
|
164
|
+
action: (args: RequestArgs) => Promise<TResult>;
|
|
165
|
+
/**
|
|
166
|
+
* Wraps a loader function to inject {@link ActionPermissionsMap} into its return value.
|
|
167
|
+
*
|
|
168
|
+
* The wrapper resolves the action context, builds the operation to extract
|
|
169
|
+
* permission descriptors, checks them against the user's grants, and merges
|
|
170
|
+
* the result as `_actionPermissions`.
|
|
171
|
+
*/
|
|
172
|
+
loader: <TLoaderData extends Record<string, Serializable>>(loaderFn: (args: RequestArgs) => Promise<TLoaderData>) => (args: RequestArgs) => Promise<TLoaderData & {
|
|
173
|
+
_actionPermissions: ActionPermissionsMap;
|
|
174
|
+
}>;
|
|
175
|
+
/** Opaque descriptor for the `useActions` client hook. */
|
|
176
|
+
client: ClientDescriptor;
|
|
177
|
+
/** Builds the raw {@link Operation} for this action, useful for cross-action composition. */
|
|
178
|
+
buildOperation: (db: Db, input: TInput, ctx: ActionContext<TUser>) => Operation<TResult>;
|
|
179
|
+
};
|
|
180
|
+
/**
|
|
181
|
+
* The result of combining multiple {@link ActionDefinition | action definitions}
|
|
182
|
+
* via `composeActions()`.
|
|
183
|
+
*
|
|
184
|
+
* Provides a single action handler that dispatches by the `_action` discriminator,
|
|
185
|
+
* a loader wrapper that checks permissions for all actions at once, a client
|
|
186
|
+
* descriptor covering all action names, and the original action map.
|
|
187
|
+
*
|
|
188
|
+
* @typeParam TActions - A record mapping action names to their {@link ActionDefinition} types.
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```ts
|
|
192
|
+
* const composed = composeActions({
|
|
193
|
+
* deletePost,
|
|
194
|
+
* publishPost,
|
|
195
|
+
* unpublishPost,
|
|
196
|
+
* });
|
|
197
|
+
*
|
|
198
|
+
* export const action = composed.action;
|
|
199
|
+
* export const loader = composed.loader(myLoader);
|
|
200
|
+
* ```
|
|
201
|
+
*/
|
|
202
|
+
type ComposedActions<TActions extends Record<string, ActionDefinition<any, any, any>>> = {
|
|
203
|
+
/** Combined action handler that dispatches to the correct action based on `_action` field. */
|
|
204
|
+
action: (args: RequestArgs) => Promise<unknown>;
|
|
205
|
+
/**
|
|
206
|
+
* Wraps a loader function to inject {@link ActionPermissionsMap} for all composed actions.
|
|
207
|
+
*
|
|
208
|
+
* Checks permissions for every action in the map and merges the results
|
|
209
|
+
* into loader data under `_actionPermissions`.
|
|
210
|
+
*/
|
|
211
|
+
loader: <TLoaderData extends Record<string, Serializable>>(loaderFn: (args: RequestArgs) => Promise<TLoaderData>) => (args: RequestArgs) => Promise<TLoaderData & {
|
|
212
|
+
_actionPermissions: ActionPermissionsMap;
|
|
213
|
+
}>;
|
|
214
|
+
/** Opaque descriptor covering all composed action names, for the `useActions` client hook. */
|
|
215
|
+
client: ClientDescriptor;
|
|
216
|
+
/** The original action definitions, keyed by name. */
|
|
217
|
+
actions: TActions;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export type { ActionPermissionStatus as A, ClientDescriptor as C, OperationsFn as O, RequestArgs as R, Serializable as S, ActionsConfig as a, ActionDefinition as b, ComposedActions as c, ActionContext as d, ActionPermissionsMap as e };
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cfast/actions",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Multi-action routes and permission-aware action definitions for React Router",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/DanielMSchmidt/cfast.git",
|
|
9
|
+
"directory": "packages/actions"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"import": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts"
|
|
18
|
+
},
|
|
19
|
+
"./client": {
|
|
20
|
+
"import": "./dist/client.js",
|
|
21
|
+
"types": "./dist/client.d.ts"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": "^19.0.0",
|
|
33
|
+
"react-router": "^7.0.0"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@cfast/db": "0.0.1",
|
|
37
|
+
"@cfast/permissions": "0.0.1"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@cloudflare/workers-types": "^4.20260305.1",
|
|
41
|
+
"@types/react": "^19.0.0",
|
|
42
|
+
"react": "^19.0.0",
|
|
43
|
+
"react-router": "^7.6.0",
|
|
44
|
+
"tsup": "^8",
|
|
45
|
+
"typescript": "^5.7",
|
|
46
|
+
"vitest": "^4.1.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsup src/index.ts src/client.ts --format esm --dts",
|
|
50
|
+
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"lint": "eslint src/",
|
|
53
|
+
"test": "vitest run"
|
|
54
|
+
}
|
|
55
|
+
}
|