rastack 0.0.51 → 0.0.52

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/auth/context.ts +73 -0
  3. package/auth/core.ts +293 -0
  4. package/auth/index.ts +23 -0
  5. package/components/auto-form/AutoForm.tsx +32 -12
  6. package/components/auto-form/use-auto-form.ts +75 -6
  7. package/components/data-table/DataTable.tsx +72 -45
  8. package/components/data-table/use-data-table.ts +57 -3
  9. package/components/index.ts +2 -0
  10. package/db.ts +9 -0
  11. package/dist/admin.js +13 -13
  12. package/dist/compile/analyze.js +7 -2
  13. package/dist/compile/entities.js +163 -16
  14. package/dist/compile/model.d.ts +7 -3
  15. package/dist/compile/openapi.js +13 -3
  16. package/dist/compile/program.d.ts +2 -1
  17. package/dist/compile/program.js +7 -2
  18. package/dist/define/auth.d.ts +27 -0
  19. package/dist/define/auth.js +42 -0
  20. package/dist/define/db.d.ts +24 -1
  21. package/dist/define/db.js +1 -1
  22. package/dist/define/index.d.ts +19 -2
  23. package/dist/define/manifest.js +10 -1
  24. package/dist/define/markers.d.ts +80 -0
  25. package/dist/define/markers.js +37 -0
  26. package/dist/define/types.d.ts +10 -0
  27. package/dist/define/types.js +26 -0
  28. package/dist/wasm/rastack_wasm.js +1 -1
  29. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  30. package/package.json +1 -1
  31. package/provider/index.ts +1 -5
  32. package/provider/provider.tsx +42 -11
  33. package/provider/types.ts +14 -2
  34. package/provider/warehouse.ts +4 -3
  35. package/provider/wasm.ts +3 -1
  36. package/runtime.ts +1 -0
  37. package/src/compile/analyze.ts +15 -3
  38. package/src/compile/entities.ts +193 -19
  39. package/src/compile/model.ts +8 -9
  40. package/src/compile/openapi.ts +11 -3
  41. package/src/compile/program.ts +10 -3
  42. package/src/define/auth.ts +28 -0
  43. package/src/define/db.ts +26 -4
  44. package/src/define/index.ts +20 -2
  45. package/src/define/manifest.ts +10 -1
  46. package/src/define/markers.ts +83 -0
  47. package/src/define/types.ts +11 -0
  48. package/test/auth.spec.ts +207 -0
  49. package/test/compile.spec.ts +18 -7
  50. package/test/components.spec.ts +116 -0
  51. package/test/entities.spec.ts +123 -6
  52. package/types.ts +7 -2
  53. package/wasm/rastack_wasm.js +1 -1
  54. package/wasm/rastack_wasm_bg.wasm +0 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [0.0.52](https://github.com/theserverkid/reactapistack/compare/v0.0.51...v0.0.52) (2026-07-16)
6
+
5
7
  ### [0.0.51](https://github.com/theserverkid/reactapistack/compare/v0.0.50...v0.0.51) (2026-07-16)
6
8
 
7
9
  ### [0.0.50](https://github.com/theserverkid/reactapistack/compare/v0.0.49...v0.0.50) (2026-07-15)
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The React seam for `rastack/auth` — a tiny context carrying the signed-in
3
+ * {@link RastackAuth} and (when available) the compiled manifest, so the
4
+ * data-connected components can gate their affordances with zero wiring.
5
+ *
6
+ * `<RAStackProvider>` provides it automatically from its `auth` / `identity`
7
+ * config; `<RastackAuthProvider>` is the standalone provider for apps that
8
+ * don't use the runtime provider (e.g. a pure `remote`-mode admin screen).
9
+ *
10
+ * Deliberately JSX-free (plain `createElement`) so the pure model builders in
11
+ * `rastack/components` can import the hooks from a `.ts` module graph.
12
+ */
13
+
14
+ import {
15
+ createContext,
16
+ createElement,
17
+ useContext,
18
+ useMemo,
19
+ type ReactNode,
20
+ } from "react";
21
+ import {
22
+ policyFromManifest,
23
+ type ManifestLike,
24
+ type RastackAuth,
25
+ type ResourcePolicy,
26
+ type ResourceRef,
27
+ } from "./core";
28
+
29
+ export interface RastackAccessValue {
30
+ /** The signed-in identity, or `undefined` when no identity is wired up. */
31
+ auth?: RastackAuth;
32
+ /** `schema.rastack.json` (object or JSON string) for policy lookups. */
33
+ manifest?: ManifestLike | string;
34
+ }
35
+
36
+ const RastackAccessContext = createContext<RastackAccessValue>({});
37
+
38
+ export interface RastackAuthProviderProps extends RastackAccessValue {
39
+ children?: ReactNode;
40
+ }
41
+
42
+ /** Provide the identity (and optionally the manifest) to the component tree. */
43
+ export function RastackAuthProvider(props: RastackAuthProviderProps) {
44
+ const { auth, manifest, children } = props;
45
+ const value = useMemo<RastackAccessValue>(
46
+ () => ({ auth, manifest }),
47
+ [auth, manifest],
48
+ );
49
+ return createElement(RastackAccessContext.Provider, { value }, children);
50
+ }
51
+
52
+ /**
53
+ * The current identity, or `undefined` when none is provided — in which case
54
+ * the components stay ungated and the server remains the sole enforcer.
55
+ */
56
+ export function useRastackAuth(): RastackAuth | undefined {
57
+ return useContext(RastackAccessContext).auth;
58
+ }
59
+
60
+ /**
61
+ * Look a resource's access policy up from the provided manifest. Returns
62
+ * `undefined` (⇒ the default `authenticated` gate) when no manifest is
63
+ * provided or the resource isn't in it.
64
+ */
65
+ export function useResourcePolicy(
66
+ ref: ResourceRef | undefined,
67
+ ): ResourcePolicy | undefined {
68
+ const { manifest } = useContext(RastackAccessContext);
69
+ return useMemo(
70
+ () => (ref == null ? undefined : policyFromManifest(manifest, ref)),
71
+ [manifest, ref],
72
+ );
73
+ }
package/auth/core.ts ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * `rastack/auth` — the client-side authorization core.
3
+ *
4
+ * The server (`rastack-api-core`) is the enforcer: every request runs through
5
+ * the resource's `permission` gate, tenant scoping, and the row-level owner
6
+ * filter, identically on Lambda and in the in-browser WASM engine. What this
7
+ * module adds is the *client's view* of those same rules, so the UI can limit
8
+ * the paths it exposes — hide a delete button the server would 403, render a
9
+ * form read-only for an anonymous visitor — instead of discovering the answer
10
+ * as a failed request.
11
+ *
12
+ * Everything here is pure and mirrors `rust/rastack-api-core` exactly:
13
+ * - {@link can} ⇔ `check_permission` + the RLS reachability check
14
+ * - {@link bypassesRls} ⇔ `AuthContext::bypasses_rls`
15
+ * - {@link ownsRow} ⇔ the owner filter `enforced_owner` derives
16
+ *
17
+ * These are UI affordances, never security: the engine re-checks every call.
18
+ */
19
+
20
+ /** The resource-level permission gate. Unset means `authenticated` — secure by default. */
21
+ export type PermissionPolicy =
22
+ "authenticated" | "authenticatedOrReadOnly" | "public";
23
+
24
+ /** The five CRUD surfaces a policy is checked against. */
25
+ export type AuthAction = "list" | "retrieve" | "create" | "update" | "delete";
26
+
27
+ /**
28
+ * The signed-in identity, as the framework sees it everywhere — the TypeScript
29
+ * mirror of the Rust `AuthContext`. Build one from your auth library's session
30
+ * ({@link userAuth} / {@link authFromClaims}) and hand it to
31
+ * `<RAStackProvider auth={…}>`; the provider runs the WASM engine as this
32
+ * identity and the components gate their affordances with it.
33
+ */
34
+ export interface RastackAuth {
35
+ /** The caller presented a verified identity. */
36
+ authenticated: boolean;
37
+ /** Verified subject — the Cognito `sub` claim. Also the tenant key. */
38
+ sub?: string;
39
+ /** Verified group claims (`cognito:groups`). */
40
+ groups?: string[];
41
+ /**
42
+ * Trusted contexts (the dev server with auth disabled) bypass every check,
43
+ * exactly like the Rust `AuthContext::trusted()`.
44
+ */
45
+ trusted?: boolean;
46
+ }
47
+
48
+ /** No identity. Only `public` resources (and reads of `authenticatedOrReadOnly`) pass. */
49
+ export function anonymousAuth(): RastackAuth {
50
+ return { authenticated: false, groups: [] };
51
+ }
52
+
53
+ /** A verified end user. */
54
+ export function userAuth(sub: string, groups: string[] = []): RastackAuth {
55
+ return { authenticated: true, sub, groups };
56
+ }
57
+
58
+ /** Full access, no row filters — local dev without auth configured. */
59
+ export function trustedAuth(): RastackAuth {
60
+ return { authenticated: true, groups: [], trusted: true };
61
+ }
62
+
63
+ /**
64
+ * Build a {@link RastackAuth} from a decoded JWT payload (a Cognito ID/access
65
+ * token): `sub` becomes the subject, `cognito:groups` the groups.
66
+ */
67
+ export function authFromClaims(claims: Record<string, unknown>): RastackAuth {
68
+ const sub = typeof claims.sub === "string" ? claims.sub : undefined;
69
+ const raw = claims["cognito:groups"];
70
+ const groups = Array.isArray(raw)
71
+ ? raw.filter((g): g is string => typeof g === "string")
72
+ : [];
73
+ return { authenticated: sub != null, sub, groups };
74
+ }
75
+
76
+ /** A resource's `access` block, as compiled into the manifest. */
77
+ export interface ResourceAccess {
78
+ /** Field stamped with the owning subject; rows are filtered to their owner. */
79
+ ownerField?: string;
80
+ /** Field holding the owning group; rows are filtered to its members. */
81
+ groupField?: string;
82
+ /**
83
+ * `"owner"` physically partitions the table per identity
84
+ * (`tenants/{sub}/…`); `"group"` per group (`groups/{name}/…` — the default
85
+ * for group-owned datasets); `"shared"` keeps one table.
86
+ */
87
+ scope?: "owner" | "group" | "shared";
88
+ /** Token groups that bypass the row filters. */
89
+ adminGroups?: string[];
90
+ }
91
+
92
+ /**
93
+ * Everything access control needs to know about one resource — the
94
+ * `permission` gate plus the row-level `access` block. This is a structural
95
+ * subset of the manifest's `ResourceModel`, so a manifest resource assigns
96
+ * directly; {@link policyFromManifest} / {@link policyFromOpenApi} extract it.
97
+ */
98
+ export interface ResourcePolicy {
99
+ permission?: PermissionPolicy | string;
100
+ access?: ResourceAccess;
101
+ }
102
+
103
+ /** Create/update/delete mutate; list/retrieve only read. */
104
+ export function isWriteAction(action: AuthAction): boolean {
105
+ return action === "create" || action === "update" || action === "delete";
106
+ }
107
+
108
+ /**
109
+ * Whether this identity bypasses row-level security on the resource — trusted
110
+ * contexts and members of a configured admin group do.
111
+ */
112
+ export function bypassesRls(
113
+ auth: RastackAuth,
114
+ policy: ResourcePolicy | undefined,
115
+ ): boolean {
116
+ if (auth.trusted) return true;
117
+ const adminGroups = policy?.access?.adminGroups ?? [];
118
+ const groups = auth.groups ?? [];
119
+ return adminGroups.some((g) => groups.includes(g));
120
+ }
121
+
122
+ /**
123
+ * The "is admin" role, driven from the token's group claims: whether this
124
+ * identity is in one of the resource's `adminGroups` (or is a trusted
125
+ * context) and therefore sees and acts on every row. An alias of
126
+ * {@link bypassesRls} with the name UIs actually want.
127
+ */
128
+ export const isAdmin = bypassesRls;
129
+
130
+ /**
131
+ * Whether the identity may perform `action` on the resource at all — the
132
+ * client-side mirror of the server's `permission` gate plus the row-level
133
+ * reachability rule (an RLS-protected resource needs a subject, unless the
134
+ * identity bypasses RLS).
135
+ *
136
+ * A `null`/`undefined` auth means "no identity is wired up" and gates nothing:
137
+ * the UI stays fully enabled and the server remains the sole enforcer. Pass
138
+ * {@link anonymousAuth} to model a signed-out visitor.
139
+ */
140
+ export function can(
141
+ auth: RastackAuth | null | undefined,
142
+ policy: ResourcePolicy | undefined,
143
+ action: AuthAction,
144
+ ): boolean {
145
+ if (auth == null) return true;
146
+ if (auth.trusted) return true;
147
+
148
+ // The permission gate. Unset means `authenticated` — secure by default.
149
+ const write = isWriteAction(action);
150
+ const permitted =
151
+ policy?.permission === "public"
152
+ ? true
153
+ : policy?.permission === "authenticatedOrReadOnly"
154
+ ? auth.authenticated || !write
155
+ : auth.authenticated;
156
+ if (!permitted) return false;
157
+
158
+ // Row-level security / tenant scoping need a subject to resolve rows or the
159
+ // tenant view — without one the server answers 401.
160
+ const access = policy?.access;
161
+ const ownerSecured = Boolean(access?.ownerField) || access?.scope === "owner";
162
+ if (ownerSecured && auth.sub == null && !bypassesRls(auth, policy)) {
163
+ return false;
164
+ }
165
+
166
+ // Group-owned resources: reads outside any group just come back empty, but
167
+ // a write can never succeed — there is no group to place the row in or no
168
+ // row a membership guard would ever pass.
169
+ if (
170
+ access?.groupField &&
171
+ write &&
172
+ (auth.groups ?? []).length === 0 &&
173
+ !bypassesRls(auth, policy)
174
+ ) {
175
+ return false;
176
+ }
177
+ return true;
178
+ }
179
+
180
+ /**
181
+ * Whether the identity owns a specific row under the resource's row-level
182
+ * security — mirrors the row guards the server applies: the owner filter
183
+ * (`ownerField` must carry the caller's subject) *and* the group-membership
184
+ * filter (`groupField` must carry one of the caller's groups). Admin groups
185
+ * and trusted contexts pass unfiltered; rows of an unsecured resource are
186
+ * "owned" by everyone.
187
+ */
188
+ export function ownsRow(
189
+ auth: RastackAuth | null | undefined,
190
+ policy: ResourcePolicy | undefined,
191
+ row: Record<string, unknown>,
192
+ ): boolean {
193
+ if (auth == null) return true;
194
+ if (bypassesRls(auth, policy)) return true;
195
+ const ownerField = policy?.access?.ownerField;
196
+ if (ownerField) {
197
+ if (auth.sub == null || String(row[ownerField] ?? "") !== auth.sub) {
198
+ return false;
199
+ }
200
+ }
201
+ const groupField = policy?.access?.groupField;
202
+ if (groupField) {
203
+ if (!(auth.groups ?? []).includes(String(row[groupField] ?? ""))) {
204
+ return false;
205
+ }
206
+ }
207
+ return true;
208
+ }
209
+
210
+ /** {@link can} on the resource *and* {@link ownsRow} on the specific row. */
211
+ export function canActOnRow(
212
+ auth: RastackAuth | null | undefined,
213
+ policy: ResourcePolicy | undefined,
214
+ action: AuthAction,
215
+ row: Record<string, unknown>,
216
+ ): boolean {
217
+ return can(auth, policy, action) && ownsRow(auth, policy, row);
218
+ }
219
+
220
+ // -- policy lookup --------------------------------------------------------
221
+
222
+ /**
223
+ * The structural slice of `schema.rastack.json` the policy lookup reads.
224
+ * Deliberately loose (any object with a `resources` array) so a manifest
225
+ * however it was loaded — imported JSON, `JSON.parse`, the typed compiler
226
+ * output — assigns without casts.
227
+ */
228
+ export interface ManifestLike {
229
+ resources?: Array<Record<string, unknown>>;
230
+ }
231
+
232
+ /** Accept `"app.model"` or `{ app, model }`. */
233
+ export type ResourceRef = string | { app: string; model: string };
234
+
235
+ function splitRef(ref: ResourceRef): { app: string; model: string } {
236
+ if (typeof ref !== "string") return ref;
237
+ const dot = ref.indexOf(".");
238
+ if (dot === -1)
239
+ throw new Error(`Invalid resource ref "${ref}" — expected "app.model".`);
240
+ return { app: ref.slice(0, dot), model: ref.slice(dot + 1) };
241
+ }
242
+
243
+ /**
244
+ * Extract a resource's {@link ResourcePolicy} from the compiled manifest
245
+ * (`schema.rastack.json`, object or JSON string), or `undefined` when the
246
+ * resource isn't found.
247
+ */
248
+ export function policyFromManifest(
249
+ manifest: ManifestLike | string | null | undefined,
250
+ ref: ResourceRef,
251
+ ): ResourcePolicy | undefined {
252
+ if (manifest == null) return undefined;
253
+ const doc: ManifestLike =
254
+ typeof manifest === "string" ? JSON.parse(manifest) : manifest;
255
+ const { app, model } = splitRef(ref);
256
+ const resource = doc.resources?.find(
257
+ (r) => r.app === app && r.model === model,
258
+ );
259
+ if (!resource) return undefined;
260
+ const policy: ResourcePolicy = {};
261
+ if (resource.permission != null) {
262
+ policy.permission = resource.permission as string;
263
+ }
264
+ if (resource.access != null) {
265
+ policy.access = resource.access as ResourceAccess;
266
+ }
267
+ return policy;
268
+ }
269
+
270
+ /**
271
+ * Extract a resource's {@link ResourcePolicy} from the compiled OpenAPI
272
+ * document — the `x-rastack-permission` / `x-rastack-access` stamps on its
273
+ * `/api/{app}/v1/{model}/` operations.
274
+ */
275
+ export function policyFromOpenApi(
276
+ doc: { paths?: Record<string, any> } | string | null | undefined,
277
+ ref: ResourceRef,
278
+ ): ResourcePolicy | undefined {
279
+ if (doc == null) return undefined;
280
+ const parsed: { paths?: Record<string, any> } =
281
+ typeof doc === "string" ? JSON.parse(doc) : doc;
282
+ const { app, model } = splitRef(ref);
283
+ const path = parsed.paths?.[`/api/${app}/v1/${model}/`];
284
+ if (!path) return undefined;
285
+ const operation = path.get ?? path.post ?? Object.values(path)[0];
286
+ if (!operation || typeof operation !== "object") return undefined;
287
+ const policy: ResourcePolicy = {};
288
+ const permission = (operation as any)["x-rastack-permission"];
289
+ if (permission != null) policy.permission = permission;
290
+ const access = (operation as any)["x-rastack-access"];
291
+ if (access != null) policy.access = access;
292
+ return policy;
293
+ }
package/auth/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `rastack/auth` — the whole auth surface of the stack:
3
+ *
4
+ * - **Authoring** (types the compiler reads): the entity markers
5
+ * (`IsAuthenticated`, `AdminGroups<…>`, `OwnerScoped`, `GroupScoped`, …)
6
+ * and the `Owner` / `Group` access-control field brands.
7
+ * - **Runtime**: the TypeScript auth type ({@link RastackAuth}), the
8
+ * client-side mirror of the server's authorization rules ({@link can},
9
+ * row-level security helpers, policy lookup), and the React context the
10
+ * provider and components share.
11
+ *
12
+ * See `docs/security-and-local-cache.md`.
13
+ */
14
+
15
+ export * from "../src/define/auth";
16
+ export * from "./core";
17
+ export {
18
+ RastackAuthProvider,
19
+ useRastackAuth,
20
+ useResourcePolicy,
21
+ type RastackAccessValue,
22
+ type RastackAuthProviderProps,
23
+ } from "./context";
@@ -13,10 +13,11 @@ import {
13
13
  useAutoForm,
14
14
  type AutoFormFieldModel,
15
15
  type AutoFormModel,
16
+ type AutoFormModelOptions,
16
17
  } from "./use-auto-form";
