@maravilla-labs/platform 0.14.0 → 0.16.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/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @maravilla-labs/platform
2
2
 
3
+ ## 0.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4b6b127: Add a `zoom` option to `resize({ crop: 'focal' })` for face-box-relative crop
8
+ framing. Until now a focal crop only centered a fixed-size window on the stored
9
+ focal _point_ — the detected face's bounding-box size was discarded, so there
10
+ was no way to control how tightly the subject was framed. `zoom` (a scale
11
+ factor, `1.0..=100.0`) now sizes the crop window from the union of the source's
12
+ detected faces: `1.0` frames tightly (a headshot), larger pulls back for
13
+ headroom/context, and a value large enough to exceed the image saturates to the
14
+ plain scale-to-cover crop. It's declarable in the `maravilla.config.ts`
15
+ `transforms` block too.
16
+
17
+ Fully backward compatible: `zoom` is optional and never serializes when omitted,
18
+ so every existing focal/center crop hashes to the same derived key and is
19
+ byte-identical. `zoom` is only honoured for `crop: 'focal'` on a source that has
20
+ stored face data (run `detectFaces` first); it's ignored for `crop: 'center'`
21
+ and a no-op when no faces were detected.
22
+
23
+ Also adds two pure, isomorphic, framework-agnostic helpers to
24
+ `@maravilla-labs/platform` — `focalCssFrame()` and `unionFaceBox()` — the
25
+ browser-side counterpart to the server crop: they turn a source's focal
26
+ point + detected faces into `object-position` + `transform: scale` CSS values,
27
+ so one `<img>` can re-frame around the face(s) for any container aspect ratio
28
+ and zoom without a server round-trip or a pre-rendered crop per shape.
29
+
30
+ ## 0.15.0
31
+
32
+ ### Minor Changes
33
+
34
+ - c949abe: Add face detection & focal points. The platform client gains `detectFaces`,
35
+ `setFocalPoint`, `getFocalPoint`, and `clearFocalPoint`, plus `crop: 'focal' | 'center'`
36
+ on resize for face-aware smart crops. adapter-core supports declarative
37
+ `detectFaces` on transform patterns and focal-crop variants that wait for
38
+ detection before rendering.
39
+
3
40
  ## 0.14.0
4
41
 
5
42
  ### Minor Changes
package/dist/config.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as TransformsConfig } from './transforms-BHWez32E.js';
2
- export { a as TransformsPatternSpec } from './transforms-BHWez32E.js';
1
+ import { T as TransformsConfig } from './transforms-DkAlUPu6.js';
2
+ export { a as TransformsPatternSpec } from './transforms-DkAlUPu6.js';
3
3
 
4
4
  /**
5
5
  * @fileoverview Typed schema for `maravilla.config.{ts,yaml,json}` files.
@@ -264,6 +264,45 @@ interface SessionConfigDefinition {
264
264
  interface SecurityConfig {
265
265
  password_policy?: PasswordPolicyDefinition;
266
266
  session?: SessionConfigDefinition;
267
+ signup?: SignupProtectionConfig;
268
+ }
269
+ /**
270
+ * Bot protection for the registration endpoints, applied to both the hosted
271
+ * `/_auth/register` form and the `POST /api/platform/auth/register` JSON API.
272
+ *
273
+ * These layers are not equally strong:
274
+ *
275
+ * - `honeypot` (and its submit-timing check) stops untargeted form spam — bots
276
+ * that scrape the HTML and fill every field. Anyone who looks at the page
277
+ * defeats it. It is free, so it is on by default, but it is a filter, not a
278
+ * gate.
279
+ * - `rate_limit` bounds how fast anyone can register. On by default.
280
+ * - `challenge` is the only layer that imposes a real per-attempt cost. `"pow"`
281
+ * makes the client burn CPU; `"turnstile"` / `"hcaptcha"` outsource the
282
+ * judgement to a third party. Off by default.
283
+ *
284
+ * A server-to-server caller cannot solve a browser challenge, so it presents
285
+ * `signup_secret` in the `x-maravilla-signup-secret` header instead. Without
286
+ * that header the JSON endpoint enforces exactly the same challenge as the
287
+ * form — otherwise a bot would simply POST the JSON endpoint to escape.
288
+ */
289
+ interface SignupProtectionConfig {
290
+ /** Default `"none"`. */
291
+ challenge?: 'none' | 'pow' | 'turnstile' | 'hcaptcha';
292
+ /** Leading zero bits a proof-of-work solution must have. Default 18. */
293
+ pow_difficulty?: number;
294
+ /** Default `true`. */
295
+ honeypot?: boolean;
296
+ /** Default `true`. */
297
+ rate_limit?: boolean;
298
+ /** Prefer `{ env: "VAR_NAME" }` or `"${env.VAR_NAME}"`. */
299
+ signup_secret?: SecretRef;
300
+ /** Required when `challenge` is `"turnstile"`. */
301
+ turnstile_secret?: SecretRef;
302
+ /** Required when `challenge` is `"hcaptcha"`. */
303
+ hcaptcha_secret?: SecretRef;
304
+ /** Public widget key, rendered into the page. Not a secret. */
305
+ captcha_site_key?: string;
267
306
  }
