@maravilla-labs/platform 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.d.ts CHANGED
@@ -42,6 +42,76 @@ type SecretRef = string | {
42
42
  env: string;
43
43
  };
44
44
 
45
+ /**
46
+ * Throws when `expr` contains a known legacy/footgun policy shape that the
47
+ * runtime evaluator would fail closed on. Called by {@link Policy.raw}.
48
+ *
49
+ * @internal
50
+ */
51
+ declare function assertNoLegacyShapes(expr: string): void;
52
+ /**
53
+ * A composable, typed policy expression. Build with the helper functions
54
+ * ({@link ownsIt}, {@link isStaff}, {@link isAdmin}, {@link relatesVia},
55
+ * {@link publicWhen}, {@link fragment}) and combine with `.and()` / `.or()`.
56
+ * `toString()` yields the raisin-rel source.
57
+ */
58
+ declare class Policy {
59
+ private readonly expr;
60
+ private constructor();
61
+ /**
62
+ * Wrap a raw raisin-rel expression. Lints for legacy footguns and
63
+ * throws on them (see {@link assertNoLegacyShapes}).
64
+ */
65
+ static raw(expr: string): Policy;
66
+ /** @internal Build without linting — for the builder helpers only. */
67
+ private static unchecked;
68
+ toString(): string;
69
+ /** Logical OR with another policy (each side parenthesized). */
70
+ or(other: Policy): Policy;
71
+ /** Logical AND with another policy (each side parenthesized). */
72
+ and(other: Policy): Policy;
73
+ }
74
+ /**
75
+ * Caller owns the resource: `auth.user_id == node.<field>` (default
76
+ * `owner`).
77
+ */
78
+ declare function ownsIt(field?: string): Policy;
79
+ /**
80
+ * Caller is a member of a staff-like group:
81
+ * `auth.roles.contains('<group>')` (default `staff`).
82
+ */
83
+ declare function isStaff(group?: string): Policy;
84
+ /**
85
+ * Caller is a platform admin: `auth.is_admin`. Valid now that the runtime
86
+ * populates `is_admin` from membership in the `admin` group.
87
+ */
88
+ declare function isAdmin(): Policy;
89
+ /**
90
+ * A typed `RELATES … VIA '<name>'` clause (FR-1). Emits
91
+ * `<object> RELATES <subject> VIA '<relationName>'[ DEPTH a..b]`.
92
+ *
93
+ * @param relationName - the relation type name (cross-validated against
94
+ * the declared `relations[]` at {@link defineConfig} time).
95
+ * @param opts.subject - the related party (default `auth.user_id`).
96
+ * @param opts.object - the anchor node (default `node.owner`).
97
+ * @param opts.depth - inclusive `[min, max]` traversal depth.
98
+ */
99
+ declare function relatesVia(relationName: string, opts?: {
100
+ subject?: string;
101
+ object?: string;
102
+ depth?: [number, number];
103
+ }): Policy;
104
+ /**
105
+ * Resource is publicly readable: `node.<field> == true` (default
106
+ * `public`).
107
+ */
108
+ declare function publicWhen(field?: string): Policy;
109
+ /**
110
+ * Reference a named fragment declared in `auth.fragments`. Resolved and
111
+ * inlined (parenthesized) by {@link defineConfig}; a dangling reference
112
+ * (no matching fragment) throws there.
113
+ */
114
+ declare function fragment(name: string): Policy;
45
115
  /**
46
116
  * The platform service this resource binds to. Used by the UI to offer
47
117
  * service-correct action presets and policy snippets, and by the reconciler
@@ -71,14 +141,20 @@ interface ResourceDefinition {
71
141
  * Optional raisin-rel policy expression. Evaluated on every KV/DB/
72
142
  * realtime/media op that targets this resource. Leave empty to skip
73
143
  * Layer 2 for this resource — tenant + owner isolation still applies.
144
+ *
145
+ * Accepts either a raw string or a typed {@link Policy} built with
146
+ * `ownsIt()`/`isStaff()`/`isAdmin()`/`relatesVia()`/`publicWhen()`/
147
+ * `fragment()` (and `.and()`/`.or()`). {@link defineConfig} serializes
148
+ * it via `.toString()` and expands any `fragment()` references.
74
149
  */
75
- policy?: string;
150
+ policy?: string | Policy;
76
151
  /**
77
152
  * C+D read-filter (option ii). JSON object the runtime ANDs into the
78
153
  * caller's filter on `db.find` / `db.findOne`. Supports `$auth.<path>`
79
154
  * placeholder strings (e.g. `"$auth.user_id"`) substituted from the
80
155
  * caller's identity at request time. Allowed paths: `user_id`, `email`,
81
- * `is_admin`, `roles`.
156
+ * `is_admin`, `status`, `email_verified`, `groups`, `roles`, `circles`,
157
+ * `profile.<field>`, `scopes.<field>`.
82
158
  *
83
159
  * Independent of `policy` — `policy` gates writes and resolved-doc reads;
84
160
  * `read_filter` scopes which rows the caller can ever see.
@@ -86,6 +162,19 @@ interface ResourceDefinition {
86
162
  * Example: `'{"$or":[{"owner":"$auth.user_id"},{"public":true}]}'`
87
163
  */
88
164
  read_filter?: string;
165
+ /**
166
+ * Field-level redaction. Maps a field dot-path to a raisin-rel expression;
167
+ * a field is replaced with null on read when its expression is truthy for
168
+ * the caller (fail-closed). Example: `{ birth_date: "!auth.is_admin" }`.
169
+ */
170
+ redact?: Record<string, string>;
171
+ /** When true, every policy decision on this resource is recorded. */
172
+ audit?: boolean;
173
+ /**
174
+ * Fallback decision when this resource has no `policy`: `'allow'` (the
175
+ * default) or `'deny'`. Overrides the tenant-wide `security.default_policy`.
176
+ */
177
+ default_policy?: 'allow' | 'deny';
89
178
  }
