@maravilla-labs/platform 0.5.0 → 0.6.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 +116 -8
- package/dist/config.js +159 -2
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +6 -568
- package/dist/index.js +62 -0
- package/dist/index.js.map +1 -1
- package/package.json +5 -10
- package/src/config.ts +313 -8
- package/src/remote-client.ts +81 -5
- package/src/types.ts +64 -654
- package/tests/policy-builder.test.ts +186 -0
- package/tests/types.test-d.ts +94 -0
- package/tsconfig.test.json +8 -0
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,8 +141,13 @@ 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>`
|
|
@@ -102,7 +177,12 @@ interface GroupDefinition {
|
|
|
102
177
|
permissions?: GroupPermissionDefinition[];
|
|
103
178
|
}
|
|
104
179
|
interface RelationTypeDefinition {
|
|
105
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* Uppercase identifier referenced from policies. Don't hand-write the
|
|
182
|
+
* `... VIA 'STEWARDS'` clause — use the typed {@link relatesVia} builder
|
|
183
|
+
* (`relatesVia('STEWARDS')`), which emits the correct single-quoted form
|
|
184
|
+
* and is cross-validated against this list at {@link defineConfig} time.
|
|
185
|
+
*/
|
|
106
186
|
relation_name: string;
|
|
107
187
|
/** Human-readable title. */
|
|
108
188
|
title: string;
|
|
@@ -199,6 +279,23 @@ interface AuthConfigBlock {
|
|
|
199
279
|
oauth?: OAuthProvidersConfig;
|
|
200
280
|
security?: SecurityConfig;
|
|
201
281
|
branding?: BrandingConfig;
|
|
282
|
+
/**
|
|
283
|
+
* Named, reusable policy fragments. Reference a fragment from any
|
|
284
|
+
* resource policy with `fragment('name')`; {@link defineConfig} expands
|
|
285
|
+
* the reference inline (wrapped in parens) at build time, so the
|
|
286
|
+
* runtime never sees the indirection. Values may be a {@link Policy} or
|
|
287
|
+
* a raw string expression.
|
|
288
|
+
*
|
|
289
|
+
* @example
|
|
290
|
+
* ```ts
|
|
291
|
+
* defineConfig({ auth: {
|
|
292
|
+
* fragments: { staffOrAdmin: isStaff().or(isAdmin()) },
|
|
293
|
+
* resources: [{ name: 'todos', title: 'Todos', actions: ['read'],
|
|
294
|
+
* policy: ownsIt().or(fragment('staffOrAdmin')) }],
|
|
295
|
+
* }});
|
|
296
|
+
* ```
|
|
297
|
+
*/
|
|
298
|
+
fragments?: Record<string, Policy | string>;
|
|
202
299
|
}
|
|
203
300
|
interface MaravillaConfig {
|
|
204
301
|
/** All project-level auth settings. Every field is optional — partial adoption is supported. */
|
|
@@ -256,21 +353,32 @@ interface DatabaseConfigBlock {
|
|
|
256
353
|
vectorIndexes?: VectorIndexDeclaration[];
|
|
257
354
|
}
|
|
258
355
|
/**
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
356
|
+
* Validate + normalize a Maravilla config.
|
|
357
|
+
*
|
|
358
|
+
* Beyond giving you full IntelliSense, this:
|
|
359
|
+
* - serializes every `Policy`-typed resource policy to its string form;
|
|
360
|
+
* - inlines `fragment('name')` references against `auth.fragments`
|
|
361
|
+
* (throws on an unknown fragment or a cycle);
|
|
362
|
+
* - cross-validates that relation names used in `relatesVia()` exist in
|
|
363
|
+
* the declared `auth.relations[]`, and (when `auth.groups[]` is
|
|
364
|
+
* declared) that groups referenced via `isStaff()` /
|
|
365
|
+
* `auth.roles.contains(...)` exist there — throwing on unknown.
|
|
366
|
+
*
|
|
367
|
+
* The returned config has all policies as plain strings, ready for the
|
|
368
|
+
* reconciler. Sections you didn't declare are left untouched.
|
|
262
369
|
*
|
|
263
370
|
* @example
|
|
264
371
|
* ```typescript
|
|
265
|
-
* import { defineConfig } from '@maravilla-labs/platform/config';
|
|
372
|
+
* import { defineConfig, ownsIt, isStaff } from '@maravilla-labs/platform/config';
|
|
266
373
|
*
|
|
267
374
|
* export default defineConfig({
|
|
268
375
|
* auth: {
|
|
269
|
-
* resources: [{ name: 'todos', title: 'Todos', actions: ['read', 'write']
|
|
376
|
+
* resources: [{ name: 'todos', title: 'Todos', actions: ['read', 'write'],
|
|
377
|
+
* policy: ownsIt().or(isStaff()) }],
|
|
270
378
|
* },
|
|
271
379
|
* });
|
|
272
380
|
* ```
|
|
273
381
|
*/
|
|
274
382
|
declare function defineConfig(config: MaravillaConfig): MaravillaConfig;
|
|
275
383
|
|
|
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 };
|
|
384
|
+
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/config.js.map
CHANGED
|
@@ -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`, `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 /**\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 /** 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":[]}
|