268
307
  interface BrandingConfig {
269
308
  app_name?: string;
@@ -478,4 +517,4 @@ interface McpConfigBlock {
478
517
  */
479
518
  declare function defineConfig(config: MaravillaConfig): MaravillaConfig;
480
519
 
481
- export { type AuthConfigBlock, type BrandingConfig, type DatabaseConfigBlock, type DocumentIndexDeclaration, type EmailConfig, type GroupDefinition, type GroupPermissionDefinition, type IndexDirectionConfig, type MaravillaConfig, type McpConfigBlock, type McpConsentConfig, type McpServerConfig, type McpUiConfig, 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 };
520
+ export { type AuthConfigBlock, type BrandingConfig, type DatabaseConfigBlock, type DocumentIndexDeclaration, type EmailConfig, type GroupDefinition, type GroupPermissionDefinition, type IndexDirectionConfig, type MaravillaConfig, type McpConfigBlock, type McpConsentConfig, type McpServerConfig, type McpUiConfig, type OAuthProviderDefinition, type OAuthProvidersConfig, type PasswordPolicyDefinition, Policy, type RegistrationConfig, type RegistrationFieldDefinition, type RelationTypeDefinition, type ResourceDefinition, type ResourceServiceType, type SecretRef, type SecurityConfig, type SessionConfigDefinition, type SignupProtectionConfig, TransformsConfig, type VectorIndexDeclaration, type VectorMetricConfig, type VectorStorageConfig, assertNoLegacyShapes, defineConfig, fragment, isAdmin, isStaff, ownsIt, publicWhen, relatesVia };
@@ -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`, `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// ── Email ──\n\n/**\n * Transactional email settings for the hosted `_auth` flows (currently the\n * password-reset email). Reconciled server-side on deploy.\n *\n * The visible `From` *address* is always the platform's verified sender\n * (Maravilla Cloud) and is not configurable. The `From` display *name* is safe\n * to customise via `from_name`; surface your own address via `reply_to`.\n */\nexport interface EmailConfig {\n /** Master switch. When `false`, the reset flow still mints a token but no email is sent. Defaults to `true`. */\n enabled?: boolean;\n /** `From` display name (e.g. `\"Acme Support\"`) → `Acme Support <noreply@maravilla.cloud>`. The address is fixed; only the name is yours. */\n from_name?: string;\n /** Reply-To address. The visible `From` is always the platform sender; replies go here. */\n reply_to?: string;\n /** Override the password-reset email subject line. */\n password_reset_subject?: string;\n /** Full custom HTML body. Use the literal `{{reset_url}}` placeholder for the link. */\n password_reset_html?: string;\n /** Full custom plain-text body (same `{{reset_url}}` placeholder). */\n password_reset_text?: string;\n /** Enable passwordless \"magic link\" email login on the hosted `/_auth/login` page. Off by default; requires `enabled` to send. */\n magic_link_enabled?: boolean;\n /** Override the magic-link email subject line. */\n magic_link_subject?: string;\n /** Full custom HTML body for the magic-link email. Use the literal `{{magic_link_url}}` placeholder. */\n magic_link_html?: string;\n /** Full custom plain-text body for the magic-link email (same `{{magic_link_url}}` placeholder). */\n magic_link_text?: string;\n /** Override the email-verification email subject line. */\n verification_subject?: string;\n /** Full custom HTML body for the verification email. Use the literal `{{verify_url}}` placeholder. */\n verification_html?: string;\n /** Full custom plain-text body for the verification email (same `{{verify_url}}` placeholder). */\n verification_text?: 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 /** Transactional email settings for the hosted `_auth` flows. */\n email?: EmailConfig;\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 * MCP server block — turns the app's `mcp/` folder into an MCP server at\n * `<app-url>/_mcp`. See {@link McpConfigBlock}.\n */\n mcp?: McpConfigBlock;\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// ── MCP block ──\n//\n// The `mcp` block turns the app's `mcp/` folder into an MCP server hosted at\n// `<app-url>/_mcp`. It is the master switch (`enabled`) plus the scope set\n// agents are offered at login — the runtime is the OAuth Authorization Server,\n// so agents authenticate with the app's own accounts (or a headless `mk_…`\n// key). Tools, server identity, and UI templates themselves are authored in\n// `mcp/*.ts` via `defineMcpServer` / `defineMcpTool`. Mirrors the Rust\n// `platform::mcp::McpConfig`.\n\n/** Server identity advertised to agents in the MCP `initialize` handshake. */\nexport interface McpServerConfig {\n /** Human-readable server name (e.g. \"Notes\"). */\n name: string;\n /** Optional semantic version. */\n version?: string;\n /** Optional natural-language usage instructions for the client/model. */\n instructions?: string;\n}\n\n/** Allow-list of routes that may be framed as MCP UI mini-apps. */\nexport interface McpUiConfig {\n /** Route globs allowed to render as iframe mini-apps (e.g. `/_mcp/ui/*`). */\n routes: string[];\n}\n\n/** Whether to persist a user's scope grant so they aren't re-prompted. */\nexport interface McpConsentConfig {\n remember?: boolean;\n}\n\nexport interface McpConfigBlock {\n /** Master switch. With `false` (the default) the `/_mcp` endpoint is off. */\n enabled: boolean;\n /** Server identity advertised to agents. */\n server?: McpServerConfig;\n /**\n * Every scope any tool can require, advertised to agents at login / Dynamic\n * Client Registration. A tool whose `scopes` aren't listed here can never be\n * granted. A tool with no scopes is public (callable without a login).\n */\n scopes?: string[];\n /** Allow-list of UI mini-app routes (frame-ancestors / loopback gate). */\n ui?: McpUiConfig;\n /** Public origin agents connect to, when it differs from the request host. */\n publicUrl?: string;\n /** Consent persistence toggle. */\n consent?: McpConsentConfig;\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":";AA+DA,IAAM,gBAAgB;AACtB,IAAM,iBAAiB;AAEvB,IAAM,cAAc;AAQb,SAAS,qBAAqB,MAAoB;AAGvD,MAAI,kBAAkB,KAAK,IAAI,GAAG;AAChC,UAAM,IAAI,MAAM,gGAA2F;AAAA,EAC7G;AACA,MAAI,gBAAgB,KAAK,IAAI,GAAG;AAC9B,UAAM,IAAI,MAAM,8FAAyF;AAAA,EAC3G;AAIA,MAAI,uBAAuB,KAAK,IAAI,GAAG;AACrC,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAGA,QAAM,QAAQ;AACd,MAAI;AACJ,UAAQ,IAAI,MAAM,KAAK,IAAI,OAAO,MAAM;AACtC,UAAM,UAAU,EAAE,CAAC;AACnB,QAAI,CAAC,WAAW,CAAC,YAAY,KAAK,OAAO,GAAG;AAC1C,YAAM,IAAI;AAAA,QACR;AAAA,MAEF;AAAA,IACF;AAAA,EACF;AACF;AAQO,IAAM,SAAN,MAAM,QAAO;AAAA,EACV,YAA6B,MAAc;AAAd;AAAA,EAAe;AAAA;AAAA;AAAA;AAAA;AAAA,EAMpD,OAAO,IAAI,MAAsB;AAC/B,yBAAqB,IAAI;AACzB,WAAO,IAAI,QAAO,IAAI;AAAA,EACxB;AAAA;AAAA,EAGA,OAAe,UAAU,MAAsB;AAC7C,WAAO,IAAI,QAAO,IAAI;AAAA,EACxB;AAAA,EAEA,WAAmB;AACjB,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGA,GAAG,OAAuB;AACxB,WAAO,QAAO,UAAU,GAAG,KAAK,IAAI,OAAO,MAAM,IAAI,EAAE;AAAA,EACzD;AAAA;AAAA,EAGA,IAAI,OAAuB;AACzB,WAAO,QAAO,UAAU,GAAG,KAAK,IAAI,OAAO,MAAM,IAAI,EAAE;AAAA,EACzD;AACF;AAMO,SAAS,OAAO,QAAgB,SAAiB;AACtD,SAAO,OAAO,IAAI,wBAAwB,KAAK,EAAE;AACnD;AAMO,SAAS,QAAQ,QAAgB,SAAiB;AACvD,SAAO,OAAO,IAAI,wBAAwB,KAAK,IAAI;AACrD;AAMO,SAAS,UAAkB;AAChC,SAAO,OAAO,IAAI,eAAe;AACnC;AAYO,SAAS,WACd,cACA,MACQ;AACR,QAAM,UAAU,MAAM,WAAW;AACjC,QAAM,SAAS,MAAM,UAAU;AAC/B,MAAI,OAAO,GAAG,MAAM,YAAY,OAAO,SAAS,YAAY;AAC5D,MAAI,MAAM,OAAO;AACf,YAAQ,UAAU,KAAK,MAAM,CAAC,CAAC,KAAK,KAAK,MAAM,CAAC,CAAC;AAAA,EACnD;AACA,SAAO,OAAO,IAAI,IAAI;AACxB;AAMO,SAAS,WAAW,QAAgB,UAAkB;AAC3D,SAAO,OAAO,IAAI,QAAQ,KAAK,UAAU;AAC3C;AAOO,SAAS,SAAS,MAAsB;AAG7C,SAAO,OAAO,IAAI,GAAG,aAAa,GAAG,IAAI,GAAG,cAAc,EAAE;AAC9D;AA+ZA,SAAS,gBACP,MACA,WACA,OAA4B,oBAAI,IAAI,GAC5B;AACR,SAAO,KAAK,QAAQ,aAAa,CAAC,QAAQ,YAAoB;AAC5D,UAAM,OAAO,QAAQ,KAAK;AAC1B,QAAI,EAAE,QAAQ,YAAY;AACxB,YAAM,IAAI;AAAA,QACR,qBAAqB,IAAI,6DACA,OAAO,KAAK,SAAS,EAAE,KAAK,IAAI,KAAK,QAAQ;AAAA,MACxE;AAAA,IACF;AACA,QAAI,KAAK,IAAI,IAAI,GAAG;AAClB,YAAM,IAAI;AAAA,QACR,qBAAqB,IAAI,oCAAoC,CAAC,GAAG,MAAM,IAAI,EAAE,KAAK,UAAK,CAAC;AAAA,MAC1F;AAAA,IACF;AACA,UAAM,WAAW,gBAAgB,UAAU,IAAI,GAAG,WAAW,oBAAI,IAAI,CAAC,GAAG,MAAM,IAAI,CAAC,CAAC;AACrF,WAAO,IAAI,SAAS,KAAK,CAAC;AAAA,EAC5B,CAAC;AACH;AAGA,SAAS,gBAAgB,MAAwB;AAC/C,QAAM,MAAgB,CAAC;AACvB,QAAM,KAAK;AACX,MAAI;AACJ,UAAQ,IAAI,GAAG,KAAK,IAAI,OAAO,KAAM,KAAI,KAAK,EAAE,CAAC,CAAC;AAClD,SAAO;AACT;AAGA,SAAS,aAAa,MAAwB;AAC5C,QAAM,MAAgB,CAAC;AACvB,QAAM,KAAK;AACX,MAAI;AACJ,UAAQ,IAAI,GAAG,KAAK,IAAI,OAAO,KAAM,KAAI,KAAK,EAAE,CAAC,CAAC;AAClD,SAAO;AACT;AA6BO,SAAS,aAAa,QAA0C;AACrE,QAAM,OAAO,OAAO;AACpB,MAAI,CAAC,KAAM,QAAO;AAIlB,QAAM,kBAA0C,CAAC;AACjD,MAAI,KAAK,WAAW;AAClB,eAAW,CAAC,MAAM,IAAI,KAAK,OAAO,QAAQ,KAAK,SAAS,GAAG;AACzD,sBAAgB,IAAI,IAAI,OAAO,SAAS,WAAW,OAAO,KAAK,SAAS;AAAA,IAC1E;AAAA,EACF;AAEA,QAAM,oBAAoB,IAAI,KAAK,KAAK,aAAa,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC;AACpF,QAAM,iBAAiB,IAAI,KAAK,KAAK,UAAU,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC;AACrE,QAAM,kBAAkB,KAAK,UAAU,CAAC,GAAG,SAAS;AAEpD,QAAM,YAAY,KAAK,WAAW,IAAI,CAAC,QAAQ;AAC7C,QAAI,IAAI,UAAU,KAAM,QAAO;AAC/B,UAAM,MAAM,OAAO,IAAI,WAAW,WAAW,IAAI,SAAS,IAAI,OAAO,SAAS;AAC9E,UAAM,WAAW,gBAAgB,KAAK,eAAe,EAAE,KAAK;AAE5D,eAAW,OAAO,gBAAgB,QAAQ,GAAG;AAC3C,UAAI,CAAC,kBAAkB,IAAI,GAAG,GAAG;AAC/B,cAAM,IAAI;AAAA,UACR,wBAAwB,IAAI,IAAI,0BAA0B,GAAG,uFAEpC,CAAC,GAAG,iBAAiB,EAAE,KAAK,IAAI,KAAK,QAAQ;AAAA,QACxE;AAAA,MACF;AAAA,IACF;AACA,QAAI,gBAAgB;AAClB,iBAAW,SAAS,aAAa,QAAQ,GAAG;AAC1C,YAAI,CAAC,eAAe,IAAI,KAAK,GAAG;AAC9B,gBAAM,IAAI;AAAA,YACR,wBAAwB,IAAI,IAAI,uBAAuB,KAAK,gEAEtC,CAAC,GAAG,cAAc,EAAE,KAAK,IAAI,CAAC;AAAA,UACtD;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAEA,WAAO,EAAE,GAAG,KAAK,QAAQ,SAAS;AAAA,EACpC,CAAC;AAED,SAAO;AAAA,IACL,GAAG;AAAA,IACH,MAAM;AAAA,MACJ,GAAG;AAAA,MACH,GAAI,YAAY,EAAE,UAAU,IAAI,CAAC;AAAA,IACnC;AAAA,EACF;AACF;","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 signup?: SignupProtectionConfig;\n}\n\n/**\n * Bot protection for the registration endpoints, applied to both the hosted\n * `/_auth/register` form and the `POST /api/platform/auth/register` JSON API.\n *\n * These layers are not equally strong:\n *\n * - `honeypot` (and its submit-timing check) stops untargeted form spam — bots\n * that scrape the HTML and fill every field. Anyone who looks at the page\n * defeats it. It is free, so it is on by default, but it is a filter, not a\n * gate.\n * - `rate_limit` bounds how fast anyone can register. On by default.\n * - `challenge` is the only layer that imposes a real per-attempt cost. `\"pow\"`\n * makes the client burn CPU; `\"turnstile\"` / `\"hcaptcha\"` outsource the\n * judgement to a third party. Off by default.\n *\n * A server-to-server caller cannot solve a browser challenge, so it presents\n * `signup_secret` in the `x-maravilla-signup-secret` header instead. Without\n * that header the JSON endpoint enforces exactly the same challenge as the\n * form — otherwise a bot would simply POST the JSON endpoint to escape.\n */\nexport interface SignupProtectionConfig {\n /** Default `\"none\"`. */\n challenge?: 'none' | 'pow' | 'turnstile' | 'hcaptcha';\n /** Leading zero bits a proof-of-work solution must have. Default 18. */\n pow_difficulty?: number;\n /** Default `true`. */\n honeypot?: boolean;\n /** Default `true`. */\n rate_limit?: boolean;\n /** Prefer `{ env: \"VAR_NAME\" }` or `\"${env.VAR_NAME}\"`. */\n signup_secret?: SecretRef;\n /** Required when `challenge` is `\"turnstile\"`. */\n turnstile_secret?: SecretRef;\n /** Required when `challenge` is `\"hcaptcha\"`. */\n hcaptcha_secret?: SecretRef;\n /** Public widget key, rendered into the page. Not a secret. */\n captcha_site_key?: string;\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// ── Email ──\n\n/**\n * Transactional email settings for the hosted `_auth` flows (currently the\n * password-reset email). Reconciled server-side on deploy.\n *\n * The visible `From` *address* is always the platform's verified sender\n * (Maravilla Cloud) and is not configurable. The `From` display *name* is safe\n * to customise via `from_name`; surface your own address via `reply_to`.\n */\nexport interface EmailConfig {\n /** Master switch. When `false`, the reset flow still mints a token but no email is sent. Defaults to `true`. */\n enabled?: boolean;\n /** `From` display name (e.g. `\"Acme Support\"`) → `Acme Support <noreply@maravilla.cloud>`. The address is fixed; only the name is yours. */\n from_name?: string;\n /** Reply-To address. The visible `From` is always the platform sender; replies go here. */\n reply_to?: string;\n /** Override the password-reset email subject line. */\n password_reset_subject?: string;\n /** Full custom HTML body. Use the literal `{{reset_url}}` placeholder for the link. */\n password_reset_html?: string;\n /** Full custom plain-text body (same `{{reset_url}}` placeholder). */\n password_reset_text?: string;\n /** Enable passwordless \"magic link\" email login on the hosted `/_auth/login` page. Off by default; requires `enabled` to send. */\n magic_link_enabled?: boolean;\n /** Override the magic-link email subject line. */\n magic_link_subject?: string;\n /** Full custom HTML body for the magic-link email. Use the literal `{{magic_link_url}}` placeholder. */\n magic_link_html?: string;\n /** Full custom plain-text body for the magic-link email (same `{{magic_link_url}}` placeholder). */\n magic_link_text?: string;\n /** Override the email-verification email subject line. */\n verification_subject?: string;\n /** Full custom HTML body for the verification email. Use the literal `{{verify_url}}` placeholder. */\n verification_html?: string;\n /** Full custom plain-text body for the verification email (same `{{verify_url}}` placeholder). */\n verification_text?: 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 /** Transactional email settings for the hosted `_auth` flows. */\n email?: EmailConfig;\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 * MCP server block — turns the app's `mcp/` folder into an MCP server at\n * `<app-url>/_mcp`. See {@link McpConfigBlock}.\n */\n mcp?: McpConfigBlock;\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// ── MCP block ──\n//\n// The `mcp` block turns the app's `mcp/` folder into an MCP server hosted at\n// `<app-url>/_mcp`. It is the master switch (`enabled`) plus the scope set\n// agents are offered at login — the runtime is the OAuth Authorization Server,\n// so agents authenticate with the app's own accounts (or a headless `mk_…`\n// key). Tools, server identity, and UI templates themselves are authored in\n// `mcp/*.ts` via `defineMcpServer` / `defineMcpTool`. Mirrors the Rust\n// `platform::mcp::McpConfig`.\n\n/** Server identity advertised to agents in the MCP `initialize` handshake. */\nexport interface McpServerConfig {\n /** Human-readable server name (e.g. \"Notes\"). */\n name: string;\n /** Optional semantic version. */\n version?: string;\n /** Optional natural-language usage instructions for the client/model. */\n instructions?: string;\n}\n\n/** Allow-list of routes that may be framed as MCP UI mini-apps. */\nexport interface McpUiConfig {\n /** Route globs allowed to render as iframe mini-apps (e.g. `/_mcp/ui/*`). */\n routes: string[];\n}\n\n/** Whether to persist a user's scope grant so they aren't re-prompted. */\nexport interface McpConsentConfig {\n remember?: boolean;\n}\n\nexport interface McpConfigBlock {\n /** Master switch. With `false` (the default) the `/_mcp` endpoint is off. */\n enabled: boolean;\n /** Server identity advertised to agents. */\n server?: McpServerConfig;\n /**\n * Every scope any tool can require, advertised to agents at login / Dynamic\n * Client Registration. A tool whose `scopes` aren't listed here can never be\n * granted. A tool with no scopes is public (callable without a login).\n */\n scopes?: string[];\n /** Allow-list of UI mini-app routes (frame-ancestors / loopback gate). */\n ui?: McpUiConfig;\n /** Public origin agents connect to, when it differs from the request host. */\n publicUrl?: string;\n /** Consent persistence toggle. */\n consent?: McpConsentConfig;\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":";AA+DA,IAAM,gBAAgB;AACtB,IAAM,iBAAiB;AAEvB,IAAM,cAAc;AAQb,SAAS,qBAAqB,MAAoB;AAGvD,MAAI,kBAAkB,KAAK,IAAI,GAAG;AAChC,UAAM,IAAI,MAAM,gGAA2F;AAAA,EAC7G;AACA,MAAI,gBAAgB,KAAK,IAAI,GAAG;AAC9B,UAAM,IAAI,MAAM,8FAAyF;AAAA,EAC3G;AAIA,MAAI,uBAAuB,KAAK,IAAI,GAAG;AACrC,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAGA,QAAM,QAAQ;AACd,MAAI;AACJ,UAAQ,IAAI,MAAM,KAAK,IAAI,OAAO,MAAM;AACtC,UAAM,UAAU,EAAE,CAAC;AACnB,QAAI,CAAC,WAAW,CAAC,YAAY,KAAK,OAAO,GAAG;AAC1C,YAAM,IAAI;AAAA,QACR;AAAA,MAEF;AAAA,IACF;AAAA,EACF;AACF;AAQO,IAAM,SAAN,MAAM,QAAO;AAAA,EACV,YAA6B,MAAc;AAAd;AAAA,EAAe;AAAA;AAAA;AAAA;AAAA;AAAA,EAMpD,OAAO,IAAI,MAAsB;AAC/B,yBAAqB,IAAI;AACzB,WAAO,IAAI,QAAO,IAAI;AAAA,EACxB;AAAA;AAAA,EAGA,OAAe,UAAU,MAAsB;AAC7C,WAAO,IAAI,QAAO,IAAI;AAAA,EACxB;AAAA,EAEA,WAAmB;AACjB,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGA,GAAG,OAAuB;AACxB,WAAO,QAAO,UAAU,GAAG,KAAK,IAAI,OAAO,MAAM,IAAI,EAAE;AAAA,EACzD;AAAA;AAAA,EAGA,IAAI,OAAuB;AACzB,WAAO,QAAO,UAAU,GAAG,KAAK,IAAI,OAAO,MAAM,IAAI,EAAE;AAAA,EACzD;AACF;AAMO,SAAS,OAAO,QAAgB,SAAiB;AACtD,SAAO,OAAO,IAAI,wBAAwB,KAAK,EAAE;AACnD;AAMO,SAAS,QAAQ,QAAgB,SAAiB;AACvD,SAAO,OAAO,IAAI,wBAAwB,KAAK,IAAI;AACrD;AAMO,SAAS,UAAkB;AAChC,SAAO,OAAO,IAAI,eAAe;AACnC;AAYO,SAAS,WACd,cACA,MACQ;AACR,QAAM,UAAU,MAAM,WAAW;AACjC,QAAM,SAAS,MAAM,UAAU;AAC/B,MAAI,OAAO,GAAG,MAAM,YAAY,OAAO,SAAS,YAAY;AAC5D,MAAI,MAAM,OAAO;AACf,YAAQ,UAAU,KAAK,MAAM,CAAC,CAAC,KAAK,KAAK,MAAM,CAAC,CAAC;AAAA,EACnD;AACA,SAAO,OAAO,IAAI,IAAI;AACxB;AAMO,SAAS,WAAW,QAAgB,UAAkB;AAC3D,SAAO,OAAO,IAAI,QAAQ,KAAK,UAAU;AAC3C;AAOO,SAAS,SAAS,MAAsB;AAG7C,SAAO,OAAO,IAAI,GAAG,aAAa,GAAG,IAAI,GAAG,cAAc,EAAE;AAC9D;AAucA,SAAS,gBACP,MACA,WACA,OAA4B,oBAAI,IAAI,GAC5B;AACR,SAAO,KAAK,QAAQ,aAAa,CAAC,QAAQ,YAAoB;AAC5D,UAAM,OAAO,QAAQ,KAAK;AAC1B,QAAI,EAAE,QAAQ,YAAY;AACxB,YAAM,IAAI;AAAA,QACR,qBAAqB,IAAI,6DACA,OAAO,KAAK,SAAS,EAAE,KAAK,IAAI,KAAK,QAAQ;AAAA,MACxE;AAAA,IACF;AACA,QAAI,KAAK,IAAI,IAAI,GAAG;AAClB,YAAM,IAAI;AAAA,QACR,qBAAqB,IAAI,oCAAoC,CAAC,GAAG,MAAM,IAAI,EAAE,KAAK,UAAK,CAAC;AAAA,MAC1F;AAAA,IACF;AACA,UAAM,WAAW,gBAAgB,UAAU,IAAI,GAAG,WAAW,oBAAI,IAAI,CAAC,GAAG,MAAM,IAAI,CAAC,CAAC;AACrF,WAAO,IAAI,SAAS,KAAK,CAAC;AAAA,EAC5B,CAAC;AACH;AAGA,SAAS,gBAAgB,MAAwB;AAC/C,QAAM,MAAgB,CAAC;AACvB,QAAM,KAAK;AACX,MAAI;AACJ,UAAQ,IAAI,GAAG,KAAK,IAAI,OAAO,KAAM,KAAI,KAAK,EAAE,CAAC,CAAC;AAClD,SAAO;AACT;AAGA,SAAS,aAAa,MAAwB;AAC5C,QAAM,MAAgB,CAAC;AACvB,QAAM,KAAK;AACX,MAAI;AACJ,UAAQ,IAAI,GAAG,KAAK,IAAI,OAAO,KAAM,KAAI,KAAK,EAAE,CAAC,CAAC;AAClD,SAAO;AACT;AA6BO,SAAS,aAAa,QAA0C;AACrE,QAAM,OAAO,OAAO;AACpB,MAAI,CAAC,KAAM,QAAO;AAIlB,QAAM,kBAA0C,CAAC;AACjD,MAAI,KAAK,WAAW;AAClB,eAAW,CAAC,MAAM,IAAI,KAAK,OAAO,QAAQ,KAAK,SAAS,GAAG;AACzD,sBAAgB,IAAI,IAAI,OAAO,SAAS,WAAW,OAAO,KAAK,SAAS;AAAA,IAC1E;AAAA,EACF;AAEA,QAAM,oBAAoB,IAAI,KAAK,KAAK,aAAa,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,aAAa,CAAC;AACpF,QAAM,iBAAiB,IAAI,KAAK,KAAK,UAAU,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC;AACrE,QAAM,kBAAkB,KAAK,UAAU,CAAC,GAAG,SAAS;AAEpD,QAAM,YAAY,KAAK,WAAW,IAAI,CAAC,QAAQ;AAC7C,QAAI,IAAI,UAAU,KAAM,QAAO;AAC/B,UAAM,MAAM,OAAO,IAAI,WAAW,WAAW,IAAI,SAAS,IAAI,OAAO,SAAS;AAC9E,UAAM,WAAW,gBAAgB,KAAK,eAAe,EAAE,KAAK;AAE5D,eAAW,OAAO,gBAAgB,QAAQ,GAAG;AAC3C,UAAI,CAAC,kBAAkB,IAAI,GAAG,GAAG;AAC/B,cAAM,IAAI;AAAA,UACR,wBAAwB,IAAI,IAAI,0BAA0B,GAAG,uFAEpC,CAAC,GAAG,iBAAiB,EAAE,KAAK,IAAI,KAAK,QAAQ;AAAA,QACxE;AAAA,MACF;AAAA,IACF;AACA,QAAI,gBAAgB;AAClB,iBAAW,SAAS,aAAa,QAAQ,GAAG;AAC1C,YAAI,CAAC,eAAe,IAAI,KAAK,GAAG;AAC9B,gBAAM,IAAI;AAAA,YACR,wBAAwB,IAAI,IAAI,uBAAuB,KAAK,gEAEtC,CAAC,GAAG,cAAc,EAAE,KAAK,IAAI,CAAC;AAAA,UACtD;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAEA,WAAO,EAAE,GAAG,KAAK,QAAQ,SAAS;AAAA,EACpC,CAAC;AAED,SAAO;AAAA,IACL,GAAG;AAAA,IACH,MAAM;AAAA,MACJ,GAAG;AAAA,MACH,GAAI,YAAY,EAAE,UAAU,IAAI,CAAC;AAAA,IACnC;AAAA,EACF;AACF;","names":[]}
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { ActAsContext, AddCircleMemberOptions, AddRelationOptions, ApiKeyInfo, A
3
3
  export { b as RenClient, a as RenClientOptions, R as RenEvent, g as getOrCreateClientId, r as renFetch, c as storageDelete, s as storageUpload } from './ren-DetHRhCL.js';
4
4
  import { LocalParticipant } from 'livekit-client';
5
5
  export { RegisterPushOptions, RegisterPushResult, offsetBefore, registerPush, unregisterPush } from './push.js';
6
- export { D as DetachAudioOpts, h as DocConvertOpts, e as DocFormat, m as DocInsertQrCodeOpts, l as DocReplaceImagesOpts, o as DocTemplateMergeOpts, g as DocThumbnailOpts, j as DocToHtmlOpts, i as DocToMarkdownOpts, f as DocToPdfOpts, d as ExtractAudioOpts, E as ExtractFramesOpts, F as FramesetManifest, I as ImageFormat, k as ImageRef, q as JobHandle, J as JobStatus, r as JobStatusResponse, M as MediaInfo, O as OcrOpts, Q as QrCodeSpec, n as QrPayload, R as ResizeOpts, c as ThumbnailOpts, b as TranscodeOpts, p as TransformSpec, T as TransformsConfig, a as TransformsPatternSpec, s as TransformsService, V as VideoFormat, t as keyFor, u as transforms } from './transforms-BHWez32E.js';
6
+ export { D as DetachAudioOpts, e as DetectFacesOpts, l as DocConvertOpts, i as DocFormat, q as DocInsertQrCodeOpts, p as DocReplaceImagesOpts, s as DocTemplateMergeOpts, k as DocThumbnailOpts, n as DocToHtmlOpts, m as DocToMarkdownOpts, j as DocToPdfOpts, d as ExtractAudioOpts, E as ExtractFramesOpts, g as FaceBox, z as FocalCssFrame, y as FocalCssFrameInput, f as FocalPoint, h as FocalPointRecord, F as FramesetManifest, I as ImageFormat, o as ImageRef, u as JobHandle, J as JobStatus, v as JobStatusResponse, M as MediaInfo, O as OcrOpts, Q as QrCodeSpec, r as QrPayload, R as ResizeOpts, S as SubjectBox, c as ThumbnailOpts, b as TranscodeOpts, t as TransformSpec, T as TransformsConfig, a as TransformsPatternSpec, w as TransformsService, V as VideoFormat, B as focalCssFrame, x as keyFor, C as transforms, A as unionFaceBox } from './transforms-DkAlUPu6.js';
7
7
 
8
8
  /**
9
9
  * Media service for video/audio room management.
@@ -257,26 +257,34 @@ interface Database {
257
257
  * @param collection - The collection name
258
258
  * @param filter - Query filter to match the document to update
259
259
  * @param update - Update operations (MongoDB-style with $set, $inc, etc.)
260
- * @returns Promise that resolves to an object with the number of modified documents
260
+ * @returns Promise that resolves to real matched/modified counts `modified`
261
+ * is 0 when the filter matched nothing, so a conditional filter plus an
262
+ * atomic operator is safe for enforcement (quotas, redemption caps, seats)
261
263
  *
262
264
  * @example
263
265
  * ```typescript
264
- * const result = await db.updateOne('users',
265
- * { email: 'john@example.com' },
266
- * { $set: { lastLogin: new Date() }, $inc: { loginCount: 1 } }
266
+ * // Atomic redemption-cap enforcement: only one of N concurrent checkouts
267
+ * // can win each remaining slot — no read-then-act needed.
268
+ * const result = await db.updateOne('discount_codes',
269
+ * { id, redeemedCount: { $lt: maxRedemptions } },
270
+ * { $inc: { redeemedCount: 1 } }
267
271
  * );
268
- * console.log(`Modified ${result.modified} documents`);
272
+ * if (result.modified === 0) throw new Error('code exhausted');
269
273
  * ```
270
274
  */
271
275
  updateOne(collection: string, filter: any, update: any): Promise<{
272
276
  modified: number;
277
+ matched: number;
278
+ matchedCount: number;
279
+ modifiedCount: number;
273
280
  }>;
274
281
  /**
275
282
  * Delete a single document from a collection.
276
283
  *
277
284
  * @param collection - The collection name
278
285
  * @param filter - Query filter to match the document to delete
279
- * @returns Promise that resolves to an object with the number of deleted documents
286
+ * @returns Promise that resolves to the real number of deleted documents
287
+ * (0 when the filter matched nothing)
280
288
  *
281
289
  * @example
282
290
  * ```typescript
@@ -286,6 +294,7 @@ interface Database {
286
294
  */
287
295
  deleteOne(collection: string, filter: any): Promise<{
288
296
  deleted: number;
297
+ deletedCount: number;
289
298
  }>;
290
299
  /**
291
300
  * Delete multiple documents from a collection.
package/dist/index.js CHANGED
@@ -1112,6 +1112,45 @@ var RemoteTransformsService = class {
1112
1112
  ocr(srcKey, opts) {
1113
1113
  return this.post("/ocr", { srcKey, opts: opts ?? {} });
1114
1114
  }
1115
+ detectFaces(srcKey, opts) {
1116
+ return this.post("/detect-faces", { srcKey, opts: opts ?? {} });
1117
+ }
1118
+ async setFocalPoint(srcKey, point) {
1119
+ const res = await fetch(`${this.baseUrl}/api/media/transforms/focal-point`, {
1120
+ method: "PUT",
1121
+ headers: { "Content-Type": "application/json", ...this.headers, ...getRequestAuthHeader() },
1122
+ body: JSON.stringify({ srcKey, x: point.x, y: point.y })
1123
+ });
1124
+ if (!res.ok) {
1125
+ const text = await res.text();
1126
+ throw new Error(`Focal-point set error (${res.status}): ${text}`);
1127
+ }
1128
+ return null;
1129
+ }
1130
+ async getFocalPoint(srcKey) {
1131
+ const res = await fetch(
1132
+ `${this.baseUrl}/api/media/transforms/focal-point?srcKey=${encodeURIComponent(srcKey)}`,
1133
+ { method: "GET", headers: { ...this.headers, ...getRequestAuthHeader() } }
1134
+ );
1135
+ if (res.status === 404) return null;
1136
+ if (!res.ok) {
1137
+ const text = await res.text();
1138
+ throw new Error(`Focal-point lookup error (${res.status}): ${text}`);
1139
+ }
1140
+ return res.json();
1141
+ }
1142
+ async clearFocalPoint(srcKey) {
1143
+ const res = await fetch(
1144
+ `${this.baseUrl}/api/media/transforms/focal-point?srcKey=${encodeURIComponent(srcKey)}`,
1145
+ { method: "DELETE", headers: { ...this.headers, ...getRequestAuthHeader() } }
1146
+ );
1147
+ if (!res.ok) {
1148
+ const text = await res.text();
1149
+ throw new Error(`Focal-point clear error (${res.status}): ${text}`);
1150
+ }
1151
+ const data = await res.json();
1152
+ return data.cleared;
1153
+ }
1115
1154
  docToPdf(srcKey, opts) {
1116
1155
  return this.post("/doc_to_pdf", { srcKey, opts: opts ?? {} });
1117
1156
  }
@@ -2025,6 +2064,8 @@ function outputExtension(spec) {
2025
2064
  const fmt = spec.format ?? "jpg";
2026
2065
  return fmt;
2027
2066
  }
2067
+ case "detect_faces":
2068
+ return "json";
2028
2069
  case "ocr":
2029
2070
  return "txt";
2030
2071
  case "doc_to_pdf":
@@ -2055,8 +2096,44 @@ function keyFor(srcKey, spec) {
2055
2096
  const ext = outputExtension(spec);
2056
2097
  return `__derived/${srcHash}/${variantHash}.${ext}`;
2057
2098
  }
2099
+ var clamp01 = (n) => Math.min(1, Math.max(0, n));
2100
+ function unionFaceBox(faces) {
2101
+ if (!faces || faces.length === 0) return null;
2102
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
2103
+ for (const f of faces) {
2104
+ x0 = Math.min(x0, f.x);
2105
+ y0 = Math.min(y0, f.y);
2106
+ x1 = Math.max(x1, f.x + f.w);
2107
+ y1 = Math.max(y1, f.y + f.h);
2108
+ }
2109
+ x0 = clamp01(x0);
2110
+ y0 = clamp01(y0);
2111
+ x1 = clamp01(x1);
2112
+ y1 = clamp01(y1);
2113
+ const w = x1 - x0;
2114
+ const h = y1 - y0;
2115
+ if (w <= 0 || h <= 0) return null;
2116
+ return { x: x0, y: y0, w, h };
2117
+ }
2118
+ function focalCssFrame(input) {
2119
+ const { focal, faces, imgWidth, imgHeight, containerAspect } = input;
2120
+ const zoom = input.zoom ?? 1;
2121
+ const objectPosition = `${(clamp01(focal.x) * 100).toFixed(1)}% ${(clamp01(focal.y) * 100).toFixed(1)}%`;
2122
+ const base = { objectPosition, scale: 1, transformOrigin: objectPosition };
2123
+ const box = unionFaceBox(faces);
2124
+ if (!box || imgWidth <= 0 || imgHeight <= 0 || !(containerAspect > 0)) return base;
2125
+ const iar = imgWidth / imgHeight;
2126
+ const car = containerAspect;
2127
+ const z = Math.max(1, zoom);
2128
+ const targetW = z * Math.max(box.w, box.h * car / iar);
2129
+ const coverW = car >= iar ? 1 : car / iar;
2130
+ const scale = Math.max(1, coverW / targetW);
2131
+ return { objectPosition, scale: Math.round(scale * 1e3) / 1e3, transformOrigin: objectPosition };
2132
+ }
2058
2133
  var transforms = {
2059
- keyFor
2134
+ keyFor,
2135
+ focalCssFrame,
2136
+ unionFaceBox
2060
2137
  };
2061
2138
 
2062
2139
  // src/index.ts
@@ -2103,6 +2180,7 @@ export {
2103
2180
  attachTrack,
2104
2181
  clearPlatformCache,
2105
2182
  detachTrack,
2183
+ focalCssFrame,
2106
2184
  getCurrentRequestStore,
2107
2185
  getOrCreateClientId,
2108
2186
  getPlatform,
@@ -2114,6 +2192,7 @@ export {
2114
2192
  storageDelete,
2115
2193
  storageUpload,
2116
2194
  transforms,
2195
+ unionFaceBox,
2117
2196
  unregisterPush
2118
2197
  };
2119
2198
  //# sourceMappingURL=index.js.map