@maravilla-labs/platform 0.3.5 → 0.3.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maravilla-labs/platform",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Universal platform client for Maravilla runtime",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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) {
@@ -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
- // For development, return a direct upload URL to the dev server
249
- // The dev server supports PUT directly to /api/storage/{key}
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}/api/storage/${encodeURIComponent(key)}`,
252
- method: 'PUT',
253
- headers: {
254
- 'Content-Type': contentType,
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
- // For development, return a direct download URL to the dev server
268
- // In production, this would call the actual storage service for presigned URLs
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}/api/storage/${encodeURIComponent(key)}`,
271
- method: 'GET',
272
- headers: {},
273
- expiresIn: options?.expiresIn || 3600, // Default 1 hour
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
- // These APIs operate on per-request state inside the platform runtime and
614
- // don't have a remote equivalent. Throw so callers get a clear message
615
- // instead of silently wrong behavior.
616
-
617
- setCurrentUser(_token: string | null): Promise<void> {
618
- return Promise.reject(new Error(
619
- 'platform.auth.setCurrentUser is only available inside the Maravilla runtime. ' +
620
- 'Remote clients should pass the Authorization header with each request instead.'
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
- throw new Error(
626
- 'platform.auth.getCurrentUser is only available inside the Maravilla runtime. ' +
627
- 'Remote clients have no per-request caller context.'
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(_action: string, _resourceId: string, _node?: Record<string, unknown> | null): Promise<boolean> {
632
- return Promise.reject(new Error(
633
- 'platform.auth.can is only available inside the Maravilla runtime. ' +
634
- 'Remote clients cannot evaluate per-request policies because there is no bound caller.'
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
+ }