@maravilla-labs/platform 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +19 -1
- package/dist/config.js.map +1 -1
- package/dist/events.d.ts +17 -1
- package/dist/events.js +4 -0
- package/dist/events.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +19 -1
- package/src/events.ts +21 -0
package/dist/config.d.ts
CHANGED
|
@@ -153,7 +153,8 @@ interface ResourceDefinition {
|
|
|
153
153
|
* caller's filter on `db.find` / `db.findOne`. Supports `$auth.<path>`
|
|
154
154
|
* placeholder strings (e.g. `"$auth.user_id"`) substituted from the
|
|
155
155
|
* caller's identity at request time. Allowed paths: `user_id`, `email`,
|
|
156
|
-
* `is_admin`, `roles
|
|
156
|
+
* `is_admin`, `status`, `email_verified`, `groups`, `roles`, `circles`,
|
|
157
|
+
* `profile.<field>`, `scopes.<field>`.
|
|
157
158
|
*
|
|
158
159
|
* Independent of `policy` — `policy` gates writes and resolved-doc reads;
|
|
159
160
|
* `read_filter` scopes which rows the caller can ever see.
|
|
@@ -161,6 +162,19 @@ interface ResourceDefinition {
|
|
|
161
162
|
* Example: `'{"$or":[{"owner":"$auth.user_id"},{"public":true}]}'`
|
|
162
163
|
*/
|
|
163
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';
|
|
164
178
|
}
|
|
165
179
|
interface GroupPermissionDefinition {
|
|
166
180
|
/** Must match a `ResourceDefinition.name`. */
|
|
@@ -171,6 +185,8 @@ interface GroupPermissionDefinition {
|
|
|
171
185
|
interface GroupDefinition {
|
|
172
186
|
/** Unique group name per tenant. */
|
|
173
187
|
name: string;
|
|
188
|
+
/** Tenant-unique slug for stable addressing in JWT/API/policies. */
|
|
189
|
+
slug?: string;
|
|
174
190
|
/** Optional description for the admin UI. */
|
|
175
191
|
description?: string;
|
|
176
192
|
/** Resource permissions granted to the group. Replaces the group's current permissions when declared. */
|
|
@@ -184,6 +200,8 @@ interface RelationTypeDefinition {
|
|
|
184
200
|
* and is cross-validated against this list at {@link defineConfig} time.
|
|
185
201
|
*/
|
|
186
202
|
relation_name: string;
|
|
203
|
+
/** Tenant-unique slug for stable addressing. */
|
|
204
|
+
slug?: string;
|
|
187
205
|
/** Human-readable title. */
|
|
188
206
|
title: string;
|
|
189
207
|
description?: string;
|
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// ════════════════════════════════════════════════════════════════════\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":[]}
|
|
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":[]}
|
package/dist/events.d.ts
CHANGED
|
@@ -197,6 +197,22 @@ declare function onDbChange(config: Omit<EventTriggerDb, 'kind'>, handler: (even
|
|
|
197
197
|
* ```
|
|
198
198
|
*/
|
|
199
199
|
declare function onAuth(config: Omit<EventTriggerAuth, 'kind'>, handler: (event: AuthEvent, ctx: EventCtx) => unknown | Promise<unknown>): RegisteredHandler<AuthEvent>;
|
|
200
|
+
/**
|
|
201
|
+
* Convenience wrapper for `onAuth({ op: 'registered' }, …)` — the most common
|
|
202
|
+
* auth hook. Fires once when a new user signs up; the canonical place to
|
|
203
|
+
* **provision** them: create their app-side profile, add them to a group/role,
|
|
204
|
+
* seed their initial records, send a welcome. `event.data.profile` carries the
|
|
205
|
+
* custom fields passed to `platform.auth.register({ profile: {...} })`.
|
|
206
|
+
*
|
|
207
|
+
* ```ts
|
|
208
|
+
* export const provision = onRegister(async (event, ctx) => {
|
|
209
|
+
* const members = await ctx.auth.getGroupByName('members');
|
|
210
|
+
* if (members) await ctx.auth.addUserToGroup(event.userId, members.id);
|
|
211
|
+
* await ctx.database.insertOne('profiles', { _id: event.userId, email: event.data?.email, created_at: event.ts });
|
|
212
|
+
* });
|
|
213
|
+
* ```
|
|
214
|
+
*/
|
|
215
|
+
declare function onRegister(handler: (event: AuthEvent, ctx: EventCtx) => unknown | Promise<unknown>): RegisteredHandler<AuthEvent>;
|
|
200
216
|
declare function onSchedule(cron: string, handler: (event: ScheduleEvent, ctx: EventCtx) => unknown | Promise<unknown>): RegisteredHandler<ScheduleEvent>;
|
|
201
217
|
declare function onQueue<T = unknown>(name: string, config: Omit<EventTriggerQueue, 'kind' | 'name'>, handler: (messages: QueueMessage<T>[], ctx: EventCtx) => unknown | Promise<unknown>): RegisteredHandler<QueueMessage<T>[]>;
|
|
202
218
|
declare function onChannel<T = unknown>(config: Omit<EventTriggerChannel, 'kind'>, handler: (event: ChannelEvent<T>, ctx: EventCtx) => unknown | Promise<unknown>): RegisteredHandler<ChannelEvent<T>>;
|
|
@@ -222,4 +238,4 @@ declare function defineEvent(config: Omit<EventTriggerRen, 'kind'>, handler: (ev
|
|
|
222
238
|
/** Type guard used by the build-time discoverer and the runtime registry. */
|
|
223
239
|
declare function isRegisteredHandler(value: unknown): value is RegisteredHandler;
|
|
224
240
|
|
|
225
|
-
export { type AuthEvent, type AuthOp, type ChannelEvent, type DbChangeEvent, type DeployEvent, type EventCtx, type EventTrigger, type EventTriggerAuth, type EventTriggerChannel, type EventTriggerDb, type EventTriggerDeploy, type EventTriggerKv, type EventTriggerQueue, type EventTriggerRen, type EventTriggerSchedule, type EventTriggerStorage, type KvChangeEvent, type QueueMessage, type RegisteredHandler, type ScheduleEvent, type StorageEvent, TRIGGER_SYMBOL, defineEvent, isRegisteredHandler, onAuth, onChannel, onDbChange, onDeploy, onKvChange, onQueue, onSchedule, onStorage };
|
|
241
|
+
export { type AuthEvent, type AuthOp, type ChannelEvent, type DbChangeEvent, type DeployEvent, type EventCtx, type EventTrigger, type EventTriggerAuth, type EventTriggerChannel, type EventTriggerDb, type EventTriggerDeploy, type EventTriggerKv, type EventTriggerQueue, type EventTriggerRen, type EventTriggerSchedule, type EventTriggerStorage, type KvChangeEvent, type QueueMessage, type RegisteredHandler, type ScheduleEvent, type StorageEvent, TRIGGER_SYMBOL, defineEvent, isRegisteredHandler, onAuth, onChannel, onDbChange, onDeploy, onKvChange, onQueue, onRegister, onSchedule, onStorage };
|
package/dist/events.js
CHANGED
|
@@ -12,6 +12,9 @@ function onDbChange(config, handler) {
|
|
|
12
12
|
function onAuth(config, handler) {
|
|
13
13
|
return register({ kind: "auth", ...config }, handler);
|
|
14
14
|
}
|
|
15
|
+
function onRegister(handler) {
|
|
16
|
+
return onAuth({ op: "registered" }, handler);
|
|
17
|
+
}
|
|
15
18
|
function onSchedule(cron, handler) {
|
|
16
19
|
return register({ kind: "schedule", cron }, handler);
|
|
17
20
|
}
|
|
@@ -43,6 +46,7 @@ export {
|
|
|
43
46
|
onDeploy,
|
|
44
47
|
onKvChange,
|
|
45
48
|
onQueue,
|
|
49
|
+
onRegister,
|
|
46
50
|
onSchedule,
|
|
47
51
|
onStorage
|
|
48
52
|
};
|
package/dist/events.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/events.ts"],"sourcesContent":["/**\n * @fileoverview Event handler registration helpers for Maravilla.\n *\n * User apps declare event handlers in `events.ts` or `events/*.ts`:\n *\n * ```ts\n * import { onKvChange, defineEvent } from '@maravilla-labs/platform/events';\n *\n * export const invalidateCache = onKvChange(\n * { namespace: 'config', keyPattern: 'feature:*' },\n * async (event, ctx) => { await ctx.kv.delete('cache', event.key!); },\n * );\n * ```\n *\n * These helpers are pure factories. They produce a `RegisteredHandler`\n * marker object that the build pipeline (`@maravilla-labs/functions`\n * `discoverEvents`) detects by its `__maravilla_trigger` property.\n * The Rust event dispatcher uses the trigger config from the manifest\n * and invokes the bundled handler via `globalThis.handleEvent(id, event)`.\n */\n\nimport type { RenEvent } from './ren.js';\n\n// ============ Trigger descriptors ============\n// Kept structurally identical to adapter-core's `EventTrigger` — duplicated\n// here so the runtime SDK has no build-time dep on adapter-core. Keep in sync.\n\nexport type EventTrigger =\n | EventTriggerKv\n | EventTriggerDb\n | EventTriggerAuth\n | EventTriggerSchedule\n | EventTriggerQueue\n | EventTriggerChannel\n | EventTriggerDeploy\n | EventTriggerStorage\n | EventTriggerRen;\n\nexport interface EventTriggerKv {\n kind: 'kv';\n namespace?: string;\n keyPattern?: string;\n op?: 'put' | 'delete' | 'expired';\n}\n\nexport interface EventTriggerDb {\n kind: 'db';\n collection: string;\n op?: 'insert' | 'update' | 'delete';\n}\n\nexport type AuthOp =\n | 'registered'\n | 'logged_in'\n | 'logged_out'\n | 'logged_out_all'\n | 'deleted'\n | 'updated';\n\nexport interface EventTriggerAuth {\n kind: 'auth';\n op?: AuthOp;\n}\n\nexport interface EventTriggerSchedule {\n kind: 'schedule';\n cron: string;\n}\n\nexport interface EventTriggerQueue {\n kind: 'queue';\n name: string;\n batch?: number;\n maxAttempts?: number;\n}\n\nexport interface EventTriggerChannel {\n kind: 'channel';\n channel: string;\n type?: string;\n}\n\nexport interface EventTriggerDeploy {\n kind: 'deploy';\n phase: 'ready' | 'draining' | 'stopped';\n}\n\n/**\n * Object storage trigger — fires when an object is created or removed\n * under the matching `keyPattern`. Used by both hand-written `onStorage`\n * handlers and by the synthesized handlers that the adapter compiles\n * from a `transforms` block in `maravilla.config.ts`.\n */\nexport interface EventTriggerStorage {\n kind: 'storage';\n /** Glob-style storage key pattern (e.g. `\"uploads/videos/**\"`). Omit to match all objects. */\n keyPattern?: string;\n /** Operation filter; omit to match both `put` and `delete`. */\n op?: 'put' | 'delete';\n}\n\nexport interface EventTriggerRen {\n kind: 'ren';\n match: { r?: string; t?: string; ns?: string };\n}\n\n// ============ Event payload shapes ============\n\nexport interface KvChangeEvent {\n op: 'put' | 'delete' | 'expired';\n namespace: string;\n key: string;\n /** Present on `put`. */\n value?: unknown;\n ts: number;\n}\n\nexport interface DbChangeEvent {\n op: 'insert' | 'update' | 'delete';\n collection: string;\n id: string;\n doc?: unknown;\n before?: unknown;\n after?: unknown;\n ts: number;\n}\n\nexport interface AuthEvent {\n op: AuthOp;\n userId: string;\n /**\n * Op-specific payload:\n * - `registered` → `{ email, provider, profile }` where `profile` is the\n * app's custom fields passed to `platform.auth.register({ profile: {...} })`.\n * - `logged_in` → `{ email }`\n * - `logged_out` → `{ sessionId }`\n * - `logged_out_all` / `deleted` / `updated` → null or empty.\n */\n data?: {\n email?: string;\n provider?: string;\n profile?: Record<string, unknown> | null;\n sessionId?: string;\n [k: string]: unknown;\n };\n ts: number;\n}\n\nexport interface ScheduleEvent {\n cron: string;\n scheduledAt: number;\n firedAt: number;\n}\n\nexport interface QueueMessage<T = unknown> {\n id: string;\n payload: T;\n attempt: number;\n enqueuedAt: number;\n}\n\nexport interface ChannelEvent<T = unknown> {\n channel: string;\n type: string;\n data?: T;\n uid?: string;\n ts: number;\n}\n\nexport interface DeployEvent {\n phase: 'ready' | 'draining' | 'stopped';\n ts: number;\n}\n\n/**\n * Storage event payload — emitted by the dev-server / runtime when an\n * object is put or deleted. The synthesized transforms handlers consume\n * the `key` field to look up the source object.\n */\nexport interface StorageEvent {\n op: 'put' | 'delete';\n key: string;\n /** Present on `put` — content type from the upload metadata, when known. */\n contentType?: string;\n /** Present on `put` — size in bytes, when known. */\n size?: number;\n ts: number;\n}\n\n// ============ Handler context ============\n\nexport interface EventCtx {\n /** Per-tenant env vars. */\n env: Record<string, string>;\n /** KV store — same shape as `getPlatform().env.KV` / `platform.kv`. */\n kv?: unknown;\n /** MongoDB-style database — same shape as `getPlatform().env.DB`. */\n database?: unknown;\n /** Object storage. */\n storage?: unknown;\n /** Durable queue producer (`.send(name, payload, opts?)`). */\n queue?: { send: (name: string, payload: unknown, opts?: unknown) => Promise<string> };\n /** Auth service — register/login/logout/user CRUD/etc. */\n auth?: unknown;\n /** Web Push service. */\n push?: unknown;\n /** Full platform object — escape hatch for services not surfaced above. */\n platform?: unknown;\n /** Trace correlation id — propagate through logs. */\n traceId: string;\n tenant: string;\n handlerId: string;\n}\n\n// ============ Registered handler marker ============\n\nexport const TRIGGER_SYMBOL = '__maravilla_trigger' as const;\n\nexport interface RegisteredHandler<Event = unknown, Ctx = EventCtx> {\n readonly [TRIGGER_SYMBOL]: EventTrigger;\n readonly handler: (event: Event, ctx: Ctx) => unknown | Promise<unknown>;\n}\n\nfunction register<Event, Ctx = EventCtx>(\n trigger: EventTrigger,\n handler: (event: Event, ctx: Ctx) => unknown | Promise<unknown>,\n): RegisteredHandler<Event, Ctx> {\n return { [TRIGGER_SYMBOL]: trigger, handler };\n}\n\n// ============ Public factory helpers ============\n\nexport function onKvChange(\n config: Omit<EventTriggerKv, 'kind'>,\n handler: (event: KvChangeEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<KvChangeEvent> {\n return register({ kind: 'kv', ...config }, handler);\n}\n\nexport function onDbChange(\n config: Omit<EventTriggerDb, 'kind'>,\n handler: (event: DbChangeEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<DbChangeEvent> {\n return register({ kind: 'db', ...config }, handler);\n}\n\n/**\n * React to user authentication events: registration, login, logout,\n * deletion, update. Omit `op` to match all auth events; set it to\n * narrow to a specific lifecycle transition.\n *\n * ```ts\n * export const welcomeEmail = onAuth(\n * { op: 'registered' },\n * async (event, ctx) => {\n * await sendWelcomeEmail(event.data?.email);\n * },\n * );\n * ```\n */\nexport function onAuth(\n config: Omit<EventTriggerAuth, 'kind'>,\n handler: (event: AuthEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<AuthEvent> {\n return register({ kind: 'auth', ...config }, handler);\n}\n\nexport function onSchedule(\n cron: string,\n handler: (event: ScheduleEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<ScheduleEvent> {\n return register({ kind: 'schedule', cron }, handler);\n}\n\nexport function onQueue<T = unknown>(\n name: string,\n config: Omit<EventTriggerQueue, 'kind' | 'name'>,\n handler: (messages: QueueMessage<T>[], ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<QueueMessage<T>[]> {\n return register({ kind: 'queue', name, ...config }, handler);\n}\n\nexport function onChannel<T = unknown>(\n config: Omit<EventTriggerChannel, 'kind'>,\n handler: (event: ChannelEvent<T>, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<ChannelEvent<T>> {\n return register({ kind: 'channel', ...config }, handler);\n}\n\nexport function onDeploy(\n phase: EventTriggerDeploy['phase'],\n handler: (event: DeployEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<DeployEvent> {\n return register({ kind: 'deploy', phase }, handler);\n}\n\n/**\n * React to object-storage `put` / `delete` events. `keyPattern` is a\n * glob (e.g. `\"uploads/videos/**\"`). Omit `op` to match both put and\n * delete; omit `keyPattern` to match every object in the tenant's\n * bucket.\n *\n * ```ts\n * export const onUpload = onStorage(\n * { keyPattern: 'uploads/photos/**', op: 'put' },\n * async (event, ctx) => {\n * await ctx.platform.media.transforms.resize(event.key, { width: 1600, format: 'webp' });\n * },\n * );\n * ```\n */\nexport function onStorage(\n config: Omit<EventTriggerStorage, 'kind'>,\n handler: (event: StorageEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<StorageEvent> {\n return register({ kind: 'storage', ...config }, handler);\n}\n\n/** Escape hatch for arbitrary `RenEvent` matches. */\nexport function defineEvent(\n config: Omit<EventTriggerRen, 'kind'>,\n handler: (event: RenEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<RenEvent> {\n return register({ kind: 'ren', ...config }, handler);\n}\n\n/** Type guard used by the build-time discoverer and the runtime registry. */\nexport function isRegisteredHandler(value: unknown): value is RegisteredHandler {\n return (\n typeof value === 'object' &&\n value !== null &&\n TRIGGER_SYMBOL in value &&\n typeof (value as Record<string, unknown>).handler === 'function'\n );\n}\n"],"mappings":";AAwNO,IAAM,iBAAiB;AAO9B,SAAS,SACP,SACA,SAC+B;AAC/B,SAAO,EAAE,CAAC,cAAc,GAAG,SAAS,QAAQ;AAC9C;AAIO,SAAS,WACd,QACA,SACkC;AAClC,SAAO,SAAS,EAAE,MAAM,MAAM,GAAG,OAAO,GAAG,OAAO;AACpD;AAEO,SAAS,WACd,QACA,SACkC;AAClC,SAAO,SAAS,EAAE,MAAM,MAAM,GAAG,OAAO,GAAG,OAAO;AACpD;AAgBO,SAAS,OACd,QACA,SAC8B;AAC9B,SAAO,SAAS,EAAE,MAAM,QAAQ,GAAG,OAAO,GAAG,OAAO;AACtD;AAEO,SAAS,WACd,MACA,SACkC;AAClC,SAAO,SAAS,EAAE,MAAM,YAAY,KAAK,GAAG,OAAO;AACrD;AAEO,SAAS,QACd,MACA,QACA,SACsC;AACtC,SAAO,SAAS,EAAE,MAAM,SAAS,MAAM,GAAG,OAAO,GAAG,OAAO;AAC7D;AAEO,SAAS,UACd,QACA,SACoC;AACpC,SAAO,SAAS,EAAE,MAAM,WAAW,GAAG,OAAO,GAAG,OAAO;AACzD;AAEO,SAAS,SACd,OACA,SACgC;AAChC,SAAO,SAAS,EAAE,MAAM,UAAU,MAAM,GAAG,OAAO;AACpD;AAiBO,SAAS,UACd,QACA,SACiC;AACjC,SAAO,SAAS,EAAE,MAAM,WAAW,GAAG,OAAO,GAAG,OAAO;AACzD;AAGO,SAAS,YACd,QACA,SAC6B;AAC7B,SAAO,SAAS,EAAE,MAAM,OAAO,GAAG,OAAO,GAAG,OAAO;AACrD;AAGO,SAAS,oBAAoB,OAA4C;AAC9E,SACE,OAAO,UAAU,YACjB,UAAU,QACV,kBAAkB,SAClB,OAAQ,MAAkC,YAAY;AAE1D;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/events.ts"],"sourcesContent":["/**\n * @fileoverview Event handler registration helpers for Maravilla.\n *\n * User apps declare event handlers in `events.ts` or `events/*.ts`:\n *\n * ```ts\n * import { onKvChange, defineEvent } from '@maravilla-labs/platform/events';\n *\n * export const invalidateCache = onKvChange(\n * { namespace: 'config', keyPattern: 'feature:*' },\n * async (event, ctx) => { await ctx.kv.delete('cache', event.key!); },\n * );\n * ```\n *\n * These helpers are pure factories. They produce a `RegisteredHandler`\n * marker object that the build pipeline (`@maravilla-labs/functions`\n * `discoverEvents`) detects by its `__maravilla_trigger` property.\n * The Rust event dispatcher uses the trigger config from the manifest\n * and invokes the bundled handler via `globalThis.handleEvent(id, event)`.\n */\n\nimport type { RenEvent } from './ren.js';\n\n// ============ Trigger descriptors ============\n// Kept structurally identical to adapter-core's `EventTrigger` — duplicated\n// here so the runtime SDK has no build-time dep on adapter-core. Keep in sync.\n\nexport type EventTrigger =\n | EventTriggerKv\n | EventTriggerDb\n | EventTriggerAuth\n | EventTriggerSchedule\n | EventTriggerQueue\n | EventTriggerChannel\n | EventTriggerDeploy\n | EventTriggerStorage\n | EventTriggerRen;\n\nexport interface EventTriggerKv {\n kind: 'kv';\n namespace?: string;\n keyPattern?: string;\n op?: 'put' | 'delete' | 'expired';\n}\n\nexport interface EventTriggerDb {\n kind: 'db';\n collection: string;\n op?: 'insert' | 'update' | 'delete';\n}\n\nexport type AuthOp =\n | 'registered'\n | 'logged_in'\n | 'logged_out'\n | 'logged_out_all'\n | 'deleted'\n | 'updated';\n\nexport interface EventTriggerAuth {\n kind: 'auth';\n op?: AuthOp;\n}\n\nexport interface EventTriggerSchedule {\n kind: 'schedule';\n cron: string;\n}\n\nexport interface EventTriggerQueue {\n kind: 'queue';\n name: string;\n batch?: number;\n maxAttempts?: number;\n}\n\nexport interface EventTriggerChannel {\n kind: 'channel';\n channel: string;\n type?: string;\n}\n\nexport interface EventTriggerDeploy {\n kind: 'deploy';\n phase: 'ready' | 'draining' | 'stopped';\n}\n\n/**\n * Object storage trigger — fires when an object is created or removed\n * under the matching `keyPattern`. Used by both hand-written `onStorage`\n * handlers and by the synthesized handlers that the adapter compiles\n * from a `transforms` block in `maravilla.config.ts`.\n */\nexport interface EventTriggerStorage {\n kind: 'storage';\n /** Glob-style storage key pattern (e.g. `\"uploads/videos/**\"`). Omit to match all objects. */\n keyPattern?: string;\n /** Operation filter; omit to match both `put` and `delete`. */\n op?: 'put' | 'delete';\n}\n\nexport interface EventTriggerRen {\n kind: 'ren';\n match: { r?: string; t?: string; ns?: string };\n}\n\n// ============ Event payload shapes ============\n\nexport interface KvChangeEvent {\n op: 'put' | 'delete' | 'expired';\n namespace: string;\n key: string;\n /** Present on `put`. */\n value?: unknown;\n ts: number;\n}\n\nexport interface DbChangeEvent {\n op: 'insert' | 'update' | 'delete';\n collection: string;\n id: string;\n doc?: unknown;\n before?: unknown;\n after?: unknown;\n ts: number;\n}\n\nexport interface AuthEvent {\n op: AuthOp;\n userId: string;\n /**\n * Op-specific payload:\n * - `registered` → `{ email, provider, profile }` where `profile` is the\n * app's custom fields passed to `platform.auth.register({ profile: {...} })`.\n * - `logged_in` → `{ email }`\n * - `logged_out` → `{ sessionId }`\n * - `logged_out_all` / `deleted` / `updated` → null or empty.\n */\n data?: {\n email?: string;\n provider?: string;\n profile?: Record<string, unknown> | null;\n sessionId?: string;\n [k: string]: unknown;\n };\n ts: number;\n}\n\nexport interface ScheduleEvent {\n cron: string;\n scheduledAt: number;\n firedAt: number;\n}\n\nexport interface QueueMessage<T = unknown> {\n id: string;\n payload: T;\n attempt: number;\n enqueuedAt: number;\n}\n\nexport interface ChannelEvent<T = unknown> {\n channel: string;\n type: string;\n data?: T;\n uid?: string;\n ts: number;\n}\n\nexport interface DeployEvent {\n phase: 'ready' | 'draining' | 'stopped';\n ts: number;\n}\n\n/**\n * Storage event payload — emitted by the dev-server / runtime when an\n * object is put or deleted. The synthesized transforms handlers consume\n * the `key` field to look up the source object.\n */\nexport interface StorageEvent {\n op: 'put' | 'delete';\n key: string;\n /** Present on `put` — content type from the upload metadata, when known. */\n contentType?: string;\n /** Present on `put` — size in bytes, when known. */\n size?: number;\n ts: number;\n}\n\n// ============ Handler context ============\n\nexport interface EventCtx {\n /** Per-tenant env vars. */\n env: Record<string, string>;\n /** KV store — same shape as `getPlatform().env.KV` / `platform.kv`. */\n kv?: unknown;\n /** MongoDB-style database — same shape as `getPlatform().env.DB`. */\n database?: unknown;\n /** Object storage. */\n storage?: unknown;\n /** Durable queue producer (`.send(name, payload, opts?)`). */\n queue?: { send: (name: string, payload: unknown, opts?: unknown) => Promise<string> };\n /** Auth service — register/login/logout/user CRUD/etc. */\n auth?: unknown;\n /** Web Push service. */\n push?: unknown;\n /** Full platform object — escape hatch for services not surfaced above. */\n platform?: unknown;\n /** Trace correlation id — propagate through logs. */\n traceId: string;\n tenant: string;\n handlerId: string;\n}\n\n// ============ Registered handler marker ============\n\nexport const TRIGGER_SYMBOL = '__maravilla_trigger' as const;\n\nexport interface RegisteredHandler<Event = unknown, Ctx = EventCtx> {\n readonly [TRIGGER_SYMBOL]: EventTrigger;\n readonly handler: (event: Event, ctx: Ctx) => unknown | Promise<unknown>;\n}\n\nfunction register<Event, Ctx = EventCtx>(\n trigger: EventTrigger,\n handler: (event: Event, ctx: Ctx) => unknown | Promise<unknown>,\n): RegisteredHandler<Event, Ctx> {\n return { [TRIGGER_SYMBOL]: trigger, handler };\n}\n\n// ============ Public factory helpers ============\n\nexport function onKvChange(\n config: Omit<EventTriggerKv, 'kind'>,\n handler: (event: KvChangeEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<KvChangeEvent> {\n return register({ kind: 'kv', ...config }, handler);\n}\n\nexport function onDbChange(\n config: Omit<EventTriggerDb, 'kind'>,\n handler: (event: DbChangeEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<DbChangeEvent> {\n return register({ kind: 'db', ...config }, handler);\n}\n\n/**\n * React to user authentication events: registration, login, logout,\n * deletion, update. Omit `op` to match all auth events; set it to\n * narrow to a specific lifecycle transition.\n *\n * ```ts\n * export const welcomeEmail = onAuth(\n * { op: 'registered' },\n * async (event, ctx) => {\n * await sendWelcomeEmail(event.data?.email);\n * },\n * );\n * ```\n */\nexport function onAuth(\n config: Omit<EventTriggerAuth, 'kind'>,\n handler: (event: AuthEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<AuthEvent> {\n return register({ kind: 'auth', ...config }, handler);\n}\n\n/**\n * Convenience wrapper for `onAuth({ op: 'registered' }, …)` — the most common\n * auth hook. Fires once when a new user signs up; the canonical place to\n * **provision** them: create their app-side profile, add them to a group/role,\n * seed their initial records, send a welcome. `event.data.profile` carries the\n * custom fields passed to `platform.auth.register({ profile: {...} })`.\n *\n * ```ts\n * export const provision = onRegister(async (event, ctx) => {\n * const members = await ctx.auth.getGroupByName('members');\n * if (members) await ctx.auth.addUserToGroup(event.userId, members.id);\n * await ctx.database.insertOne('profiles', { _id: event.userId, email: event.data?.email, created_at: event.ts });\n * });\n * ```\n */\nexport function onRegister(\n handler: (event: AuthEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<AuthEvent> {\n return onAuth({ op: 'registered' }, handler);\n}\n\nexport function onSchedule(\n cron: string,\n handler: (event: ScheduleEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<ScheduleEvent> {\n return register({ kind: 'schedule', cron }, handler);\n}\n\nexport function onQueue<T = unknown>(\n name: string,\n config: Omit<EventTriggerQueue, 'kind' | 'name'>,\n handler: (messages: QueueMessage<T>[], ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<QueueMessage<T>[]> {\n return register({ kind: 'queue', name, ...config }, handler);\n}\n\nexport function onChannel<T = unknown>(\n config: Omit<EventTriggerChannel, 'kind'>,\n handler: (event: ChannelEvent<T>, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<ChannelEvent<T>> {\n return register({ kind: 'channel', ...config }, handler);\n}\n\nexport function onDeploy(\n phase: EventTriggerDeploy['phase'],\n handler: (event: DeployEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<DeployEvent> {\n return register({ kind: 'deploy', phase }, handler);\n}\n\n/**\n * React to object-storage `put` / `delete` events. `keyPattern` is a\n * glob (e.g. `\"uploads/videos/**\"`). Omit `op` to match both put and\n * delete; omit `keyPattern` to match every object in the tenant's\n * bucket.\n *\n * ```ts\n * export const onUpload = onStorage(\n * { keyPattern: 'uploads/photos/**', op: 'put' },\n * async (event, ctx) => {\n * await ctx.platform.media.transforms.resize(event.key, { width: 1600, format: 'webp' });\n * },\n * );\n * ```\n */\nexport function onStorage(\n config: Omit<EventTriggerStorage, 'kind'>,\n handler: (event: StorageEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<StorageEvent> {\n return register({ kind: 'storage', ...config }, handler);\n}\n\n/** Escape hatch for arbitrary `RenEvent` matches. */\nexport function defineEvent(\n config: Omit<EventTriggerRen, 'kind'>,\n handler: (event: RenEvent, ctx: EventCtx) => unknown | Promise<unknown>,\n): RegisteredHandler<RenEvent> {\n return register({ kind: 'ren', ...config }, handler);\n}\n\n/** Type guard used by the build-time discoverer and the runtime registry. */\nexport function isRegisteredHandler(value: unknown): value is RegisteredHandler {\n return (\n typeof value === 'object' &&\n value !== null &&\n TRIGGER_SYMBOL in value &&\n typeof (value as Record<string, unknown>).handler === 'function'\n );\n}\n"],"mappings":";AAwNO,IAAM,iBAAiB;AAO9B,SAAS,SACP,SACA,SAC+B;AAC/B,SAAO,EAAE,CAAC,cAAc,GAAG,SAAS,QAAQ;AAC9C;AAIO,SAAS,WACd,QACA,SACkC;AAClC,SAAO,SAAS,EAAE,MAAM,MAAM,GAAG,OAAO,GAAG,OAAO;AACpD;AAEO,SAAS,WACd,QACA,SACkC;AAClC,SAAO,SAAS,EAAE,MAAM,MAAM,GAAG,OAAO,GAAG,OAAO;AACpD;AAgBO,SAAS,OACd,QACA,SAC8B;AAC9B,SAAO,SAAS,EAAE,MAAM,QAAQ,GAAG,OAAO,GAAG,OAAO;AACtD;AAiBO,SAAS,WACd,SAC8B;AAC9B,SAAO,OAAO,EAAE,IAAI,aAAa,GAAG,OAAO;AAC7C;AAEO,SAAS,WACd,MACA,SACkC;AAClC,SAAO,SAAS,EAAE,MAAM,YAAY,KAAK,GAAG,OAAO;AACrD;AAEO,SAAS,QACd,MACA,QACA,SACsC;AACtC,SAAO,SAAS,EAAE,MAAM,SAAS,MAAM,GAAG,OAAO,GAAG,OAAO;AAC7D;AAEO,SAAS,UACd,QACA,SACoC;AACpC,SAAO,SAAS,EAAE,MAAM,WAAW,GAAG,OAAO,GAAG,OAAO;AACzD;AAEO,SAAS,SACd,OACA,SACgC;AAChC,SAAO,SAAS,EAAE,MAAM,UAAU,MAAM,GAAG,OAAO;AACpD;AAiBO,SAAS,UACd,QACA,SACiC;AACjC,SAAO,SAAS,EAAE,MAAM,WAAW,GAAG,OAAO,GAAG,OAAO;AACzD;AAGO,SAAS,YACd,QACA,SAC6B;AAC7B,SAAO,SAAS,EAAE,MAAM,OAAO,GAAG,OAAO,GAAG,OAAO;AACrD;AAGO,SAAS,oBAAoB,OAA4C;AAC9E,SACE,OAAO,UAAU,YACjB,UAAU,QACV,kBAAkB,SAClB,OAAQ,MAAkC,YAAY;AAE1D;","names":[]}
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -262,7 +262,8 @@ export interface ResourceDefinition {
|
|
|
262
262
|
* caller's filter on `db.find` / `db.findOne`. Supports `$auth.<path>`
|
|
263
263
|
* placeholder strings (e.g. `"$auth.user_id"`) substituted from the
|
|
264
264
|
* caller's identity at request time. Allowed paths: `user_id`, `email`,
|
|
265
|
-
* `is_admin`, `roles
|
|
265
|
+
* `is_admin`, `status`, `email_verified`, `groups`, `roles`, `circles`,
|
|
266
|
+
* `profile.<field>`, `scopes.<field>`.
|
|
266
267
|
*
|
|
267
268
|
* Independent of `policy` — `policy` gates writes and resolved-doc reads;
|
|
268
269
|
* `read_filter` scopes which rows the caller can ever see.
|
|
@@ -270,6 +271,19 @@ export interface ResourceDefinition {
|
|
|
270
271
|
* Example: `'{"$or":[{"owner":"$auth.user_id"},{"public":true}]}'`
|
|
271
272
|
*/
|
|
272
273
|
read_filter?: string;
|
|
274
|
+
/**
|
|
275
|
+
* Field-level redaction. Maps a field dot-path to a raisin-rel expression;
|
|
276
|
+
* a field is replaced with null on read when its expression is truthy for
|
|
277
|
+
* the caller (fail-closed). Example: `{ birth_date: "!auth.is_admin" }`.
|
|
278
|
+
*/
|
|
279
|
+
redact?: Record<string, string>;
|
|
280
|
+
/** When true, every policy decision on this resource is recorded. */
|
|
281
|
+
audit?: boolean;
|
|
282
|
+
/**
|
|
283
|
+
* Fallback decision when this resource has no `policy`: `'allow'` (the
|
|
284
|
+
* default) or `'deny'`. Overrides the tenant-wide `security.default_policy`.
|
|
285
|
+
*/
|
|
286
|
+
default_policy?: 'allow' | 'deny';
|
|
273
287
|
}
|
|
274
288
|
|
|
275
289
|
// ── Groups ──
|
|
@@ -284,6 +298,8 @@ export interface GroupPermissionDefinition {
|
|
|
284
298
|
export interface GroupDefinition {
|
|
285
299
|
/** Unique group name per tenant. */
|
|
286
300
|
name: string;
|
|
301
|
+
/** Tenant-unique slug for stable addressing in JWT/API/policies. */
|
|
302
|
+
slug?: string;
|
|
287
303
|
/** Optional description for the admin UI. */
|
|
288
304
|
description?: string;
|
|
289
305
|
/** Resource permissions granted to the group. Replaces the group's current permissions when declared. */
|
|
@@ -300,6 +316,8 @@ export interface RelationTypeDefinition {
|
|
|
300
316
|
* and is cross-validated against this list at {@link defineConfig} time.
|
|
301
317
|
*/
|
|
302
318
|
relation_name: string;
|
|
319
|
+
/** Tenant-unique slug for stable addressing. */
|
|
320
|
+
slug?: string;
|
|
303
321
|
/** Human-readable title. */
|
|
304
322
|
title: string;
|
|
305
323
|
description?: string;
|
package/src/events.ts
CHANGED
|
@@ -265,6 +265,27 @@ export function onAuth(
|
|
|
265
265
|
return register({ kind: 'auth', ...config }, handler);
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Convenience wrapper for `onAuth({ op: 'registered' }, …)` — the most common
|
|
270
|
+
* auth hook. Fires once when a new user signs up; the canonical place to
|
|
271
|
+
* **provision** them: create their app-side profile, add them to a group/role,
|
|
272
|
+
* seed their initial records, send a welcome. `event.data.profile` carries the
|
|
273
|
+
* custom fields passed to `platform.auth.register({ profile: {...} })`.
|
|
274
|
+
*
|
|
275
|
+
* ```ts
|
|
276
|
+
* export const provision = onRegister(async (event, ctx) => {
|
|
277
|
+
* const members = await ctx.auth.getGroupByName('members');
|
|
278
|
+
* if (members) await ctx.auth.addUserToGroup(event.userId, members.id);
|
|
279
|
+
* await ctx.database.insertOne('profiles', { _id: event.userId, email: event.data?.email, created_at: event.ts });
|
|
280
|
+
* });
|
|
281
|
+
* ```
|
|
282
|
+
*/
|
|
283
|
+
export function onRegister(
|
|
284
|
+
handler: (event: AuthEvent, ctx: EventCtx) => unknown | Promise<unknown>,
|
|
285
|
+
): RegisteredHandler<AuthEvent> {
|
|
286
|
+
return onAuth({ op: 'registered' }, handler);
|
|
287
|
+
}
|
|
288
|
+
|
|
268
289
|
export function onSchedule(
|
|
269
290
|
cron: string,
|
|
270
291
|
handler: (event: ScheduleEvent, ctx: EventCtx) => unknown | Promise<unknown>,
|