@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 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.
@@ -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
+ };
@@ -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
+ }