17
- import type { AutoFormInputKind, AutoFormOptions } from "./fields";
18
+ import type { AutoFormInputKind } from "./fields";
18
19
 
19
- export interface AutoFormProps extends AutoFormOptions {
20
+ export interface AutoFormProps extends AutoFormModelOptions {
20
21
  /** The form hook result (`useUpdateXForm(...)`) driving the form. */
21
22
  form: AutoFormLike;
22
23
  submitLabel?: React.ReactNode;
@@ -61,6 +62,10 @@ export function AutoForm(props: AutoFormProps): React.JSX.Element {
61
62
  form,
62
63
  fields,
63
64
  exclude,
65
+ auth,
66
+ policy,
67
+ resource,
68
+ action,
64
69
  submitLabel = "Save",
65
70
  onCancel,
66
71
  cancelLabel = "Cancel",
@@ -72,7 +77,14 @@ export function AutoForm(props: AutoFormProps): React.JSX.Element {
72
77
  className,
73
78
  } = props;
74
79
 
75
- const model = useAutoForm(form, { fields, exclude });
80
+ const model = useAutoForm(form, {
81
+ fields,
82
+ exclude,
83
+ auth,
84
+ policy,
85
+ resource,
86
+ action,
87
+ });
76
88
  const [touched, setTouched] = useState<Set<string>>(() => new Set());
77
89
  const [submitted, setSubmitted] = useState(false);
78
90
 
@@ -87,6 +99,7 @@ export function AutoForm(props: AutoFormProps): React.JSX.Element {
87
99
 
88
100
  const handleSubmit = (e: React.FormEvent) => {
89
101
  e.preventDefault();
102
+ if (!model.canWrite) return;
90
103
  setSubmitted(true);
91
104
  model.submit();
92
105
  onSubmit?.();
@@ -210,18 +223,25 @@ export function AutoForm(props: AutoFormProps): React.JSX.Element {
210
223
  {children ? (
211
224
  children(model)
212
225
  ) : (
213
- <div
214
- style={{
215
- display: "grid",
216
- gridTemplateColumns: `repeat(${Math.max(1, columns)}, minmax(0, 1fr))`,
217
- gap: tokens.space.lg,
218
- }}
226
+ // Authorization is enforced by the API; here it shapes the UI — an
227
+ // identity the policy denies writes to gets every control disabled.
228
+ <fieldset
229
+ disabled={!model.canWrite}
230
+ style={{ border: 0, margin: 0, padding: 0, minInlineSize: 0 }}
219
231
  >
220
- {model.fields.map(renderField)}
221
- </div>
232
+ <div
233
+ style={{
234
+ display: "grid",
235
+ gridTemplateColumns: `repeat(${Math.max(1, columns)}, minmax(0, 1fr))`,
236
+ gap: tokens.space.lg,
237
+ }}
238
+ >
239
+ {model.fields.map(renderField)}
240
+ </div>
241
+ </fieldset>
222
242
  )}
223
243
 
224
- {!hideActions && !children ? (
244
+ {!hideActions && !children && model.canWrite ? (
225
245
  <div
226
246
  style={{
227
247
  display: "flex",
@@ -12,6 +12,15 @@ import type React from "react";
12
12
  import type { FieldOption } from "../../hooks/form/interfaces";
13
13
  import type { AutoFormField, AutoFormLike } from "../types";
14
14
  import { humanizeKey } from "../util";
15
+ import {
16
+ bypassesRls,
17
+ can,
18
+ useRastackAuth,
19
+ useResourcePolicy,
20
+ type RastackAuth,
21
+ type ResourcePolicy,
22
+ type ResourceRef,
23
+ } from "../../auth";
15
24
  import {
16
25
  inputKindForFieldType,
17
26
  resolveFieldSpecs,
@@ -53,6 +62,13 @@ export interface AutoFormModel {
53
62
  isSubmitting: boolean;
54
63
  /** `true` when the form is valid and not mid-submit. */
55
64
  canSubmit: boolean;
65
+ /**
66
+ * Whether the current identity may write this resource at all — the client
67
+ * mirror of the server's `permission` gate + row-level security. `false`
68
+ * renders the form read-only (`<AutoForm>` disables every control and hides
69
+ * the actions row). Always `true` when no auth/policy is wired up.
70
+ */
71
+ canWrite: boolean;
56
72
  errors: Record<string, unknown>;
57
73
  /**
58
74
  * State-machine actions — feature-detected from `useForm(Entity)` results
@@ -61,13 +77,54 @@ export interface AutoFormModel {
61
77
  transitions: AutoFormTransitionModel[];
62
78
  }
63
79
 
80
+ /** Authorization inputs shared by `useAutoForm` and `<AutoForm>`. */
81
+ export interface AutoFormAuthOptions {
82
+ /**
83
+ * The identity to gate against. Defaults to the ambient identity from
84
+ * `<RAStackProvider auth={…}>` / `<RastackAuthProvider>`; pass `null` to
85
+ * opt out of gating for this form.
86
+ */
87
+ auth?: RastackAuth | null;
88
+ /** The resource's compiled `permission` + `access` policy. */
89
+ policy?: ResourcePolicy;
90
+ /**
91
+ * `"app.model"` — looks `policy` up from the provider's manifest when one
92
+ * is available (always the case in `local`/`s3` mode).
93
+ */
94
+ resource?: ResourceRef;
95
+ /** Which write this form performs. Default `"update"`. */
96
+ action?: "create" | "update";
97
+ }
98
+
99
+ export type AutoFormModelOptions = AutoFormOptions & AutoFormAuthOptions;
100
+
64
101
  /** Pure model assembly — see {@link useAutoForm}. Testable with a mock form. */
65
102
  export function buildAutoFormModel(
66
103
  form: AutoFormLike,
67
- options: AutoFormOptions = {},
104
+ options: AutoFormModelOptions = {},
68
105
  ): AutoFormModel {
106
+ const auth = options.auth ?? undefined;
107
+ const policy = options.policy;
108
+ const canWrite = can(auth, policy, options.action ?? "update");
109
+
110
+ // The owner field is stamped from the verified token on create and
111
+ // immutable after that — never user-editable. Drop it from inferred field
112
+ // lists (an explicit `fields` entry still wins) unless the identity
113
+ // bypasses row-level security (admins may see whose row it is).
114
+ const ownerField = policy?.access?.ownerField;
115
+ const hideOwner =
116
+ ownerField != null &&
117
+ !options.fields?.length &&
118
+ auth != null &&
119
+ !bypassesRls(auth, policy);
120
+
69
121
  const fieldNames = Object.keys(form.formValues ?? {});
70
- const specs = resolveFieldSpecs(fieldNames, options);
122
+ const specs = resolveFieldSpecs(fieldNames, {
123
+ ...options,
124
+ exclude: hideOwner
125
+ ? [...(options.exclude ?? []), ownerField]
126
+ : options.exclude,
127
+ });
71
128
 
72
129
  const fields: AutoFormFieldModel[] = specs.map((spec) => {
73
130
  const field = form.getFormField(spec.name);
@@ -102,16 +159,28 @@ export function buildAutoFormModel(
102
159
  submit: () => form.onSubmit(),
103
160
  reset: () => form.resetForm(),
104
161
  isSubmitting: form.isSubmitting,
105
- canSubmit: !form.isSubmitDisabled && !form.isSubmitting,
162
+ canSubmit: canWrite && !form.isSubmitDisabled && !form.isSubmitting,
163
+ canWrite,
106
164
  errors: (form.errors as Record<string, unknown>) ?? {},
107
165
  transitions,
108
166
  };
109
167
  }
110
168
 
111
- /** React hook wrapper around {@link buildAutoFormModel}. */
169
+ /**
170
+ * React hook wrapper around {@link buildAutoFormModel}. Auth handling is built
171
+ * in: the identity defaults to the ambient one from `<RAStackProvider
172
+ * auth={…}>`, and passing `resource: "app.model"` resolves the policy from the
173
+ * provider's manifest — so inside the provider, gating needs zero wiring.
174
+ */
112
175
  export function useAutoForm(
113
176
  form: AutoFormLike,
114
- options: AutoFormOptions = {},
177
+ options: AutoFormModelOptions = {},
115
178
  ): AutoFormModel {
116
- return buildAutoFormModel(form, options);
179
+ const ambientAuth = useRastackAuth();
180
+ const ambientPolicy = useResourcePolicy(options.resource);
181
+ return buildAutoFormModel(form, {
182
+ ...options,
183
+ auth: options.auth !== undefined ? options.auth : ambientAuth,
184
+ policy: options.policy ?? ambientPolicy,
185
+ });
117
186
  }