@maravilla-labs/platform 0.1.35 → 0.1.38

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/src/config.ts ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * @fileoverview Typed schema for `maravilla.config.{ts,yaml,json}` files.
3
+ *
4
+ * Declares your project's auth settings (resources, groups, relations,
5
+ * registration fields, OAuth providers, security policy, branding) alongside
6
+ * your code. The Maravilla adapter reads this at build time and reconciles
7
+ * the settings into delivery on deploy.
8
+ *
9
+ * ```typescript
10
+ * import { defineConfig } from '@maravilla-labs/platform/config';
11
+ *
12
+ * export default defineConfig({
13
+ * auth: {
14
+ * resources: [
15
+ * { name: 'todos', title: 'Todos', actions: ['read', 'write'],
16
+ * policy: 'auth.user_id == node.owner' },
17
+ * ],
18
+ * },
19
+ * });
20
+ * ```
21
+ *
22
+ * Omitted sections leave the DB alone — partial adoption is explicitly
23
+ * supported. List-based sections (`resources`, `groups`, `relations`,
24
+ * `oauth`) are upserted and never auto-delete DB-only entries. Singleton
25
+ * sections (`registration`, `security`, `branding`) are replaced wholesale
26
+ * when declared.
27
+ */
28
+
29
+ /**
30
+ * String value that may either be a literal secret or a reference to an
31
+ * environment variable on the **tenant** (resolved server-side at
32
+ * reconcile time, never shipped plaintext in the manifest).
33
+ *
34
+ * Accepted forms:
35
+ * - `"literal-value"` — inline (not recommended for real secrets)
36
+ * - `"${env.VAR_NAME}"` — string-template form
37
+ * - `{ env: "VAR_NAME" }` — object form
38
+ */
39
+ export type SecretRef = string | { env: string };
40
+
41
+ // ── Resources + policies ──
42
+
43
+ export interface ResourceDefinition {
44
+ /** URL-safe slug. Used as the resource key in code (e.g. the KV namespace). */
45
+ name: string;
46
+ /** Human-readable title for the admin UI. */
47
+ title: string;
48
+ /** Optional longer description. */
49
+ description?: string;
50
+ /** Actions this resource supports, e.g. `['read', 'write', 'delete']`. */
51
+ actions: string[];
52
+ /**
53
+ * Optional raisin-rel policy expression. Evaluated on every KV/DB/
54
+ * realtime/media op that targets this resource. Leave empty to skip
55
+ * Layer 2 for this resource — tenant + owner isolation still applies.
56
+ */
57
+ policy?: string;
58
+ }
59
+
60
+ // ── Groups ──
61
+
62
+ export interface GroupPermissionDefinition {
63
+ /** Must match a `ResourceDefinition.name`. */
64
+ resource_name: string;
65
+ /** Actions this group is granted on the resource. */
66
+ actions: string[];
67
+ }
68
+
69
+ export interface GroupDefinition {
70
+ /** Unique group name per tenant. */
71
+ name: string;
72
+ /** Optional description for the admin UI. */
73
+ description?: string;
74
+ /** Resource permissions granted to the group. Replaces the group's current permissions when declared. */
75
+ permissions?: GroupPermissionDefinition[];
76
+ }
77
+
78
+ // ── Relations ──
79
+
80
+ export interface RelationTypeDefinition {
81
+ /** Uppercase identifier used in policies (`... VIA 'STEWARDS'`). */
82
+ relation_name: string;
83
+ /** Human-readable title. */
84
+ title: string;
85
+ description?: string;
86
+ /** Grouping for the admin UI (e.g. `"family"`, `"work"`). */
87
+ category?: string;
88
+ icon?: string;
89
+ color?: string;
90
+ /** Name of the inverse relation type, if one exists. */
91
+ inverse_relation_name?: string;
92
+ /** When true, membership in this relation implies stewardship rights. */
93
+ implies_stewardship?: boolean;
94
+ /** When true, the relation can only target users flagged as minors. */
95
+ requires_minor?: boolean;
96
+ /** When true, the relation is symmetric (A→B implies B→A). */
97
+ bidirectional?: boolean;
98
+ }
99
+
100
+ // ── Registration fields ──
101
+
102
+ export interface RegistrationFieldDefinition {
103
+ /** Field key used as the form field name + in profile data. */
104
+ key: string;
105
+ /** Display label. */
106
+ label: string;
107
+ /** One of: text, email, phone, date, number, select, boolean, url, textarea. */
108
+ field_type: string;
109
+ required: boolean;
110
+ show_on_register: boolean;
111
+ /** Optional validation metadata — passed through to the UI. */
112
+ validation?: Record<string, unknown>;
113
+ }
114
+
115
+ export interface RegistrationConfig {
116
+ /** Ordered list of custom registration fields. Declaring this replaces the full list. */
117
+ fields: RegistrationFieldDefinition[];
118
+ }
119
+
120
+ // ── OAuth providers ──
121
+
122
+ export interface OAuthProviderDefinition {
123
+ enabled: boolean;
124
+ client_id: string;
125
+ /** Prefer `{ env: "VAR_NAME" }` or `"${env.VAR_NAME}"`. */
126
+ client_secret: SecretRef;
127
+ scopes: string[];
128
+ /** Only for `custom_oidc`. */
129
+ discovery_url?: string;
130
+ }
131
+
132
+ export interface OAuthProvidersConfig {
133
+ google?: OAuthProviderDefinition;
134
+ github?: OAuthProviderDefinition;
135
+ okta?: OAuthProviderDefinition;
136
+ custom_oidc?: OAuthProviderDefinition;
137
+ }
138
+
139
+ // ── Security ──
140
+
141
+ export interface PasswordPolicyDefinition {
142
+ min_length: number;
143
+ require_uppercase: boolean;
144
+ require_number: boolean;
145
+ require_special: boolean;
146
+ }
147
+
148
+ export interface SessionConfigDefinition {
149
+ access_token_ttl_secs: number;
150
+ refresh_token_ttl_secs: number;
151
+ max_sessions_per_user: number;
152
+ require_email_verification: boolean;
153
+ }
154
+
155
+ export interface SecurityConfig {
156
+ password_policy?: PasswordPolicyDefinition;
157
+ session?: SessionConfigDefinition;
158
+ }
159
+
160
+ // ── Branding ──
161
+
162
+ export interface BrandingConfig {
163
+ app_name?: string;
164
+ logo_url?: string;
165
+ primary_color?: string;
166
+ secondary_color?: string;
167
+ welcome_message?: string;
168
+ welcome_subtitle?: string;
169
+ /** `"centered"`, `"split-left"`, `"split-right"`, or `"fullscreen"`. */
170
+ layout?: string;
171
+ background_image_url?: string;
172
+ /** 0–100 percentage. */
173
+ background_focal_point?: { x: number; y: number };
174
+ background_gradient?: string;
175
+ /** `"light"`, `"dark"`, or `"auto"`. */
176
+ color_mode?: string;
177
+ font_family?: string;
178
+ terms_url?: string;
179
+ privacy_url?: string;
180
+ /** Raw CSS merged into the hosted auth pages. */
181
+ custom_css?: string;
182
+ }
183
+
184
+ // ── Top-level shape ──
185
+
186
+ export interface AuthConfigBlock {
187
+ resources?: ResourceDefinition[];
188
+ groups?: GroupDefinition[];
189
+ relations?: RelationTypeDefinition[];
190
+ registration?: RegistrationConfig;
191
+ oauth?: OAuthProvidersConfig;
192
+ security?: SecurityConfig;
193
+ branding?: BrandingConfig;
194
+ }
195
+
196
+ export interface MaravillaConfig {
197
+ /** All project-level auth settings. Every field is optional — partial adoption is supported. */
198
+ auth?: AuthConfigBlock;
199
+ }
200
+
201
+ /**
202
+ * Identity function that returns the config unchanged — exists purely so the
203
+ * TypeScript compiler can infer `MaravillaConfig` and give you IntelliSense
204
+ * on every field.
205
+ *
206
+ * @example
207
+ * ```typescript
208
+ * import { defineConfig } from '@maravilla-labs/platform/config';
209
+ *
210
+ * export default defineConfig({
211
+ * auth: {
212
+ * resources: [{ name: 'todos', title: 'Todos', actions: ['read', 'write'] }],
213
+ * },
214
+ * });
215
+ * ```
216
+ */
217
+ export function defineConfig(config: MaravillaConfig): MaravillaConfig {
218
+ return config;
219
+ }
package/src/index.ts CHANGED
@@ -64,7 +64,7 @@ declare global {
64
64
  var platform: Platform | undefined;
65
65
  }
