@maravilla-labs/platform 0.3.5 → 0.3.8
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/index.d.ts +287 -1
- package/dist/index.js +287 -40
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +7 -1
- package/src/media.ts +2 -1
- package/src/remote-client.ts +268 -43
- package/src/request-scope.ts +133 -0
- package/src/types.ts +293 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -173,4 +173,10 @@ export function clearPlatformCache(): void {
|
|
|
173
173
|
if (typeof globalThis !== 'undefined') {
|
|
174
174
|
globalThis.__maravilla_platform = undefined;
|
|
175
175
|
}
|
|
176
|
-
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export {
|
|
179
|
+
runWithRequest,
|
|
180
|
+
getCurrentRequestStore,
|
|
181
|
+
type RequestStore,
|
|
182
|
+
} from './request-scope.js';
|
package/src/media.ts
CHANGED
|
@@ -50,9 +50,10 @@ export class RemoteMediaService implements MediaService {
|
|
|
50
50
|
) {}
|
|
51
51
|
|
|
52
52
|
private async fetch(url: string, options: RequestInit = {}) {
|
|
53
|
+
const { getRequestAuthHeader } = await import('./request-scope.js');
|
|
53
54
|
const response = await fetch(url, {
|
|
54
55
|
...options,
|
|
55
|
-
headers: { ...this.headers, ...options.headers },
|
|
56
|
+
headers: { ...this.headers, ...getRequestAuthHeader(), ...options.headers },
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
if (!response.ok) {
|
package/src/remote-client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { KvNamespace, KvListResult, Database, DbFindOptions, Storage, RealtimeService, PresenceService, AuthService, AuthCaller, AuthUser, AuthSession, AuthField, RegisterOptions, LoginOptions, UserListFilter, UserListResponse, UpdateUserOptions, PolicyService, VectorIndexSpec, VectorIndexDescriptor, VectorQueryWithFilter, VectorSearchHit, IndexSpec, IndexDescriptor, Workflows, WorkflowHandle, WorkflowRun, WorkflowStepRecord } from './types.js';
|
|
2
2
|
import { RemoteMediaService } from './media.js';
|
|
3
|
+
import { getRequestAuthHeader } from './request-scope.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Remote KV namespace implementation that communicates with a development server.
|
|
@@ -23,7 +24,7 @@ class RemoteKvNamespace implements KvNamespace {
|
|
|
23
24
|
private async fetch(url: string, options: RequestInit = {}) {
|
|
24
25
|
const response = await fetch(url, {
|
|
25
26
|
...options,
|
|
26
|
-
headers: { ...this.headers, ...options.headers },
|
|
27
|
+
headers: { ...this.headers, ...getRequestAuthHeader(), ...options.headers },
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
if (!response.ok && response.status !== 404) {
|
|
@@ -94,7 +95,7 @@ class RemoteDatabase implements Database {
|
|
|
94
95
|
private async fetch(url: string, options: RequestInit = {}) {
|
|
95
96
|
const response = await fetch(url, {
|
|
96
97
|
...options,
|
|
97
|
-
headers: { ...this.headers, ...options.headers },
|
|
98
|
+
headers: { ...this.headers, ...getRequestAuthHeader(), ...options.headers },
|
|
98
99
|
});
|
|
99
100
|
|
|
100
101
|
if (!response.ok && response.status !== 404) {
|
|
@@ -228,7 +229,7 @@ class RemoteStorage implements Storage {
|
|
|
228
229
|
private async fetch(url: string, options: RequestInit = {}) {
|
|
229
230
|
const response = await fetch(url, {
|
|
230
231
|
...options,
|
|
231
|
-
headers: { ...this.headers, ...options.headers },
|
|
232
|
+
headers: { ...this.headers, ...getRequestAuthHeader(), ...options.headers },
|
|
232
233
|
});
|
|
233
234
|
|
|
234
235
|
if (!response.ok && response.status !== 404) {
|
|
@@ -245,16 +246,24 @@ class RemoteStorage implements Storage {
|
|
|
245
246
|
headers: Record<string, string>;
|
|
246
247
|
expiresIn: number;
|
|
247
248
|
}> {
|
|
248
|
-
//
|
|
249
|
-
//
|
|
249
|
+
// Pre-flight Layer-2 gate: dev-server runs the bucket policy with
|
|
250
|
+
// `action: "upload-url"` and returns the upload URL on success
|
|
251
|
+
// (or 403 on denial). Mirrors prod's
|
|
252
|
+
// platform.storage.generateUploadUrl behaviour.
|
|
253
|
+
const res = await this.fetch(`${this.baseUrl}/api/storage/upload-url`, {
|
|
254
|
+
method: 'POST',
|
|
255
|
+
body: JSON.stringify({ key, content_type: contentType, size_limit: options?.sizeLimit }),
|
|
256
|
+
});
|
|
257
|
+
const data = (await res.json()) as { url: string; method: string; headers: Record<string, string>; expiresIn: number };
|
|
258
|
+
// The dev-server returns a relative URL; absolutise it. Prepend
|
|
259
|
+
// tenant headers so direct PUTs by the caller still carry the
|
|
260
|
+
// X-Tenant-Id (Authorization will be re-added at fetch time by
|
|
261
|
+
// getRequestAuthHeader if a request scope is bound).
|
|
250
262
|
return {
|
|
251
|
-
url: `${this.baseUrl}
|
|
252
|
-
method:
|
|
253
|
-
headers: {
|
|
254
|
-
|
|
255
|
-
...this.headers, // Include tenant headers
|
|
256
|
-
},
|
|
257
|
-
expiresIn: 3600, // 1 hour
|
|
263
|
+
url: data.url.startsWith('http') ? data.url : `${this.baseUrl}${data.url}`,
|
|
264
|
+
method: data.method,
|
|
265
|
+
headers: { ...data.headers, ...this.headers },
|
|
266
|
+
expiresIn: data.expiresIn,
|
|
258
267
|
};
|
|
259
268
|
}
|
|
260
269
|
|
|
@@ -264,13 +273,16 @@ class RemoteStorage implements Storage {
|
|
|
264
273
|
headers: Record<string, string>;
|
|
265
274
|
expiresIn: number;
|
|
266
275
|
}> {
|
|
267
|
-
|
|
268
|
-
|
|
276
|
+
const res = await this.fetch(`${this.baseUrl}/api/storage/download-url`, {
|
|
277
|
+
method: 'POST',
|
|
278
|
+
body: JSON.stringify({ key, expires_in: options?.expiresIn }),
|
|
279
|
+
});
|
|
280
|
+
const data = (await res.json()) as { url: string; method: string; headers: Record<string, string>; expiresIn: number };
|
|
269
281
|
return {
|
|
270
|
-
url: `${this.baseUrl}
|
|
271
|
-
method:
|
|
272
|
-
headers:
|
|
273
|
-
expiresIn:
|
|
282
|
+
url: data.url.startsWith('http') ? data.url : `${this.baseUrl}${data.url}`,
|
|
283
|
+
method: data.method,
|
|
284
|
+
headers: data.headers,
|
|
285
|
+
expiresIn: data.expiresIn,
|
|
274
286
|
};
|
|
275
287
|
}
|
|
276
288
|
|
|
@@ -434,7 +446,7 @@ class RemotePresenceService implements PresenceService {
|
|
|
434
446
|
async join(channel: string, userId: string, metadata?: any): Promise<boolean> {
|
|
435
447
|
const res = await fetch(`${this.baseUrl}/api/realtime/presence/${encodeURIComponent(channel)}/join`, {
|
|
436
448
|
method: 'POST',
|
|
437
|
-
headers: this.headers,
|
|
449
|
+
headers: { ...this.headers, ...getRequestAuthHeader() },
|
|
438
450
|
body: JSON.stringify({ userId, metadata }),
|
|
439
451
|
});
|
|
440
452
|
const data = await res.json() as any;
|
|
@@ -444,7 +456,7 @@ class RemotePresenceService implements PresenceService {
|
|
|
444
456
|
async leave(channel: string, userId: string): Promise<boolean> {
|
|
445
457
|
const res = await fetch(`${this.baseUrl}/api/realtime/presence/${encodeURIComponent(channel)}/leave`, {
|
|
446
458
|
method: 'POST',
|
|
447
|
-
headers: this.headers,
|
|
459
|
+
headers: { ...this.headers, ...getRequestAuthHeader() },
|
|
448
460
|
body: JSON.stringify({ userId }),
|
|
449
461
|
});
|
|
450
462
|
const data = await res.json() as any;
|
|
@@ -453,7 +465,7 @@ class RemotePresenceService implements PresenceService {
|
|
|
453
465
|
|
|
454
466
|
async members(channel: string): Promise<Array<{ userId: string; metadata?: any; lastSeen?: number }>> {
|
|
455
467
|
const res = await fetch(`${this.baseUrl}/api/realtime/presence/${encodeURIComponent(channel)}`, {
|
|
456
|
-
headers: this.headers,
|
|
468
|
+
headers: { ...this.headers, ...getRequestAuthHeader() },
|
|
457
469
|
});
|
|
458
470
|
const data = await res.json() as any;
|
|
459
471
|
return data.members ?? [];
|
|
@@ -470,14 +482,14 @@ class RemoteRealtimeService implements RealtimeService {
|
|
|
470
482
|
async publish(channel: string, data: any, options?: { userId?: string }): Promise<void> {
|
|
471
483
|
await fetch(`${this.baseUrl}/api/realtime/publish`, {
|
|
472
484
|
method: 'POST',
|
|
473
|
-
headers: this.headers,
|
|
485
|
+
headers: { ...this.headers, ...getRequestAuthHeader() },
|
|
474
486
|
body: JSON.stringify({ channel, data, userId: options?.userId }),
|
|
475
487
|
});
|
|
476
488
|
}
|
|
477
489
|
|
|
478
490
|
async channels(): Promise<string[]> {
|
|
479
491
|
const res = await fetch(`${this.baseUrl}/api/realtime/channels`, {
|
|
480
|
-
headers: this.headers,
|
|
492
|
+
headers: { ...this.headers, ...getRequestAuthHeader() },
|
|
481
493
|
});
|
|
482
494
|
const data = await res.json() as any;
|
|
483
495
|
return data.channels ?? [];
|
|
@@ -498,7 +510,7 @@ class RemoteAuthService implements AuthService {
|
|
|
498
510
|
private async post(path: string, body?: any): Promise<any> {
|
|
499
511
|
const res = await fetch(`${this.baseUrl}/api/platform/auth${path}`, {
|
|
500
512
|
method: 'POST',
|
|
501
|
-
headers: this.headers,
|
|
513
|
+
headers: { ...this.headers, ...getRequestAuthHeader() },
|
|
502
514
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
503
515
|
});
|
|
504
516
|
if (!res.ok) {
|
|
@@ -577,6 +589,156 @@ class RemoteAuthService implements AuthService {
|
|
|
577
589
|
return this.post('/oauth/callback', { provider, code: params.code, state: params.state });
|
|
578
590
|
}
|
|
579
591
|
|
|
592
|
+
// ── Groups ──
|
|
593
|
+
|
|
594
|
+
async createGroup(options: any): Promise<any> {
|
|
595
|
+
return this.post('/groups/create', options);
|
|
596
|
+
}
|
|
597
|
+
async listGroups(): Promise<any[]> {
|
|
598
|
+
return this.post('/groups/list', {});
|
|
599
|
+
}
|
|
600
|
+
async getGroup(groupId: string): Promise<any> {
|
|
601
|
+
return this.post('/groups/get', { group_id: groupId });
|
|
602
|
+
}
|
|
603
|
+
async updateGroup(groupId: string, options: any): Promise<any> {
|
|
604
|
+
return this.post('/groups/update', { group_id: groupId, ...options });
|
|
605
|
+
}
|
|
606
|
+
async deleteGroup(groupId: string): Promise<void> {
|
|
607
|
+
await this.post('/groups/delete', { group_id: groupId });
|
|
608
|
+
}
|
|
609
|
+
async addUserToGroup(userId: string, groupId: string): Promise<void> {
|
|
610
|
+
await this.post('/groups/add-user', { user_id: userId, group_id: groupId });
|
|
611
|
+
}
|
|
612
|
+
async removeUserFromGroup(userId: string, groupId: string): Promise<void> {
|
|
613
|
+
await this.post('/groups/remove-user', { user_id: userId, group_id: groupId });
|
|
614
|
+
}
|
|
615
|
+
async getUserGroups(userId: string): Promise<any[]> {
|
|
616
|
+
return this.post('/groups/get-user-groups', { user_id: userId });
|
|
617
|
+
}
|
|
618
|
+
async getGroupMembers(groupId: string): Promise<any[]> {
|
|
619
|
+
return this.post('/groups/get-members', { group_id: groupId });
|
|
620
|
+
}
|
|
621
|
+
async getGroupPermissions(groupId: string): Promise<any[]> {
|
|
622
|
+
return this.post('/groups/get-permissions', { group_id: groupId });
|
|
623
|
+
}
|
|
624
|
+
async setGroupPermissions(groupId: string, permissions: any[]): Promise<void> {
|
|
625
|
+
await this.post('/groups/set-permissions', { group_id: groupId, permissions });
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ── Circles ──
|
|
629
|
+
|
|
630
|
+
async createCircle(options: any): Promise<any> {
|
|
631
|
+
return this.post('/circles/create', options);
|
|
632
|
+
}
|
|
633
|
+
async listCircles(): Promise<any[]> {
|
|
634
|
+
return this.post('/circles/list', {});
|
|
635
|
+
}
|
|
636
|
+
async getCircle(circleId: string): Promise<any> {
|
|
637
|
+
return this.post('/circles/get', { circle_id: circleId });
|
|
638
|
+
}
|
|
639
|
+
async updateCircle(circleId: string, options: any): Promise<any> {
|
|
640
|
+
return this.post('/circles/update', { circle_id: circleId, ...options });
|
|
641
|
+
}
|
|
642
|
+
async deleteCircle(circleId: string): Promise<void> {
|
|
643
|
+
await this.post('/circles/delete', { circle_id: circleId });
|
|
644
|
+
}
|
|
645
|
+
async addCircleMember(circleId: string, options: any): Promise<void> {
|
|
646
|
+
await this.post('/circles/add-member', { circle_id: circleId, ...options });
|
|
647
|
+
}
|
|
648
|
+
async removeCircleMember(circleId: string, userId: string): Promise<void> {
|
|
649
|
+
await this.post('/circles/remove-member', { circle_id: circleId, user_id: userId });
|
|
650
|
+
}
|
|
651
|
+
async getCircleMembers(circleId: string): Promise<any[]> {
|
|
652
|
+
return this.post('/circles/get-members', { circle_id: circleId });
|
|
653
|
+
}
|
|
654
|
+
async getUserCircles(userId: string): Promise<any[]> {
|
|
655
|
+
return this.post('/circles/get-user-circles', { user_id: userId });
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── Resources ──
|
|
659
|
+
|
|
660
|
+
async createResource(options: any): Promise<any> {
|
|
661
|
+
return this.post('/resources/create', options);
|
|
662
|
+
}
|
|
663
|
+
async listResources(): Promise<any[]> {
|
|
664
|
+
return this.post('/resources/list', {});
|
|
665
|
+
}
|
|
666
|
+
async updateResource(resourceId: string, options: any): Promise<any> {
|
|
667
|
+
return this.post('/resources/update', { resource_id: resourceId, ...options });
|
|
668
|
+
}
|
|
669
|
+
async deleteResource(resourceId: string): Promise<void> {
|
|
670
|
+
await this.post('/resources/delete', { resource_id: resourceId });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ── Relation types ──
|
|
674
|
+
|
|
675
|
+
async createRelationType(options: any): Promise<any> {
|
|
676
|
+
return this.post('/relation-types/create', options);
|
|
677
|
+
}
|
|
678
|
+
async listRelationTypes(): Promise<any[]> {
|
|
679
|
+
return this.post('/relation-types/list', {});
|
|
680
|
+
}
|
|
681
|
+
async updateRelationType(id: string, options: any): Promise<any> {
|
|
682
|
+
return this.post('/relation-types/update', { id, ...options });
|
|
683
|
+
}
|
|
684
|
+
async deleteRelationType(id: string): Promise<void> {
|
|
685
|
+
await this.post('/relation-types/delete', { id });
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ── Profile ──
|
|
689
|
+
|
|
690
|
+
async getProfile(userId: string): Promise<Record<string, any>> {
|
|
691
|
+
return this.post('/profile/get', { user_id: userId });
|
|
692
|
+
}
|
|
693
|
+
async setProfile(userId: string, data: Record<string, any>): Promise<void> {
|
|
694
|
+
await this.post('/profile/set', { user_id: userId, data });
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ── Auth config ──
|
|
698
|
+
|
|
699
|
+
async getAuthConfig(): Promise<any> {
|
|
700
|
+
return this.post('/config/get', {});
|
|
701
|
+
}
|
|
702
|
+
async setAuthConfig(config: any): Promise<void> {
|
|
703
|
+
await this.post('/config/set', config);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ── Stewardship sub-namespace ──
|
|
707
|
+
|
|
708
|
+
readonly stewardship = {
|
|
709
|
+
resolve: (userId: string) => this.post('/stewardship/resolve', { user_id: userId }),
|
|
710
|
+
createOverride: (options: any) =>
|
|
711
|
+
this.post('/stewardship/create-override', options),
|
|
712
|
+
revoke: async (id: string) => {
|
|
713
|
+
await this.post('/stewardship/revoke', { id });
|
|
714
|
+
},
|
|
715
|
+
checkPermission: async (
|
|
716
|
+
stewardId: string,
|
|
717
|
+
wardId: string,
|
|
718
|
+
resource: string,
|
|
719
|
+
action: string,
|
|
720
|
+
): Promise<boolean> => {
|
|
721
|
+
const r = await this.post('/stewardship/check-permission', {
|
|
722
|
+
steward_id: stewardId,
|
|
723
|
+
ward_id: wardId,
|
|
724
|
+
resource,
|
|
725
|
+
action,
|
|
726
|
+
});
|
|
727
|
+
return Boolean((r as any)?.allowed);
|
|
728
|
+
},
|
|
729
|
+
createActAs: (stewardId: string, wardId: string) =>
|
|
730
|
+
this.post('/stewardship/create-act-as', { steward_id: stewardId, ward_id: wardId }),
|
|
731
|
+
listAudit: (
|
|
732
|
+
userId: string,
|
|
733
|
+
options: { limit?: number; offset?: number } = {},
|
|
734
|
+
) =>
|
|
735
|
+
this.post('/stewardship/list-audit', {
|
|
736
|
+
user_id: userId,
|
|
737
|
+
limit: options.limit ?? 50,
|
|
738
|
+
offset: options.offset ?? 0,
|
|
739
|
+
}),
|
|
740
|
+
};
|
|
741
|
+
|
|
580
742
|
withAuth<T extends (request: Request & { user: AuthUser }) => Promise<Response>>(
|
|
581
743
|
handler: T
|
|
582
744
|
): (request: Request) => Promise<Response> {
|
|
@@ -610,32 +772,95 @@ class RemoteAuthService implements AuthService {
|
|
|
610
772
|
|
|
611
773
|
// ── Request-scoped identity + authorization ──
|
|
612
774
|
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
)
|
|
775
|
+
// In dev mode the SDK uses Node's AsyncLocalStorage (via
|
|
776
|
+
// request-scope.ts) to track per-request state — tenants must wrap
|
|
777
|
+
// their inbound request handler with `runWithRequest(...)`. Without
|
|
778
|
+
// that wrapper, set/get/can fall back to clear errors / anonymous
|
|
779
|
+
// results so the misuse is obvious rather than silent.
|
|
780
|
+
|
|
781
|
+
async setCurrentUser(token: string | null): Promise<void> {
|
|
782
|
+
const { getCurrentRequestStore } = await import('./request-scope.js');
|
|
783
|
+
const store = getCurrentRequestStore();
|
|
784
|
+
if (!store) {
|
|
785
|
+
// No active request scope (e.g. background task / one-shot
|
|
786
|
+
// script / test that didn't wrap). Silent no-op matches the
|
|
787
|
+
// runtime's op_auth_set_current_user behaviour when
|
|
788
|
+
// REQUEST_CTX isn't set
|
|
789
|
+
// (crates/runtime/src/ops/platform/ops_authz.rs:90-114).
|
|
790
|
+
// The Maravilla Vite plugin auto-opens a scope for every dev
|
|
791
|
+
// HTTP request, so SvelteKit/RR hook code reaches this branch
|
|
792
|
+
// only outside the request lifecycle.
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (!token) {
|
|
796
|
+
store.token = undefined;
|
|
797
|
+
store.user = undefined;
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
store.token = token;
|
|
801
|
+
store.user = await this.validate(token);
|
|
622
802
|
}
|
|
623
803
|
|
|
624
804
|
getCurrentUser(): AuthCaller {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
)
|
|
805
|
+
// Synchronous read: ALS module is already loaded by the time
|
|
806
|
+
// setCurrentUser ran, OR we just return anonymous.
|
|
807
|
+
// Use require-style import via a cached reference to keep this sync.
|
|
808
|
+
const mod = (RemoteAuthService as any)._requestScope as
|
|
809
|
+
| typeof import('./request-scope.js')
|
|
810
|
+
| undefined;
|
|
811
|
+
const store = mod?.getCurrentRequestStore?.();
|
|
812
|
+
const user = store?.user;
|
|
813
|
+
if (!user) {
|
|
814
|
+
return mod?.anonymousCaller?.() ?? {
|
|
815
|
+
user_id: '',
|
|
816
|
+
email: '',
|
|
817
|
+
is_admin: false,
|
|
818
|
+
roles: [],
|
|
819
|
+
is_anonymous: true,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
user_id: user.id,
|
|
824
|
+
email: user.email,
|
|
825
|
+
is_admin: false,
|
|
826
|
+
roles: user.groups ?? [],
|
|
827
|
+
is_anonymous: false,
|
|
828
|
+
};
|
|
629
829
|
}
|
|
630
830
|
|
|
631
|
-
can(
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
831
|
+
async can(
|
|
832
|
+
action: string,
|
|
833
|
+
resourceId: string,
|
|
834
|
+
node?: Record<string, unknown> | null,
|
|
835
|
+
): Promise<boolean> {
|
|
836
|
+
const { getCurrentRequestStore } = await import('./request-scope.js');
|
|
837
|
+
const store = getCurrentRequestStore();
|
|
838
|
+
const token = store?.token;
|
|
839
|
+
if (!token) {
|
|
840
|
+
// No bound user → fail-closed, like the runtime evaluator.
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
const r = await this.post('/can', {
|
|
844
|
+
token,
|
|
845
|
+
action,
|
|
846
|
+
resource_id: resourceId,
|
|
847
|
+
node: node ?? null,
|
|
848
|
+
});
|
|
849
|
+
return Boolean((r as any)?.allowed);
|
|
636
850
|
}
|
|
637
851
|
}
|
|
638
852
|
|
|
853
|
+
// Cache the request-scope module on the class so getCurrentUser can read
|
|
854
|
+
// it synchronously (required by the AuthService interface — getCurrentUser
|
|
855
|
+
// is sync). Lazy-load on first setCurrentUser/can call (which is async).
|
|
856
|
+
import('./request-scope.js')
|
|
857
|
+
.then((mod) => {
|
|
858
|
+
(RemoteAuthService as any)._requestScope = mod;
|
|
859
|
+
})
|
|
860
|
+
.catch(() => {
|
|
861
|
+
// Non-Node environment — getCurrentUser will return anonymous.
|
|
862
|
+
});
|
|
863
|
+
|
|
639
864
|
/**
|
|
640
865
|
* Remote stub for the per-request Layer 2 policy toggle. The toggle lives in
|
|
641
866
|
* per-request state inside the runtime and has no remote equivalent.
|
|
@@ -670,7 +895,7 @@ class RemoteWorkflows implements Workflows {
|
|
|
670
895
|
private async request(path: string, options: RequestInit = {}): Promise<Response> {
|
|
671
896
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
672
897
|
...options,
|
|
673
|
-
headers: { ...this.headers, ...options.headers },
|
|
898
|
+
headers: { ...this.headers, ...getRequestAuthHeader(), ...options.headers },
|
|
674
899
|
});
|
|
675
900
|
if (!response.ok && response.status !== 404) {
|
|
676
901
|
const error = await response.text();
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request scope for the SDK's remote client. Mirrors the runtime's
|
|
3
|
+
* `REQUEST_CTX` (a `tokio::task_local!` in Rust) so that
|
|
4
|
+
* `platform.auth.setCurrentUser(token)`, `getCurrentUser()`, and `can()`
|
|
5
|
+
* — plus future per-request state like the Layer-2 policy toggle —
|
|
6
|
+
* have a place to live when the SDK is running outside the runtime
|
|
7
|
+
* (Vite SSR, Node.js HTTP servers, edge workers that expose Node APIs).
|
|
8
|
+
*
|
|
9
|
+
* Implementation: Node's `AsyncLocalStorage`. Tenant code wraps each
|
|
10
|
+
* inbound request with `runWithRequest(async () => { ... })` so the
|
|
11
|
+
* store stays scoped to the request's async chain. `setCurrentUser`
|
|
12
|
+
* / `can` read and write that store; if a tenant forgets to wrap, the
|
|
13
|
+
* methods either no-op (set) or throw a clear error (get/can).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AuthCaller, AuthUser } from './types.js';
|
|
17
|
+
|
|
18
|
+
export interface RequestStore {
|
|
19
|
+
/** Bound access-token JWT (set by `auth.setCurrentUser`). */
|
|
20
|
+
token?: string;
|
|
21
|
+
/** Bound user (cached so `getCurrentUser` doesn't re-validate). */
|
|
22
|
+
user?: AuthUser;
|
|
23
|
+
/** Whether Layer-2 policy enforcement is enabled for this request. */
|
|
24
|
+
policyEnabled: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Lazy-loaded so this module is safe to import in non-Node environments
|
|
28
|
+
// (browsers, edge workers) where `node:async_hooks` doesn't exist. Calls
|
|
29
|
+
// to runWithRequest in those environments fall back to a synchronous
|
|
30
|
+
// no-op scope (the fn just runs `fn()`).
|
|
31
|
+
let alsImpl: { run: (s: RequestStore, fn: () => any) => any; getStore: () => RequestStore | undefined } | null = null;
|
|
32
|
+
|
|
33
|
+
async function ensureAls() {
|
|
34
|
+
if (alsImpl) return alsImpl;
|
|
35
|
+
try {
|
|
36
|
+
// Dynamic import keeps non-Node environments from crashing at module
|
|
37
|
+
// load time. `node:async_hooks` is unconditionally available in Node.
|
|
38
|
+
const { AsyncLocalStorage } = await import('node:async_hooks');
|
|
39
|
+
const inner = new AsyncLocalStorage<RequestStore>();
|
|
40
|
+
alsImpl = {
|
|
41
|
+
run: (s, fn) => inner.run(s, fn),
|
|
42
|
+
getStore: () => inner.getStore(),
|
|
43
|
+
};
|
|
44
|
+
} catch {
|
|
45
|
+
// Non-Node: stub. Note that without ALS, concurrent requests will
|
|
46
|
+
// share the same `cachedStore`, which is wrong — but in non-Node
|
|
47
|
+
// SDK environments the per-request use case typically doesn't
|
|
48
|
+
// apply (browser code is single-threaded per page).
|
|
49
|
+
let cached: RequestStore | undefined;
|
|
50
|
+
alsImpl = {
|
|
51
|
+
run: (s, fn) => {
|
|
52
|
+
const prev = cached;
|
|
53
|
+
cached = s;
|
|
54
|
+
try {
|
|
55
|
+
return fn();
|
|
56
|
+
} finally {
|
|
57
|
+
cached = prev;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
getStore: () => cached,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return alsImpl;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wrap a function in a fresh request scope. **Rarely needed** — the
|
|
68
|
+
* Maravilla Vite plugin (`@maravilla-labs/vite-plugin`) auto-opens a
|
|
69
|
+
* scope around every inbound dev HTTP request, so tenant code
|
|
70
|
+
* (SvelteKit `hooks.server.ts`, RR/Remix loaders, etc.) reaches
|
|
71
|
+
* `setCurrentUser/getCurrentUser/can` with a scope already active.
|
|
72
|
+
*
|
|
73
|
+
* Reach for `runWithRequest` only when the SDK runs **outside** an
|
|
74
|
+
* HTTP request: standalone Node scripts, test fixtures, custom
|
|
75
|
+
* servers that don't go through Vite. Inside an active scope,
|
|
76
|
+
* `platform.auth.setCurrentUser/getCurrentUser/can` operate on a
|
|
77
|
+
* per-request store keyed by Node's `AsyncLocalStorage`.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* // Test fixture that needs to bind a user before exercising the SDK
|
|
82
|
+
* await runWithRequest(async () => {
|
|
83
|
+
* await getPlatform().auth.setCurrentUser(testToken);
|
|
84
|
+
* const result = await getPlatform().env.DB.find('users', {});
|
|
85
|
+
* expect(result).toEqual(...);
|
|
86
|
+
* });
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export async function runWithRequest<T>(fn: () => Promise<T> | T): Promise<T> {
|
|
90
|
+
const als = await ensureAls();
|
|
91
|
+
return als.run({ policyEnabled: true }, () => Promise.resolve(fn())) as Promise<T>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read the current request's store, if any. Returns undefined when
|
|
96
|
+
* called outside a `runWithRequest` scope. Used by `RemoteAuthService`
|
|
97
|
+
* and (future) other Remote* services to source per-request state.
|
|
98
|
+
*
|
|
99
|
+
* Note: the `als` may not yet be initialised when this is called; in
|
|
100
|
+
* that case the function returns undefined synchronously. Subsequent
|
|
101
|
+
* `runWithRequest` calls will populate it.
|
|
102
|
+
*/
|
|
103
|
+
export function getCurrentRequestStore(): RequestStore | undefined {
|
|
104
|
+
return alsImpl?.getStore();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Construct an anonymous `AuthCaller` snapshot — used by
|
|
109
|
+
* `auth.getCurrentUser()` when no identity is bound.
|
|
110
|
+
*/
|
|
111
|
+
export function anonymousCaller(): AuthCaller {
|
|
112
|
+
return {
|
|
113
|
+
user_id: '',
|
|
114
|
+
email: '',
|
|
115
|
+
is_admin: false,
|
|
116
|
+
roles: [],
|
|
117
|
+
is_anonymous: true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Return `Authorization: Bearer <token>` if a token is bound in the
|
|
123
|
+
* current request store, else an empty object. Every Remote* service's
|
|
124
|
+
* fetch helper merges this so dev-server can identify the caller and
|
|
125
|
+
* (eventually) enforce Layer-2 policies on KV/DB/Storage ops.
|
|
126
|
+
*
|
|
127
|
+
* Synchronous so it can be called from inside `headers: { ... }`
|
|
128
|
+
* literals without forcing each fetch helper to be re-shaped.
|
|
129
|
+
*/
|
|
130
|
+
export function getRequestAuthHeader(): Record<string, string> {
|
|
131
|
+
const token = alsImpl?.getStore()?.token;
|
|
132
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
133
|
+
}
|