90
179
  interface GroupPermissionDefinition {
91
180
  /** Must match a `ResourceDefinition.name`. */
@@ -96,14 +185,23 @@ interface GroupPermissionDefinition {
96
185
  interface GroupDefinition {
97
186
  /** Unique group name per tenant. */
98
187
  name: string;
188
+ /** Tenant-unique slug for stable addressing in JWT/API/policies. */
189
+ slug?: string;
99
190
  /** Optional description for the admin UI. */
100
191
  description?: string;
101
192
  /** Resource permissions granted to the group. Replaces the group's current permissions when declared. */
102
193
  permissions?: GroupPermissionDefinition[];
103
194
  }
104
195
  interface RelationTypeDefinition {
105
- /** Uppercase identifier used in policies (`... VIA 'STEWARDS'`). */
196
+ /**
197
+ * Uppercase identifier referenced from policies. Don't hand-write the
198
+ * `... VIA 'STEWARDS'` clause — use the typed {@link relatesVia} builder
199
+ * (`relatesVia('STEWARDS')`), which emits the correct single-quoted form
200
+ * and is cross-validated against this list at {@link defineConfig} time.
201
+ */
106
202
  relation_name: string;
203
+ /** Tenant-unique slug for stable addressing. */
204
+ slug?: string;
107
205
  /** Human-readable title. */
108
206
  title: string;
109
207
  description?: string;
@@ -199,6 +297,23 @@ interface AuthConfigBlock {
199
297
  oauth?: OAuthProvidersConfig;
200
298
  security?: SecurityConfig;
201
299
  branding?: BrandingConfig;
300
+ /**
301
+ * Named, reusable policy fragments. Reference a fragment from any
302
+ * resource policy with `fragment('name')`; {@link defineConfig} expands
303
+ * the reference inline (wrapped in parens) at build time, so the
304
+ * runtime never sees the indirection. Values may be a {@link Policy} or
305
+ * a raw string expression.
306
+ *
307
+ * @example
308
+ * ```ts
309
+ * defineConfig({ auth: {
310
+ * fragments: { staffOrAdmin: isStaff().or(isAdmin()) },
311
+ * resources: [{ name: 'todos', title: 'Todos', actions: ['read'],
312
+ * policy: ownsIt().or(fragment('staffOrAdmin')) }],
313
+ * }});
314
+ * ```
315
+ */
316
+ fragments?: Record<string, Policy | string>;
202
317
  }
203
318
  interface MaravillaConfig {
204
319
  /** All project-level auth settings. Every field is optional — partial adoption is supported. */
@@ -256,21 +371,32 @@ interface DatabaseConfigBlock {
256
371
  vectorIndexes?: VectorIndexDeclaration[];
257
372
  }
258
373
  /**
259
- * Identity function that returns the config unchanged — exists purely so the
260
- * TypeScript compiler can infer `MaravillaConfig` and give you IntelliSense
261
- * on every field.
374
+ * Validate + normalize a Maravilla config.
375
+ *
376
+ * Beyond giving you full IntelliSense, this:
377
+ * - serializes every `Policy`-typed resource policy to its string form;
378
+ * - inlines `fragment('name')` references against `auth.fragments`
379
+ * (throws on an unknown fragment or a cycle);
380
+ * - cross-validates that relation names used in `relatesVia()` exist in
381
+ * the declared `auth.relations[]`, and (when `auth.groups[]` is
382
+ * declared) that groups referenced via `isStaff()` /
383
+ * `auth.roles.contains(...)` exist there — throwing on unknown.
384
+ *
385
+ * The returned config has all policies as plain strings, ready for the
386
+ * reconciler. Sections you didn't declare are left untouched.
262
387
  *
263
388
  * @example
264
389
  * ```typescript
265
- * import { defineConfig } from '@maravilla-labs/platform/config';
390
+ * import { defineConfig, ownsIt, isStaff } from '@maravilla-labs/platform/config';
266
391
  *
267
392
  * export default defineConfig({
268
393
  * auth: {
269
- * resources: [{ name: 'todos', title: 'Todos', actions: ['read', 'write'] }],
394
+ * resources: [{ name: 'todos', title: 'Todos', actions: ['read', 'write'],
395
+ * policy: ownsIt().or(isStaff()) }],
270
396
  * },
271
397
  * });
272
398
  * ```
273
399
  */
274
400
  declare function defineConfig(config: MaravillaConfig): MaravillaConfig;
275
401
 
276
- export { type AuthConfigBlock, type BrandingConfig, type DatabaseConfigBlock, type DocumentIndexDeclaration, type GroupDefinition, type GroupPermissionDefinition, type IndexDirectionConfig, type MaravillaConfig, type OAuthProviderDefinition, type OAuthProvidersConfig, type PasswordPolicyDefinition, type RegistrationConfig, type RegistrationFieldDefinition, type RelationTypeDefinition, type ResourceDefinition, type ResourceServiceType, type SecretRef, type SecurityConfig, type SessionConfigDefinition, TransformsConfig, type VectorIndexDeclaration, type VectorMetricConfig, type VectorStorageConfig, defineConfig };
402
+ export { type AuthConfigBlock, type BrandingConfig, type DatabaseConfigBlock, type DocumentIndexDeclaration, type GroupDefinition, type GroupPermissionDefinition, type IndexDirectionConfig, type MaravillaConfig, type OAuthProviderDefinition, type OAuthProvidersConfig, type PasswordPolicyDefinition, Policy, type RegistrationConfig, type RegistrationFieldDefinition, type RelationTypeDefinition, type ResourceDefinition, type ResourceServiceType, type SecretRef, type SecurityConfig, type SessionConfigDefinition, TransformsConfig, type VectorIndexDeclaration, type VectorMetricConfig, type VectorStorageConfig, assertNoLegacyShapes, defineConfig, fragment, isAdmin, isStaff, ownsIt, publicWhen, relatesVia };
package/dist/config.js CHANGED
@@ -1,8 +1,165 @@
1
1
  // src/config.ts
2
+ var FRAGMENT_OPEN = " fragment(";
3
+ var FRAGMENT_CLOSE = ") ";
4
+ var FRAGMENT_RE = / fragment\(([^)]+)\) /g;
5
+ function assertNoLegacyShapes(expr) {
6
+ if (/auth\.isAdmin\b/.test(expr)) {
7
+ throw new Error("Policy: `auth.isAdmin` is invalid \u2014 use the `isAdmin()` builder or write `auth.is_admin`.");
8
+ }
9
+ if (/auth\.admin\b/.test(expr)) {
10
+ throw new Error("Policy: `auth.admin` is invalid \u2014 use the `isAdmin()` builder or write `auth.is_admin`.");
11
+ }
12
+ if (/(?<![\w.])is_admin\b/.test(expr)) {
13
+ throw new Error(
14
+ "Policy: bare `is_admin` is not a valid root \u2014 use the `isAdmin()` builder or write `auth.is_admin`."
15
+ );
16
+ }
17
+ const viaRe = /\bVIA\b\s*([^\s)]+)?/g;
18
+ let m;
19
+ while ((m = viaRe.exec(expr)) !== null) {
20
+ const operand = m[1];
21
+ if (!operand || !/^'[^']+'$/.test(operand)) {
22
+ throw new Error(
23
+ "Policy: `VIA` must be followed by a single-quoted relation name (e.g. VIA 'STEWARDS'). Use the `relatesVia('NAME')` builder instead of writing the clause by hand."
24
+ );
25
+ }
26
+ }
27
+ }
28
+ var Policy = class _Policy {
29
+ constructor(expr) {
30
+ this.expr = expr;
31
+ }
32
+ expr;
33
+ /**
34
+ * Wrap a raw raisin-rel expression. Lints for legacy footguns and
35
+ * throws on them (see {@link assertNoLegacyShapes}).
36
+ */
37
+ static raw(expr) {
38
+ assertNoLegacyShapes(expr);
39
+ return new _Policy(expr);
40
+ }
41
+ /** @internal Build without linting — for the builder helpers only. */
42
+ static unchecked(expr) {
43
+ return new _Policy(expr);
44
+ }
45
+ toString() {
46
+ return this.expr;
47
+ }
48
+ /** Logical OR with another policy (each side parenthesized). */
49
+ or(other) {
50
+ return _Policy.unchecked(`${this.expr} || ${other.expr}`);
51
+ }
52
+ /** Logical AND with another policy (each side parenthesized). */
53
+ and(other) {
54
+ return _Policy.unchecked(`${this.expr} && ${other.expr}`);
55
+ }
56
+ };
57
+ function ownsIt(field = "owner") {
58
+ return Policy.raw(`auth.user_id == node.${field}`);
59
+ }
60
+ function isStaff(group = "staff") {
61
+ return Policy.raw(`auth.roles.contains('${group}')`);
62
+ }
63
+ function isAdmin() {
64
+ return Policy.raw("auth.is_admin");
65
+ }
66
+ function relatesVia(relationName, opts) {
67
+ const subject = opts?.subject ?? "auth.user_id";
68
+ const object = opts?.object ?? "node.owner";
69
+ let expr = `${object} RELATES ${subject} VIA '${relationName}'`;
70
+ if (opts?.depth) {
71
+ expr += ` DEPTH ${opts.depth[0]}..${opts.depth[1]}`;
72
+ }
73
+ return Policy.raw(expr);
74
+ }
75
+ function publicWhen(field = "public") {
76
+ return Policy.raw(`node.${field} == true`);
77
+ }
78
+ function fragment(name) {
79
+ return Policy.raw(`${FRAGMENT_OPEN}${name}${FRAGMENT_CLOSE}`);
80
+ }
81
+ function expandFragments(expr, fragments, seen = /* @__PURE__ */ new Set()) {
82
+ return expr.replace(FRAGMENT_RE, (_match, rawName) => {
83
+ const name = rawName.trim();
84
+ if (!(name in fragments)) {
85
+ throw new Error(
86
+ `Policy: fragment('${name}') is not declared in auth.fragments. Declared fragments: ${Object.keys(fragments).join(", ") || "(none)"}.`
87
+ );
88
+ }
89
+ if (seen.has(name)) {
90
+ throw new Error(
91
+ `Policy: fragment('${name}') is part of a reference cycle (${[...seen, name].join(" \u2192 ")}).`
92
+ );
93
+ }
94
+ const expanded = expandFragments(fragments[name], fragments, /* @__PURE__ */ new Set([...seen, name]));
95
+ return `(${expanded.trim()})`;
96
+ });
97
+ }
98
+ function relationNamesIn(expr) {
99
+ const out = [];
100
+ const re = /\bVIA\s+'([^']+)'/g;
101
+ let m;
102
+ while ((m = re.exec(expr)) !== null) out.push(m[1]);
103
+ return out;
104
+ }
105
+ function groupNamesIn(expr) {
106
+ const out = [];
107
+ const re = /auth\.roles\.contains\(\s*'([^']+)'\s*\)/g;
108
+ let m;
109
+ while ((m = re.exec(expr)) !== null) out.push(m[1]);
110
+ return out;
111
+ }
2
112
  function defineConfig(config) {
3
- return config;
113
+ const auth = config.auth;
114
+ if (!auth) return config;
115
+ const fragmentStrings = {};
116
+ if (auth.fragments) {
117
+ for (const [name, frag] of Object.entries(auth.fragments)) {
118
+ fragmentStrings[name] = typeof frag === "string" ? frag : frag.toString();
119
+ }
120
+ }
121
+ const declaredRelations = new Set((auth.relations ?? []).map((r) => r.relation_name));
122
+ const declaredGroups = new Set((auth.groups ?? []).map((g) => g.name));
123
+ const validateGroups = (auth.groups ?? []).length > 0;
124
+ const resources = auth.resources?.map((res) => {
125
+ if (res.policy == null) return res;
126
+ const raw = typeof res.policy === "string" ? res.policy : res.policy.toString();
127
+ const expanded = expandFragments(raw, fragmentStrings).trim();
128
+ for (const rel of relationNamesIn(expanded)) {
129
+ if (!declaredRelations.has(rel)) {
130
+ throw new Error(
131
+ `Policy for resource '${res.name}' references relation '${rel}' via relatesVia(), but it is not declared in auth.relations[]. Declared relations: ${[...declaredRelations].join(", ") || "(none)"}.`
132
+ );
133
+ }
134
+ }
135
+ if (validateGroups) {
136
+ for (const group of groupNamesIn(expanded)) {
137
+ if (!declaredGroups.has(group)) {
138
+ throw new Error(
139
+ `Policy for resource '${res.name}' references group '${group}', but it is not declared in auth.groups[]. Declared groups: ${[...declaredGroups].join(", ")}.`
140
+ );
141
+ }
142
+ }
143
+ }
144
+ return { ...res, policy: expanded };
145
+ });
146
+ return {
147
+ ...config,
148
+ auth: {
149
+ ...auth,
150
+ ...resources ? { resources } : {}
151
+ }
152
+ };
4
153
  }
