@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/dist/config.d.ts +192 -0
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +353 -1
- package/dist/index.js +141 -3
- package/dist/index.js.map +1 -1
- package/package.json +5 -1
- package/src/config.ts +219 -0
- package/src/index.ts +2 -2
- package/src/remote-client.ts +175 -1
- package/src/types.ts +376 -0
- package/tsup.config.ts +1 -1
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 |
|
|
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 =
|
|
170
|
+
cachedPlatform = undefined;
|
|
171
171
|
if (typeof globalThis !== 'undefined') {
|
|
172
172
|
globalThis.__maravilla_platform = undefined;
|
|
173
173
|
}
|
package/src/remote-client.ts
CHANGED
|
@@ -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
|
}
|