66
66
 
67
- let cachedPlatform: Platform | null = null;
67
+ let cachedPlatform: Platform | undefined = undefined;
68
68
 
69
69
  /**
70
70
  * Get the platform instance. This will:
@@ -167,7 +167,7 @@ export function getPlatform(options?: {
167
167
  * ```
168
168
  */
169
169
  export function clearPlatformCache(): void {
170
- cachedPlatform = null;
170
+ cachedPlatform = undefined;
171
171
  if (typeof globalThis !== 'undefined') {
172
172
  globalThis.__maravilla_platform = undefined;
173
173
  }
@@ -1,4 +1,4 @@
1
- import type { KvNamespace, KvListResult, Database, DbFindOptions, Storage, RealtimeService, PresenceService } from './types.js';
1
+ import type { KvNamespace, KvListResult, Database, DbFindOptions, Storage, RealtimeService, PresenceService, AuthService, AuthCaller, AuthUser, AuthSession, AuthField, RegisterOptions, LoginOptions, UserListFilter, UserListResponse, UpdateUserOptions, PolicyService } from './types.js';
2
2
  import { RemoteMediaService } from './media.js';
3
3
 
4
4
  /**
@@ -405,6 +405,176 @@ class RemoteRealtimeService implements RealtimeService {
405
405
  }
406
406
  }
407
407
 
408
+ /**
409
+ * Remote auth service for development environments.
410
+ * Calls the dev-server's platform auth ops via HTTP.
411
+ * @internal
412
+ */
413
+ class RemoteAuthService implements AuthService {
414
+ constructor(
415
+ private baseUrl: string,
416
+ private headers: Record<string, string>
417
+ ) {}
418
+
419
+ private async post(path: string, body?: any): Promise<any> {
420
+ const res = await fetch(`${this.baseUrl}/api/platform/auth${path}`, {
421
+ method: 'POST',
422
+ headers: this.headers,
423
+ body: body !== undefined ? JSON.stringify(body) : undefined,
424
+ });
425
+ if (!res.ok) {
426
+ const text = await res.text();
427
+ throw new Error(`Auth error (${res.status}): ${text}`);
428
+ }
429
+ if (res.status === 204) return undefined;
430
+ return res.json();
431
+ }
432
+
433
+ async register(options: RegisterOptions): Promise<AuthUser> {
434
+ return this.post('/register', options);
435
+ }
436
+
437
+ async login(options: LoginOptions): Promise<AuthSession> {
438
+ return this.post('/login', options);
439
+ }
440
+
441
+ async validate(accessToken: string): Promise<AuthUser> {
442
+ return this.post('/validate', { token: accessToken });
443
+ }
444
+
445
+ async refresh(refreshToken: string): Promise<AuthSession> {
446
+ return this.post('/refresh', { token: refreshToken });
447
+ }
448
+
449
+ async logout(sessionId: string): Promise<void> {
450
+ await this.post('/logout', { session_id: sessionId });
451
+ }
452
+
453
+ async getUser(userId: string): Promise<AuthUser | null> {
454
+ return this.post('/get-user', { user_id: userId });
455
+ }
456
+
457
+ async listUsers(filter?: UserListFilter): Promise<UserListResponse> {
458
+ return this.post('/list-users', filter ?? {});
459
+ }
460
+
461
+ async updateUser(userId: string, update: UpdateUserOptions): Promise<AuthUser> {
462
+ return this.post('/update-user', { user_id: userId, ...update });
463
+ }
464
+
465
+ async deleteUser(userId: string): Promise<void> {
466
+ await this.post('/delete-user', { user_id: userId });
467
+ }
468
+
469
+ async sendVerification(userId: string): Promise<{ token: string }> {
470
+ return this.post('/send-verification', { user_id: userId });
471
+ }
472
+
473
+ async verifyEmail(token: string): Promise<void> {
474
+ await this.post('/verify-email', { token });
475
+ }
476
+
477
+ async sendPasswordReset(email: string): Promise<{ token: string }> {
478
+ return this.post('/send-password-reset', { email });
479
+ }
480
+
481
+ async resetPassword(token: string, newPassword: string): Promise<void> {
482
+ await this.post('/reset-password', { token, new_password: newPassword });
483
+ }
484
+
485
+ async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {
486
+ await this.post('/change-password', { user_id: userId, old_password: oldPassword, new_password: newPassword });
487
+ }
488
+
489
+ async getFieldConfig(): Promise<{ fields: AuthField[] }> {
490
+ return this.post('/field-config');
491
+ }
492
+
493
+ async getOAuthUrl(provider: string, options?: { redirectUri?: string }): Promise<{ auth_url: string; state: string }> {
494
+ return this.post('/oauth/start', { provider, redirect_uri: options?.redirectUri || `/_auth/callback/${provider}` });
495
+ }
496
+
497
+ async handleOAuthCallback(provider: string, params: { code: string; state: string }): Promise<any> {
498
+ return this.post('/oauth/callback', { provider, code: params.code, state: params.state });
499
+ }
500
+
501
+ withAuth<T extends (request: Request & { user: AuthUser }) => Promise<Response>>(
502
+ handler: T
503
+ ): (request: Request) => Promise<Response> {
504
+ const self = this;
505
+ return async function(request: Request): Promise<Response> {
506
+ let token: string | null = null;
507
+ const authHeader = request.headers.get('Authorization');
508
+ if (authHeader?.startsWith('Bearer ')) {
509
+ token = authHeader.slice(7);
510
+ } else {
511
+ const cookies = request.headers.get('Cookie') ?? '';
512
+ const match = cookies.match(/__session=([^;]+)/);
513
+ if (match) token = match[1];
514
+ }
515
+ if (!token) {
516
+ return new Response(JSON.stringify({ error: 'Authentication required' }), {
517
+ status: 401, headers: { 'Content-Type': 'application/json' },
518
+ });
519
+ }
520
+ try {
521
+ const user = await self.validate(token);
522
+ (request as any).user = user;
523
+ return handler(request as Request & { user: AuthUser });
524
+ } catch {
525
+ return new Response(JSON.stringify({ error: 'Invalid or expired token' }), {
526
+ status: 401, headers: { 'Content-Type': 'application/json' },
527
+ });
528
+ }
529
+ };
530
+ }
531
+
532
+ // ── Request-scoped identity + authorization ──
533
+ //
534
+ // These APIs operate on per-request state inside the platform runtime and
535
+ // don't have a remote equivalent. Throw so callers get a clear message
536
+ // instead of silently wrong behavior.
537
+
538
+ setCurrentUser(_token: string | null): Promise<void> {
539
+ return Promise.reject(new Error(
540
+ 'platform.auth.setCurrentUser is only available inside the Maravilla runtime. ' +
541
+ 'Remote clients should pass the Authorization header with each request instead.'
542
+ ));
543
+ }
544
+
545
+ getCurrentUser(): AuthCaller {
546
+ throw new Error(
547
+ 'platform.auth.getCurrentUser is only available inside the Maravilla runtime. ' +
548
+ 'Remote clients have no per-request caller context.'
549
+ );
550
+ }
551
+
552
+ can(_action: string, _resourceId: string, _node?: Record<string, unknown> | null): Promise<boolean> {
553
+ return Promise.reject(new Error(
554
+ 'platform.auth.can is only available inside the Maravilla runtime. ' +
555
+ 'Remote clients cannot evaluate per-request policies because there is no bound caller.'
556
+ ));
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Remote stub for the per-request Layer 2 policy toggle. The toggle lives in
562
+ * per-request state inside the runtime and has no remote equivalent.
563
+ */
564
+ class RemotePolicyService implements PolicyService {
565
+ setEnabled(_enabled: boolean): void {
566
+ throw new Error(
567
+ 'platform.policy.setEnabled is only available inside the Maravilla runtime.'
568
+ );
569
+ }
570
+
571
+ isEnabled(): boolean {
572
+ throw new Error(
573
+ 'platform.policy.isEnabled is only available inside the Maravilla runtime.'
574
+ );
575
+ }
576
+ }
577
+
408
578
  /**
409
579
  * Create a remote platform client for development environments.
410
580
  *
@@ -446,6 +616,8 @@ export function createRemoteClient(baseUrl: string, tenant: string) {
446
616
  const storage = new RemoteStorage(baseUrl, headers);
447
617
  const media = new RemoteMediaService(baseUrl, headers);
448
618
  const realtime = new RemoteRealtimeService(baseUrl, headers);
619
+ const auth = new RemoteAuthService(baseUrl, headers);
620
+ const policy = new RemotePolicyService();
449
621
 
450
622
  return {
451
623
  env: {
@@ -455,5 +627,7 @@ export function createRemoteClient(baseUrl: string, tenant: string) {
455
627
  },
456
628
  media,
457
629
  realtime,
630
+ auth,
631
+ policy,
458
632
  };
459
633
  }