5
154
  export {
6
- defineConfig
155
+ Policy,
156
+ assertNoLegacyShapes,
157
+ defineConfig,
158
+ fragment,
159
+ isAdmin,
160
+ isStaff,
161
+ ownsIt,
162
+ publicWhen,
163
+ relatesVia
7
164
  };
8
165
  //# sourceMappingURL=config.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/config.ts"],"sourcesContent":["/**\n * @fileoverview Typed schema for `maravilla.config.{ts,yaml,json}` files.\n *\n * Declares your project's auth settings (resources, groups, relations,\n * registration fields, OAuth providers, security policy, branding) alongside\n * your code. The Maravilla adapter reads this at build time and reconciles\n * the settings into delivery on deploy.\n *\n * ```typescript\n * import { defineConfig } from '@maravilla-labs/platform/config';\n *\n * export default defineConfig({\n * auth: {\n * resources: [\n * { name: 'todos', title: 'Todos', actions: ['read', 'write'],\n * policy: 'auth.user_id == node.owner' },\n * ],\n * },\n * });\n * ```\n *\n * Omitted sections leave the DB alone — partial adoption is explicitly\n * supported. List-based sections (`resources`, `groups`, `relations`,\n * `oauth`) are upserted and never auto-delete DB-only entries. Singleton\n * sections (`registration`, `security`, `branding`) are replaced wholesale\n * when declared.\n */\n\n/**\n * String value that may either be a literal secret or a reference to an\n * environment variable on the **tenant** (resolved server-side at\n * reconcile time, never shipped plaintext in the manifest).\n *\n * Accepted forms:\n * - `\"literal-value\"` — inline (not recommended for real secrets)\n * - `\"${env.VAR_NAME}\"` — string-template form\n * - `{ env: \"VAR_NAME\" }` — object form\n */\nexport type SecretRef = string | { env: string };\n\nimport type { TransformsConfig } from './transforms.js';\nexport type { TransformsConfig, TransformsPatternSpec } from './transforms.js';\n\n// ── Resources + policies ──\n\n/**\n * The platform service this resource binds to. Used by the UI to offer\n * service-correct action presets and policy snippets, and by the reconciler\n * to validate that policies reference legal `node.*` fields for the service.\n *\n * Omit for legacy / cross-service umbrella resources — the runtime falls back\n * to matching purely by `name` (a name collision between e.g. a KV namespace\n * and a DB collection will silently share a policy, which is rarely desired).\n */\nexport type ResourceServiceType =\n | 'kv'\n | 'database'\n | 'realtime'\n | 'media'\n | 'vector'\n | 'storage'\n | 'queue'\n | 'push'\n | 'workflow'\n | 'transforms';\n\nexport interface ResourceDefinition {\n /** URL-safe slug. Used as the resource key in code (e.g. the KV namespace). */\n name: string;\n /** Human-readable title for the admin UI. */\n title: string;\n /** Optional longer description. */\n description?: string;\n /**\n * Which platform service this resource gates. When set, the reconciler\n * validates that the policy only references `node.*` fields legal for that\n * service (e.g. a `realtime` policy can't reference `node.collection`).\n */\n type?: ResourceServiceType;\n /** Actions this resource supports, e.g. `['read', 'write', 'delete']`. */\n actions: string[];\n /**\n * Optional raisin-rel policy expression. Evaluated on every KV/DB/\n * realtime/media op that targets this resource. Leave empty to skip\n * Layer 2 for this resource — tenant + owner isolation still applies.\n */\n policy?: string;\n /**\n * C+D read-filter (option ii). JSON object the runtime ANDs into the\n * caller's filter on `db.find` / `db.findOne`. Supports `$auth.<path>`\n * placeholder strings (e.g. `\"$auth.user_id\"`) substituted from the\n * caller's identity at request time. Allowed paths: `user_id`, `email`,\n * `is_admin`, `roles`.\n *\n * Independent of `policy` — `policy` gates writes and resolved-doc reads;\n * `read_filter` scopes which rows the caller can ever see.\n *\n * Example: `'{\"$or\":[{\"owner\":\"$auth.user_id\"},{\"public\":true}]}'`\n */\n read_filter?: string;\n}\n\n// ── Groups ──\n\nexport interface GroupPermissionDefinition {\n /** Must match a `ResourceDefinition.name`. */\n resource_name: string;\n /** Actions this group is granted on the resource. */\n actions: string[];\n}\n\nexport interface GroupDefinition {\n /** Unique group name per tenant. */\n name: string;\n /** Optional description for the admin UI. */\n description?: string;\n /** Resource permissions granted to the group. Replaces the group's current permissions when declared. */\n permissions?: GroupPermissionDefinition[];\n}\n\n// ── Relations ──\n\nexport interface RelationTypeDefinition {\n /** Uppercase identifier used in policies (`... VIA 'STEWARDS'`). */\n relation_name: string;\n /** Human-readable title. */\n title: string;\n description?: string;\n /** Grouping for the admin UI (e.g. `\"family\"`, `\"work\"`). */\n category?: string;\n icon?: string;\n color?: string;\n /** Name of the inverse relation type, if one exists. */\n inverse_relation_name?: string;\n /** When true, membership in this relation implies stewardship rights. */\n implies_stewardship?: boolean;\n /** When true, the relation can only target users flagged as minors. */\n requires_minor?: boolean;\n /** When true, the relation is symmetric (A→B implies B→A). */\n bidirectional?: boolean;\n}\n\n// ── Registration fields ──\n\nexport interface RegistrationFieldDefinition {\n /** Field key used as the form field name + in profile data. */\n key: string;\n /** Display label. */\n label: string;\n /** One of: text, email, phone, date, number, select, boolean, url, textarea. */\n field_type: string;\n required: boolean;\n show_on_register: boolean;\n /** Optional validation metadata — passed through to the UI. */\n validation?: Record<string, unknown>;\n}\n\nexport interface RegistrationConfig {\n /** Ordered list of custom registration fields. Declaring this replaces the full list. */\n fields: RegistrationFieldDefinition[];\n}\n\n// ── OAuth providers ──\n\nexport interface OAuthProviderDefinition {\n enabled: boolean;\n client_id: string;\n /** Prefer `{ env: \"VAR_NAME\" }` or `\"${env.VAR_NAME}\"`. */\n client_secret: SecretRef;\n scopes: string[];\n /** Only for `custom_oidc`. */\n discovery_url?: string;\n}\n\nexport interface OAuthProvidersConfig {\n google?: OAuthProviderDefinition;\n github?: OAuthProviderDefinition;\n okta?: OAuthProviderDefinition;\n custom_oidc?: OAuthProviderDefinition;\n}\n\n// ── Security ──\n\nexport interface PasswordPolicyDefinition {\n min_length: number;\n require_uppercase: boolean;\n require_number: boolean;\n require_special: boolean;\n}\n\nexport interface SessionConfigDefinition {\n access_token_ttl_secs: number;\n refresh_token_ttl_secs: number;\n max_sessions_per_user: number;\n require_email_verification: boolean;\n}\n\nexport interface SecurityConfig {\n password_policy?: PasswordPolicyDefinition;\n session?: SessionConfigDefinition;\n}\n\n// ── Branding ──\n\nexport interface BrandingConfig {\n app_name?: string;\n logo_url?: string;\n primary_color?: string;\n secondary_color?: string;\n welcome_message?: string;\n welcome_subtitle?: string;\n /** `\"centered\"`, `\"split-left\"`, `\"split-right\"`, or `\"fullscreen\"`. */\n layout?: string;\n background_image_url?: string;\n /** 0–100 percentage. */\n background_focal_point?: { x: number; y: number };\n background_gradient?: string;\n /** `\"light\"`, `\"dark\"`, or `\"auto\"`. */\n color_mode?: string;\n font_family?: string;\n terms_url?: string;\n privacy_url?: string;\n /** Raw CSS merged into the hosted auth pages. */\n custom_css?: string;\n}\n\n// ── Top-level shape ──\n\nexport interface AuthConfigBlock {\n resources?: ResourceDefinition[];\n groups?: GroupDefinition[];\n relations?: RelationTypeDefinition[];\n registration?: RegistrationConfig;\n oauth?: OAuthProvidersConfig;\n security?: SecurityConfig;\n branding?: BrandingConfig;\n}\n\nexport interface MaravillaConfig {\n /** All project-level auth settings. Every field is optional — partial adoption is supported. */\n auth?: AuthConfigBlock;\n /** Declarative database indexes (regular + vector). Reconciled upsert-only on deploy. */\n database?: DatabaseConfigBlock;\n /**\n * Declarative media transforms — each entry compiles into a synthetic\n * `storage.put` event handler that fans out the declared\n * `platform.media.transforms.*` calls (via `Promise.all`) for every\n * matching upload. See {@link TransformsConfig}.\n */\n transforms?: TransformsConfig;\n}\n\n// ── Database block ──\n//\n// Regular indexes speed up document reads on frequently-queried fields.\n// Vector indexes back hybrid semantic search via sqlite-vec. Both are\n// upsert-only — declaring an index in config creates it if missing,\n// updates metadata when safe, and never auto-deletes DB-only indexes.\n\n/** MongoDB-style key direction: `1` ascending, `-1` descending. */\nexport type IndexDirectionConfig = 1 | -1;\n\nexport interface DocumentIndexDeclaration {\n /** Collection the index lives on. */\n collection: string;\n /** Optional name; falls back to an auto-derived name. */\n name?: string;\n /**\n * Compound-index key shape. Array of `[field, direction]` tuples\n * preserves ordering, which matters for compound indexes.\n */\n keys: Array<[string, IndexDirectionConfig]> | Record<string, IndexDirectionConfig>;\n unique?: boolean;\n sparse?: boolean;\n /**\n * Partial-index predicate — restricted to inline-literal operators\n * (`$eq`, `$ne`, `$gt`/`$gte`/`$lt`/`$lte`, `$in`/`$nin`, `$exists`,\n * `$and`, `$or`). No `$regex` / `$where` / `$text`.\n */\n partial?: Record<string, unknown>;\n /** TTL in seconds. Requires a single-field index on a unix-seconds field. */\n expireAfterSeconds?: number;\n}\n\n/** Distance metric used by a vector index. */\nexport type VectorMetricConfig = 'cosine' | 'l2' | 'hamming';\n\n/** Storage precision for a vector index. */\nexport type VectorStorageConfig = 'float32' | 'int8' | 'bit';\n\nexport interface VectorIndexDeclaration {\n collection: string;\n field: string;\n dimensions: number;\n metric?: VectorMetricConfig;\n storage?: VectorStorageConfig;\n matryoshka?: boolean;\n multiVector?: boolean;\n}\n\nexport interface DatabaseConfigBlock {\n /** MongoDB-style secondary indexes. */\n indexes?: DocumentIndexDeclaration[];\n /** sqlite-vec-backed vector indexes. */\n vectorIndexes?: VectorIndexDeclaration[];\n}\n\n/**\n * Identity function that returns the config unchanged — exists purely so the\n * TypeScript compiler can infer `MaravillaConfig` and give you IntelliSense\n * on every field.\n *\n * @example\n * ```typescript\n * import { defineConfig } from '@maravilla-labs/platform/config';\n *\n * export default defineConfig({\n * auth: {\n * resources: [{ name: 'todos', title: 'Todos', actions: ['read', 'write'] }],\n * },\n * });\n * ```\n */\nexport function defineConfig(config: MaravillaConfig): MaravillaConfig {\n return config;\n}\n"],"mappings":";AAmUO,SAAS,aAAa,QAA0C;AACrE,SAAO;AACT;","names":[]}
1
+ {"version":3,"sources":["../src/config.ts"],"sourcesContent":["/**\n * @fileoverview Typed schema for `maravilla.config.{ts,yaml,json}` files.\n *\n * Declares your project's auth settings (resources, groups, relations,\n * registration fields, OAuth providers, security policy, branding) alongside\n * your code. The Maravilla adapter reads this at build time and reconciles\n * the settings into delivery on deploy.\n *\n * ```typescript\n * import { defineConfig } from '@maravilla-labs/platform/config';\n *\n * export default defineConfig({\n * auth: {\n * resources: [\n * { name: 'todos', title: 'Todos', actions: ['read', 'write'],\n * policy: 'auth.user_id == node.owner' },\n * ],\n * },\n * });\n * ```\n *\n * Omitted sections leave the DB alone — partial adoption is explicitly\n * supported. List-based sections (`resources`, `groups`, `relations`,\n * `oauth`) are upserted and never auto-delete DB-only entries. Singleton\n * sections (`registration`, `security`, `branding`) are replaced wholesale\n * when declared.\n */\n\n/**\n * String value that may either be a literal secret or a reference to an\n * environment variable on the **tenant** (resolved server-side at\n * reconcile time, never shipped plaintext in the manifest).\n *\n * Accepted forms:\n * - `\"literal-value\"` — inline (not recommended for real secrets)\n * - `\"${env.VAR_NAME}\"` — string-template form\n * - `{ env: \"VAR_NAME\" }` — object form\n */\nexport type SecretRef = string | { env: string };\n\nimport type { TransformsConfig } from './transforms.js';\nexport type { TransformsConfig, TransformsPatternSpec } from './transforms.js';\n\n// ════════════════════════════════════════════════════════════════════\n// Typed policy builder (FR-7)\n// ════════════════════════════════════════════════════════════════════\n//\n// A tiny, composable builder for raisin-rel policy expressions. It exists\n// to (a) make the common patterns terse and discoverable and (b) refuse\n// the two footguns that silently fail closed in the runtime:\n// - bare `is_admin` / `auth.isAdmin` / `auth.admin` — use `isAdmin()` /\n// `auth.is_admin`;\n// - `VIA` followed by anything other than a single-quoted bareword —\n// use `relatesVia('NAME')`.\n// The builder emits valid syntax; `Policy.raw(...)` lints hand-written\n// expressions for the same footguns and throws on them.\n\n/**\n * Sentinel wrapping an unexpanded `fragment('name')` reference. Padded\n * with spaces so it composes safely inside `&&`/`||` chains, and chosen\n * so it cannot appear in valid raisin-rel source. It is always replaced\n * by {@link defineConfig} before reaching the runtime.\n */\nconst FRAGMENT_OPEN = ' fragment(';\nconst FRAGMENT_CLOSE = ') ';\n/** Matches the sentinel and captures the fragment NAME. */\nconst FRAGMENT_RE = / fragment\\(([^)]+)\\) /g;\n\n/**\n * Throws when `expr` contains a known legacy/footgun policy shape that the\n * runtime evaluator would fail closed on. Called by {@link Policy.raw}.\n *\n * @internal\n */\nexport function assertNoLegacyShapes(expr: string): void {\n // `auth.isAdmin` / `auth.admin` — camelCase / wrong field. Check before\n // the bare-`is_admin` rule so the message is the most specific one.\n if (/auth\\.isAdmin\\b/.test(expr)) {\n throw new Error(\"Policy: `auth.isAdmin` is invalid — use the `isAdmin()` builder or write `auth.is_admin`.\");\n }\n if (/auth\\.admin\\b/.test(expr)) {\n throw new Error(\"Policy: `auth.admin` is invalid — use the `isAdmin()` builder or write `auth.is_admin`.\");\n }\n // Bare `is_admin` (the valid form is the qualified `auth.is_admin`).\n // Negative lookbehind: any `is_admin` not preceded by a word char or\n // dot is \"bare\". `auth.is_admin` is preceded by `.` and thus allowed.\n if (/(?<![\\w.])is_admin\\b/.test(expr)) {\n throw new Error(\n \"Policy: bare `is_admin` is not a valid root — use the `isAdmin()` builder or write `auth.is_admin`.\",\n );\n }\n // `VIA` must be followed by a single-quoted relation name: `VIA 'NAME'`.\n // Reject `VIA \"NAME\"`, `VIA STEWARDS`, `VIA auth.x`, trailing `VIA`, etc.\n const viaRe = /\\bVIA\\b\\s*([^\\s)]+)?/g;\n let m: RegExpExecArray | null;\n while ((m = viaRe.exec(expr)) !== null) {\n const operand = m[1];\n if (!operand || !/^'[^']+'$/.test(operand)) {\n throw new Error(\n \"Policy: `VIA` must be followed by a single-quoted relation name (e.g. VIA 'STEWARDS'). \" +\n \"Use the `relatesVia('NAME')` builder instead of writing the clause by hand.\",\n );\n }\n }\n}\n\n/**\n * A composable, typed policy expression. Build with the helper functions\n * ({@link ownsIt}, {@link isStaff}, {@link isAdmin}, {@link relatesVia},\n * {@link publicWhen}, {@link fragment}) and combine with `.and()` / `.or()`.\n * `toString()` yields the raisin-rel source.\n */\nexport class Policy {\n private constructor(private readonly expr: string) {}\n\n /**\n * Wrap a raw raisin-rel expression. Lints for legacy footguns and\n * throws on them (see {@link assertNoLegacyShapes}).\n */\n static raw(expr: string): Policy {\n assertNoLegacyShapes(expr);\n return new Policy(expr);\n }\n\n /** @internal Build without linting — for the builder helpers only. */\n private static unchecked(expr: string): Policy {\n return new Policy(expr);\n }\n\n toString(): string {\n return this.expr;\n }\n\n /** Logical OR with another policy (each side parenthesized). */\n or(other: Policy): Policy {\n return Policy.unchecked(`${this.expr} || ${other.expr}`);\n }\n\n /** Logical AND with another policy (each side parenthesized). */\n and(other: Policy): Policy {\n return Policy.unchecked(`${this.expr} && ${other.expr}`);\n }\n}\n\n/**\n * Caller owns the resource: `auth.user_id == node.<field>` (default\n * `owner`).\n */\nexport function ownsIt(field: string = 'owner'): Policy {\n return Policy.raw(`auth.user_id == node.${field}`);\n}\n\n/**\n * Caller is a member of a staff-like group:\n * `auth.roles.contains('<group>')` (default `staff`).\n */\nexport function isStaff(group: string = 'staff'): Policy {\n return Policy.raw(`auth.roles.contains('${group}')`);\n}\n\n/**\n * Caller is a platform admin: `auth.is_admin`. Valid now that the runtime\n * populates `is_admin` from membership in the `admin` group.\n */\nexport function isAdmin(): Policy {\n return Policy.raw('auth.is_admin');\n}\n\n/**\n * A typed `RELATES … VIA '<name>'` clause (FR-1). Emits\n * `<object> RELATES <subject> VIA '<relationName>'[ DEPTH a..b]`.\n *\n * @param relationName - the relation type name (cross-validated against\n * the declared `relations[]` at {@link defineConfig} time).\n * @param opts.subject - the related party (default `auth.user_id`).\n * @param opts.object - the anchor node (default `node.owner`).\n * @param opts.depth - inclusive `[min, max]` traversal depth.\n */\nexport function relatesVia(\n relationName: string,\n opts?: { subject?: string; object?: string; depth?: [number, number] },\n): Policy {\n const subject = opts?.subject ?? 'auth.user_id';\n const object = opts?.object ?? 'node.owner';\n let expr = `${object} RELATES ${subject} VIA '${relationName}'`;\n if (opts?.depth) {\n expr += ` DEPTH ${opts.depth[0]}..${opts.depth[1]}`;\n }\n return Policy.raw(expr);\n}\n\n/**\n * Resource is publicly readable: `node.<field> == true` (default\n * `public`).\n */\nexport function publicWhen(field: string = 'public'): Policy {\n return Policy.raw(`node.${field} == true`);\n}\n\n/**\n * Reference a named fragment declared in `auth.fragments`. Resolved and\n * inlined (parenthesized) by {@link defineConfig}; a dangling reference\n * (no matching fragment) throws there.\n */\nexport function fragment(name: string): Policy {\n // Carries a sentinel that defineConfig() recognizes and expands. It is\n // never emitted to the runtime un-expanded.\n return Policy.raw(`${FRAGMENT_OPEN}${name}${FRAGMENT_CLOSE}`);\n}\n\n// ── Resources + policies ──\n\n/**\n * The platform service this resource binds to. Used by the UI to offer\n * service-correct action presets and policy snippets, and by the reconciler\n * to validate that policies reference legal `node.*` fields for the service.\n *\n * Omit for legacy / cross-service umbrella resources — the runtime falls back\n * to matching purely by `name` (a name collision between e.g. a KV namespace\n * and a DB collection will silently share a policy, which is rarely desired).\n */\nexport type ResourceServiceType =\n | 'kv'\n | 'database'\n | 'realtime'\n | 'media'\n | 'vector'\n | 'storage'\n | 'queue'\n | 'push'\n | 'workflow'\n | 'transforms';\n\nexport interface ResourceDefinition {\n /** URL-safe slug. Used as the resource key in code (e.g. the KV namespace). */\n name: string;\n /** Human-readable title for the admin UI. */\n title: string;\n /** Optional longer description. */\n description?: string;\n /**\n * Which platform service this resource gates. When set, the reconciler\n * validates that the policy only references `node.*` fields legal for that\n * service (e.g. a `realtime` policy can't reference `node.collection`).\n */\n type?: ResourceServiceType;\n /** Actions this resource supports, e.g. `['read', 'write', 'delete']`. */\n actions: string[];\n /**\n * Optional raisin-rel policy expression. Evaluated on every KV/DB/\n * realtime/media op that targets this resource. Leave empty to skip\n * Layer 2 for this resource — tenant + owner isolation still applies.\n *\n * Accepts either a raw string or a typed {@link Policy} built with\n * `ownsIt()`/`isStaff()`/`isAdmin()`/`relatesVia()`/`publicWhen()`/\n * `fragment()` (and `.and()`/`.or()`). {@link defineConfig} serializes\n * it via `.toString()` and expands any `fragment()` references.\n */\n policy?: string | Policy;\n /**\n * C+D read-filter (option ii). JSON object the runtime ANDs into the\n * caller's filter on `db.find` / `db.findOne`. Supports `$auth.<path>`\n * placeholder strings (e.g. `\"$auth.user_id\"`) substituted from the\n * caller's identity at request time. Allowed paths: `user_id`, `email`,\n * `is_admin`, `status`, `email_verified`, `groups`, `roles`, `circles`,\n * `profile.<field>`, `scopes.<field>`.\n *\n * Independent of `policy` — `policy` gates writes and resolved-doc reads;\n * `read_filter` scopes which rows the caller can ever see.\n *\n * Example: `'{\"$or\":[{\"owner\":\"$auth.user_id\"},{\"public\":true}]}'`\n */\n read_filter?: string;\n /**\n * Field-level redaction. Maps a field dot-path to a raisin-rel expression;\n * a field is replaced with null on read when its expression is truthy for\n * the caller (fail-closed). Example: `{ birth_date: \"!auth.is_admin\" }`.\n */\n redact?: Record<string, string>;\n /** When true, every policy decision on this resource is recorded. */\n audit?: boolean;\n /**\n * Fallback decision when this resource has no `policy`: `'allow'` (the\n * default) or `'deny'`. Overrides the tenant-wide `security.default_policy`.\n */\n default_policy?: 'allow' | 'deny';\n}\n\n// ── Groups ──\n\nexport interface GroupPermissionDefinition {\n /** Must match a `ResourceDefinition.name`. */\n resource_name: string;\n /** Actions this group is granted on the resource. */\n actions: string[];\n}\n\nexport interface GroupDefinition {\n /** Unique group name per tenant. */\n name: string;\n /** Tenant-unique slug for stable addressing in JWT/API/policies. */\n slug?: string;\n /** Optional description for the admin UI. */\n description?: string;\n /** Resource permissions granted to the group. Replaces the group's current permissions when declared. */\n permissions?: GroupPermissionDefinition[];\n}\n\n// ── Relations ──\n\nexport interface RelationTypeDefinition {\n /**\n * Uppercase identifier referenced from policies. Don't hand-write the\n * `... VIA 'STEWARDS'` clause — use the typed {@link relatesVia} builder\n * (`relatesVia('STEWARDS')`), which emits the correct single-quoted form\n * and is cross-validated against this list at {@link defineConfig} time.\n */\n relation_name: string;\n /** Tenant-unique slug for stable addressing. */\n slug?: string;\n /** Human-readable title. */\n title: string;\n description?: string;\n /** Grouping for the admin UI (e.g. `\"family\"`, `\"work\"`). */\n category?: string;\n icon?: string;\n color?: string;\n /** Name of the inverse relation type, if one exists. */\n inverse_relation_name?: string;\n /** When true, membership in this relation implies stewardship rights. */\n implies_stewardship?: boolean;\n /** When true, the relation can only target users flagged as minors. */\n requires_minor?: boolean;\n /** When true, the relation is symmetric (A→B implies B→A). */\n bidirectional?: boolean;\n}\n\n// ── Registration fields ──\n\nexport interface RegistrationFieldDefinition {\n /** Field key used as the form field name + in profile data. */\n key: string;\n /** Display label. */\n label: string;\n /** One of: text, email, phone, date, number, select, boolean, url, textarea. */\n field_type: string;\n required: boolean;\n show_on_register: boolean;\n /** Optional validation metadata — passed through to the UI. */\n validation?: Record<string, unknown>;\n}\n\nexport interface RegistrationConfig {\n /** Ordered list of custom registration fields. Declaring this replaces the full list. */\n fields: RegistrationFieldDefinition[];\n}\n\n// ── OAuth providers ──\n\nexport interface OAuthProviderDefinition {\n enabled: boolean;\n client_id: string;\n /** Prefer `{ env: \"VAR_NAME\" }` or `\"${env.VAR_NAME}\"`. */\n client_secret: SecretRef;\n scopes: string[];\n /** Only for `custom_oidc`. */\n discovery_url?: string;\n}\n\nexport interface OAuthProvidersConfig {\n google?: OAuthProviderDefinition;\n github?: OAuthProviderDefinition;\n okta?: OAuthProviderDefinition;\n custom_oidc?: OAuthProviderDefinition;\n}\n\n// ── Security ──\n\nexport interface PasswordPolicyDefinition {\n min_length: number;\n require_uppercase: boolean;\n require_number: boolean;\n require_special: boolean;\n}\n\nexport interface SessionConfigDefinition {\n access_token_ttl_secs: number;\n refresh_token_ttl_secs: number;\n max_sessions_per_user: number;\n require_email_verification: boolean;\n}\n\nexport interface SecurityConfig {\n password_policy?: PasswordPolicyDefinition;\n session?: SessionConfigDefinition;\n}\n\n// ── Branding ──\n\nexport interface BrandingConfig {\n app_name?: string;\n logo_url?: string;\n primary_color?: string;\n secondary_color?: string;\n welcome_message?: string;\n welcome_subtitle?: string;\n /** `\"centered\"`, `\"split-left\"`, `\"split-right\"`, or `\"fullscreen\"`. */\n layout?: string;\n background_image_url?: string;\n /** 0–100 percentage. */\n background_focal_point?: { x: number; y: number };\n background_gradient?: string;\n /** `\"light\"`, `\"dark\"`, or `\"auto\"`. */\n color_mode?: string;\n font_family?: string;\n terms_url?: string;\n privacy_url?: string;\n /** Raw CSS merged into the hosted auth pages. */\n custom_css?: string;\n}\n\n// ── Top-level shape ──\n\nexport interface AuthConfigBlock {\n resources?: ResourceDefinition[];\n groups?: GroupDefinition[];\n relations?: RelationTypeDefinition[];\n registration?: RegistrationConfig;\n oauth?: OAuthProvidersConfig;\n security?: SecurityConfig;\n branding?: BrandingConfig;\n /**\n * Named, reusable policy fragments. Reference a fragment from any\n * resource policy with `fragment('name')`; {@link defineConfig} expands\n * the reference inline (wrapped in parens) at build time, so the\n * runtime never sees the indirection. Values may be a {@link Policy} or\n * a raw string expression.\n *\n * @example\n * ```ts\n * defineConfig({ auth: {\n * fragments: { staffOrAdmin: isStaff().or(isAdmin()) },\n * resources: [{ name: 'todos', title: 'Todos', actions: ['read'],\n * policy: ownsIt().or(fragment('staffOrAdmin')) }],\n * }});\n * ```\n */\n fragments?: Record<string, Policy | string>;\n}\n\nexport interface MaravillaConfig {\n /** All project-level auth settings. Every field is optional — partial adoption is supported. */\n auth?: AuthConfigBlock;\n /** Declarative database indexes (regular + vector). Reconciled upsert-only on deploy. */\n database?: DatabaseConfigBlock;\n /**\n * Declarative media transforms — each entry compiles into a synthetic\n * `storage.put` event handler that fans out the declared\n * `platform.media.transforms.*` calls (via `Promise.all`) for every\n * matching upload. See {@link TransformsConfig}.\n */\n transforms?: TransformsConfig;\n}\n\n// ── Database block ──\n//\n// Regular indexes speed up document reads on frequently-queried fields.\n// Vector indexes back hybrid semantic search via sqlite-vec. Both are\n// upsert-only — declaring an index in config creates it if missing,\n// updates metadata when safe, and never auto-deletes DB-only indexes.\n\n/** MongoDB-style key direction: `1` ascending, `-1` descending. */\nexport type IndexDirectionConfig = 1 | -1;\n\nexport interface DocumentIndexDeclaration {\n /** Collection the index lives on. */\n collection: string;\n /** Optional name; falls back to an auto-derived name. */\n name?: string;\n /**\n * Compound-index key shape. Array of `[field, direction]` tuples\n * preserves ordering, which matters for compound indexes.\n */\n keys: Array<[string, IndexDirectionConfig]> | Record<string, IndexDirectionConfig>;\n unique?: boolean;\n sparse?: boolean;\n /**\n * Partial-index predicate — restricted to inline-literal operators\n * (`$eq`, `$ne`, `$gt`/`$gte`/`$lt`/`$lte`, `$in`/`$nin`, `$exists`,\n * `$and`, `$or`). No `$regex` / `$where` / `$text`.\n */\n partial?: Record<string, unknown>;\n /** TTL in seconds. Requires a single-field index on a unix-seconds field. */\n expireAfterSeconds?: number;\n}\n\n/** Distance metric used by a vector index. */\nexport type VectorMetricConfig = 'cosine' | 'l2' | 'hamming';\n\n/** Storage precision for a vector index. */\nexport type VectorStorageConfig = 'float32' | 'int8' | 'bit';\n\nexport interface VectorIndexDeclaration {\n collection: string;\n field: string;\n dimensions: number;\n metric?: VectorMetricConfig;\n storage?: VectorStorageConfig;\n matryoshka?: boolean;\n multiVector?: boolean;\n}\n\nexport interface DatabaseConfigBlock {\n /** MongoDB-style secondary indexes. */\n indexes?: DocumentIndexDeclaration[];\n /** sqlite-vec-backed vector indexes. */\n vectorIndexes?: VectorIndexDeclaration[];\n}\n\n/**\n * Recursively expand `fragment(name)` sentinels in `expr` against the\n * declared `fragments` map. Each expansion is wrapped in parens so it\n * binds correctly inside `&&`/`||`. Throws on an unknown fragment or a\n * fragment cycle.\n *\n * @internal\n */\nfunction expandFragments(\n expr: string,\n fragments: Record<string, string>,\n seen: ReadonlySet<string> = new Set(),\n): string {\n return expr.replace(FRAGMENT_RE, (_match, rawName: string) => {\n const name = rawName.trim();\n if (!(name in fragments)) {\n throw new Error(\n `Policy: fragment('${name}') is not declared in auth.fragments. ` +\n `Declared fragments: ${Object.keys(fragments).join(', ') || '(none)'}.`,\n );\n }\n if (seen.has(name)) {\n throw new Error(\n `Policy: fragment('${name}') is part of a reference cycle (${[...seen, name].join(' → ')}).`,\n );\n }\n const expanded = expandFragments(fragments[name], fragments, new Set([...seen, name]));\n return `(${expanded.trim()})`;\n });\n}\n\n/** Extract every relation name referenced via `VIA 'NAME'`. @internal */\nfunction relationNamesIn(expr: string): string[] {\n const out: string[] = [];\n const re = /\\bVIA\\s+'([^']+)'/g;\n let m: RegExpExecArray | null;\n while ((m = re.exec(expr)) !== null) out.push(m[1]);\n return out;\n}\n\n/** Extract every group referenced via `auth.roles.contains('GROUP')`. @internal */\nfunction groupNamesIn(expr: string): string[] {\n const out: string[] = [];\n const re = /auth\\.roles\\.contains\\(\\s*'([^']+)'\\s*\\)/g;\n let m: RegExpExecArray | null;\n while ((m = re.exec(expr)) !== null) out.push(m[1]);\n return out;\n}\n\n/**\n * Validate + normalize a Maravilla config.\n *\n * Beyond giving you full IntelliSense, this:\n * - serializes every `Policy`-typed resource policy to its string form;\n * - inlines `fragment('name')` references against `auth.fragments`\n * (throws on an unknown fragment or a cycle);\n * - cross-validates that relation names used in `relatesVia()` exist in\n * the declared `auth.relations[]`, and (when `auth.groups[]` is\n * declared) that groups referenced via `isStaff()` /\n * `auth.roles.contains(...)` exist there — throwing on unknown.\n *\n * The returned config has all policies as plain strings, ready for the\n * reconciler. Sections you didn't declare are left untouched.\n *\n * @example\n * ```typescript\n * import { defineConfig, ownsIt, isStaff } from '@maravilla-labs/platform/config';\n *\n * export default defineConfig({\n * auth: {\n * resources: [{ name: 'todos', title: 'Todos', actions: ['read', 'write'],\n * policy: ownsIt().or(isStaff()) }],\n * },\n * });\n * ```\n */\nexport function defineConfig(config: MaravillaConfig): MaravillaConfig {\n const auth = config.auth;\n if (!auth) return config;\n\n // Normalize the declared fragments to strings up front (a fragment may\n // itself be a Policy or a raw string).\n const fragmentStrings: Record<string, string> = {};\n if (auth.fragments) {\n for (const [name, frag] of Object.entries(auth.fragments)) {\n fragmentStrings[name] = typeof frag === 'string' ? frag : frag.toString();\n }\n }\n\n const declaredRelations = new Set((auth.relations ?? []).map((r) => r.relation_name));\n const declaredGroups = new Set((auth.groups ?? []).map((g) => g.name));\n const validateGroups = (auth.groups ?? []).length > 0;\n\n const resources = auth.resources?.map((res) => {\n if (res.policy == null) return res;\n const raw = typeof res.policy === 'string' ? res.policy : res.policy.toString();\n const expanded = expandFragments(raw, fragmentStrings).trim();\n\n for (const rel of relationNamesIn(expanded)) {\n if (!declaredRelations.has(rel)) {\n throw new Error(\n `Policy for resource '${res.name}' references relation '${rel}' via relatesVia(), ` +\n `but it is not declared in auth.relations[]. ` +\n `Declared relations: ${[...declaredRelations].join(', ') || '(none)'}.`,\n );\n }\n }\n if (validateGroups) {\n for (const group of groupNamesIn(expanded)) {\n if (!declaredGroups.has(group)) {\n throw new Error(\n `Policy for resource '${res.name}' references group '${group}', ` +\n `but it is not declared in auth.groups[]. ` +\n `Declared groups: ${[...declaredGroups].join(', ')}.`,\n );\n }\n }\n }\n\n return { ...res, policy: expanded };\n });\n\n return {\n ...config,\n auth: {\n ...auth,\n ...(resources ? { resources } : {}),\n },\n };\n}\n"],"mappings":"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","names":[]}