@maravilla-labs/platform 0.2.1 → 0.2.4

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/events.ts ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * @fileoverview Event handler registration helpers for Maravilla.
3
+ *
4
+ * User apps declare event handlers in `events.ts` or `events/*.ts`:
5
+ *
6
+ * ```ts
7
+ * import { onKvChange, defineEvent } from '@maravilla-labs/platform/events';
8
+ *
9
+ * export const invalidateCache = onKvChange(
10
+ * { namespace: 'config', keyPattern: 'feature:*' },
11
+ * async (event, ctx) => { await ctx.kv.delete('cache', event.key!); },
12
+ * );
13
+ * ```
14
+ *
15
+ * These helpers are pure factories. They produce a `RegisteredHandler`
16
+ * marker object that the build pipeline (`@maravilla-labs/functions`
17
+ * `discoverEvents`) detects by its `__maravilla_trigger` property.
18
+ * The Rust event dispatcher uses the trigger config from the manifest
19
+ * and invokes the bundled handler via `globalThis.handleEvent(id, event)`.
20
+ */
21
+
22
+ import type { RenEvent } from './ren.js';
23
+
24
+ // ============ Trigger descriptors ============
25
+ // Kept structurally identical to adapter-core's `EventTrigger` — duplicated
26
+ // here so the runtime SDK has no build-time dep on adapter-core. Keep in sync.
27
+
28
+ export type EventTrigger =
29
+ | EventTriggerKv
30
+ | EventTriggerDb
31
+ | EventTriggerAuth
32
+ | EventTriggerSchedule
33
+ | EventTriggerQueue
34
+ | EventTriggerChannel
35
+ | EventTriggerDeploy
36
+ | EventTriggerRen;
37
+
38
+ export interface EventTriggerKv {
39
+ kind: 'kv';
40
+ namespace?: string;
41
+ keyPattern?: string;
42
+ op?: 'put' | 'delete' | 'expired';
43
+ }
44
+
45
+ export interface EventTriggerDb {
46
+ kind: 'db';
47
+ collection: string;
48
+ op?: 'insert' | 'update' | 'delete';
49
+ }
50
+
51
+ export type AuthOp =
52
+ | 'registered'
53
+ | 'logged_in'
54
+ | 'logged_out'
55
+ | 'logged_out_all'
56
+ | 'deleted'
57
+ | 'updated';
58
+
59
+ export interface EventTriggerAuth {
60
+ kind: 'auth';
61
+ op?: AuthOp;
62
+ }
63
+
64
+ export interface EventTriggerSchedule {
65
+ kind: 'schedule';
66
+ cron: string;
67
+ }
68
+
69
+ export interface EventTriggerQueue {
70
+ kind: 'queue';
71
+ name: string;
72
+ batch?: number;
73
+ maxAttempts?: number;
74
+ }
75
+
76
+ export interface EventTriggerChannel {
77
+ kind: 'channel';
78
+ channel: string;
79
+ type?: string;
80
+ }
81
+
82
+ export interface EventTriggerDeploy {
83
+ kind: 'deploy';
84
+ phase: 'ready' | 'draining' | 'stopped';
85
+ }
86
+
87
+ export interface EventTriggerRen {
88
+ kind: 'ren';
89
+ match: { r?: string; t?: string; ns?: string };
90
+ }
91
+
92
+ // ============ Event payload shapes ============
93
+
94
+ export interface KvChangeEvent {
95
+ op: 'put' | 'delete' | 'expired';
96
+ namespace: string;
97
+ key: string;
98
+ /** Present on `put`. */
99
+ value?: unknown;
100
+ ts: number;
101
+ }
102
+
103
+ export interface DbChangeEvent {
104
+ op: 'insert' | 'update' | 'delete';
105
+ collection: string;
106
+ id: string;
107
+ doc?: unknown;
108
+ before?: unknown;
109
+ after?: unknown;
110
+ ts: number;
111
+ }
112
+
113
+ export interface AuthEvent {
114
+ op: AuthOp;
115
+ userId: string;
116
+ /**
117
+ * Op-specific payload:
118
+ * - `registered` → `{ email, provider, profile }` where `profile` is the
119
+ * app's custom fields passed to `platform.auth.register({ profile: {...} })`.
120
+ * - `logged_in` → `{ email }`
121
+ * - `logged_out` → `{ sessionId }`
122
+ * - `logged_out_all` / `deleted` / `updated` → null or empty.
123
+ */
124
+ data?: {
125
+ email?: string;
126
+ provider?: string;
127
+ profile?: Record<string, unknown> | null;
128
+ sessionId?: string;
129
+ [k: string]: unknown;
130
+ };
131
+ ts: number;
132
+ }
133
+
134
+ export interface ScheduleEvent {
135
+ cron: string;
136
+ scheduledAt: number;
137
+ firedAt: number;
138
+ }
139
+
140
+ export interface QueueMessage<T = unknown> {
141
+ id: string;
142
+ payload: T;
143
+ attempt: number;
144
+ enqueuedAt: number;
145
+ }
146
+
147
+ export interface ChannelEvent<T = unknown> {
148
+ channel: string;
149
+ type: string;
150
+ data?: T;
151
+ uid?: string;
152
+ ts: number;
153
+ }
154
+
155
+ export interface DeployEvent {
156
+ phase: 'ready' | 'draining' | 'stopped';
157
+ ts: number;
158
+ }
159
+
160
+ // ============ Handler context ============
161
+
162
+ export interface EventCtx {
163
+ /** Per-tenant env vars. */
164
+ env: Record<string, string>;
165
+ /** KV store — same shape as `getPlatform().env.KV` / `platform.kv`. */
166
+ kv?: unknown;
167
+ /** MongoDB-style database — same shape as `getPlatform().env.DB`. */
168
+ database?: unknown;
169
+ /** Object storage. */
170
+ storage?: unknown;
171
+ /** Durable queue producer (`.send(name, payload, opts?)`). */
172
+ queue?: { send: (name: string, payload: unknown, opts?: unknown) => Promise<string> };
173
+ /** Auth service — register/login/logout/user CRUD/etc. */
174
+ auth?: unknown;
175
+ /** Web Push service. */
176
+ push?: unknown;
177
+ /** Full platform object — escape hatch for services not surfaced above. */
178
+ platform?: unknown;
179
+ /** Trace correlation id — propagate through logs. */
180
+ traceId: string;
181
+ tenant: string;
182
+ handlerId: string;
183
+ }
184
+
185
+ // ============ Registered handler marker ============
186
+
187
+ export const TRIGGER_SYMBOL = '__maravilla_trigger' as const;
188
+
189
+ export interface RegisteredHandler<Event = unknown, Ctx = EventCtx> {
190
+ readonly [TRIGGER_SYMBOL]: EventTrigger;
191
+ readonly handler: (event: Event, ctx: Ctx) => unknown | Promise<unknown>;
192
+ }
193
+
194
+ function register<Event, Ctx = EventCtx>(
195
+ trigger: EventTrigger,
196
+ handler: (event: Event, ctx: Ctx) => unknown | Promise<unknown>,
197
+ ): RegisteredHandler<Event, Ctx> {
198
+ return { [TRIGGER_SYMBOL]: trigger, handler };
199
+ }
200
+
201
+ // ============ Public factory helpers ============
202
+
203
+ export function onKvChange(
204
+ config: Omit<EventTriggerKv, 'kind'>,
205
+ handler: (event: KvChangeEvent, ctx: EventCtx) => unknown | Promise<unknown>,
206
+ ): RegisteredHandler<KvChangeEvent> {
207
+ return register({ kind: 'kv', ...config }, handler);
208
+ }
209
+
210
+ export function onDbChange(
211
+ config: Omit<EventTriggerDb, 'kind'>,
212
+ handler: (event: DbChangeEvent, ctx: EventCtx) => unknown | Promise<unknown>,
213
+ ): RegisteredHandler<DbChangeEvent> {
214
+ return register({ kind: 'db', ...config }, handler);
215
+ }
216
+
217
+ /**
218
+ * React to user authentication events: registration, login, logout,
219
+ * deletion, update. Omit `op` to match all auth events; set it to
220
+ * narrow to a specific lifecycle transition.
221
+ *
222
+ * ```ts
223
+ * export const welcomeEmail = onAuth(
224
+ * { op: 'registered' },
225
+ * async (event, ctx) => {
226
+ * await sendWelcomeEmail(event.data?.email);
227
+ * },
228
+ * );
229
+ * ```
230
+ */
231
+ export function onAuth(
232
+ config: Omit<EventTriggerAuth, 'kind'>,
233
+ handler: (event: AuthEvent, ctx: EventCtx) => unknown | Promise<unknown>,
234
+ ): RegisteredHandler<AuthEvent> {
235
+ return register({ kind: 'auth', ...config }, handler);
236
+ }
237
+
238
+ export function onSchedule(
239
+ cron: string,
240
+ handler: (event: ScheduleEvent, ctx: EventCtx) => unknown | Promise<unknown>,
241
+ ): RegisteredHandler<ScheduleEvent> {
242
+ return register({ kind: 'schedule', cron }, handler);
243
+ }
244
+
245
+ export function onQueue<T = unknown>(
246
+ name: string,
247
+ config: Omit<EventTriggerQueue, 'kind' | 'name'>,
248
+ handler: (messages: QueueMessage<T>[], ctx: EventCtx) => unknown | Promise<unknown>,
249
+ ): RegisteredHandler<QueueMessage<T>[]> {
250
+ return register({ kind: 'queue', name, ...config }, handler);
251
+ }
252
+
253
+ export function onChannel<T = unknown>(
254
+ config: Omit<EventTriggerChannel, 'kind'>,
255
+ handler: (event: ChannelEvent<T>, ctx: EventCtx) => unknown | Promise<unknown>,
256
+ ): RegisteredHandler<ChannelEvent<T>> {
257
+ return register({ kind: 'channel', ...config }, handler);
258
+ }
259
+
260
+ export function onDeploy(
261
+ phase: EventTriggerDeploy['phase'],
262
+ handler: (event: DeployEvent, ctx: EventCtx) => unknown | Promise<unknown>,
263
+ ): RegisteredHandler<DeployEvent> {
264
+ return register({ kind: 'deploy', phase }, handler);
265
+ }
266
+
267
+ /** Escape hatch for arbitrary `RenEvent` matches. */
268
+ export function defineEvent(
269
+ config: Omit<EventTriggerRen, 'kind'>,
270
+ handler: (event: RenEvent, ctx: EventCtx) => unknown | Promise<unknown>,
271
+ ): RegisteredHandler<RenEvent> {
272
+ return register({ kind: 'ren', ...config }, handler);
273
+ }
274
+
275
+ /** Type guard used by the build-time discoverer and the runtime registry. */
276
+ export function isRegisteredHandler(value: unknown): value is RegisteredHandler {
277
+ return (
278
+ typeof value === 'object' &&
279
+ value !== null &&
280
+ TRIGGER_SYMBOL in value &&
281
+ typeof (value as Record<string, unknown>).handler === 'function'
282
+ );
283
+ }
package/src/index.ts CHANGED
@@ -51,6 +51,7 @@ export * from './ren.js';
51
51
  export * from './realtime.js';
52
52
  export * from './media.js';
53
53
  export * from './media-room.js';
54
+ export * from './push.js';
54
55
 
55
56
  /**
56
57
  * Global platform instance injected by Maravilla runtime or development tools.
package/src/push.ts ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * @fileoverview Web Push client SDK for Maravilla tenants.
3
+ *
4
+ * Talks to the tenant-origin `/_rt/push` endpoints served by delivery:
5
+ * - GET /vapid-public-key — fetch the VAPID public key for subscribe
6
+ * - POST /subscribe — create a subscription
7
+ * - POST /unsubscribe — delete a subscription by id (or endpoint)
8
+ *
9
+ * The matching service worker is served from `/_rt/push/sw.js` on the
10
+ * same tenant origin (browsers require same-origin SW registration).
11
+ */
12
+
13
+ const DEFAULT_BASE_PATH = '/_rt/push';
14
+ const DEFAULT_SW_PATH = '/_rt/push/sw.js';
15
+ const VISITOR_STORAGE_KEY = 'maravilla.push.visitorId';
16
+ const REGISTER_TIMEOUT_MS = 10_000;
17
+
18
+ export interface RegisterPushOptions {
19
+ /** Free-form topic strings to tag this subscription with. */
20
+ topics?: string[];
21
+ /** Authenticated user id, if any. Takes precedence over visitorId. */
22
+ userId?: string | null;
23
+ /** Anonymous visitor id. If omitted and userId is also omitted, one
24
+ * is generated and persisted to localStorage under `maravilla.push.visitorId`. */
25
+ visitorId?: string | null;
26
+ /** Override the sw.js path (defaults to `/_platform/push/sw.js`). */
27
+ swPath?: string;
28
+ /** Override the API base path (defaults to `/_platform/push`). */
29
+ basePath?: string;
30
+ }
31
+
32
+ export interface RegisterPushResult {
33
+ /** The browser PushSubscription (see Web Push spec). */
34
+ subscription: PushSubscription;
35
+ /** Server-issued subscription id — pass this back to unregisterPush. */
36
+ subscriptionId: string;
37
+ }
38
+
39
+ interface VapidPublicKeyResponse {
40
+ publicKey: string;
41
+ contactEmail?: string;
42
+ }
43
+
44
+ interface ServerSubscription {
45
+ id: string;
46
+ [key: string]: unknown;
47
+ }
48
+
49
+ function assertPushSupported(): void {
50
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
51
+ throw new Error('Web Push is not supported: serviceWorker is unavailable');
52
+ }
53
+ if (typeof window === 'undefined' || !('PushManager' in window)) {
54
+ throw new Error('Web Push is not supported: PushManager is unavailable');
55
+ }
56
+ }
57
+
58
+ function base64UrlToArrayBuffer(input: string): ArrayBuffer {
59
+ const padding = '='.repeat((4 - (input.length % 4)) % 4);
60
+ const base64 = (input + padding).replace(/-/g, '+').replace(/_/g, '/');
61
+ const raw = atob(base64);
62
+ const buffer = new ArrayBuffer(raw.length);
63
+ const view = new Uint8Array(buffer);
64
+ for (let i = 0; i < raw.length; i++) {
65
+ view[i] = raw.charCodeAt(i);
66
+ }
67
+ return buffer;
68
+ }
69
+
70
+ function arrayBufferToBase64Url(buffer: ArrayBuffer | null): string | undefined {
71
+ if (!buffer) return undefined;
72
+ const bytes = new Uint8Array(buffer);
73
+ let binary = '';
74
+ for (let i = 0; i < bytes.length; i++) {
75
+ binary += String.fromCharCode(bytes[i]);
76
+ }
77
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
78
+ }
79
+
80
+ function randomUuid(): string {
81
+ const c = typeof crypto !== 'undefined' ? crypto : undefined;
82
+ if (c && typeof c.randomUUID === 'function') {
83
+ return c.randomUUID();
84
+ }
85
+ const bytes = new Uint8Array(16);
86
+ if (c && typeof c.getRandomValues === 'function') {
87
+ c.getRandomValues(bytes);
88
+ } else {
89
+ for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
90
+ }
91
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
92
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
93
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
94
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
95
+ }
96
+
97
+ function resolveVisitorId(
98
+ userId: string | null | undefined,
99
+ visitorId: string | null | undefined,
100
+ ): string | null {
101
+ if (visitorId) return visitorId;
102
+ if (userId) return null;
103
+ try {
104
+ const stored = window.localStorage.getItem(VISITOR_STORAGE_KEY);
105
+ if (stored) return stored;
106
+ const fresh = randomUuid();
107
+ window.localStorage.setItem(VISITOR_STORAGE_KEY, fresh);
108
+ return fresh;
109
+ } catch {
110
+ return randomUuid();
111
+ }
112
+ }
113
+
114
+ async function fetchVapidPublicKey(basePath: string): Promise<string> {
115
+ const res = await fetch(`${basePath}/vapid-public-key`, {
116
+ method: 'GET',
117
+ credentials: 'same-origin',
118
+ headers: { Accept: 'application/json' },
119
+ });
120
+ if (!res.ok) {
121
+ throw new Error(`Failed to fetch VAPID public key: ${res.status} ${res.statusText}`);
122
+ }
123
+ const body = (await res.json()) as VapidPublicKeyResponse;
124
+ if (!body || typeof body.publicKey !== 'string' || body.publicKey.length === 0) {
125
+ throw new Error('VAPID public key response is missing `publicKey`');
126
+ }
127
+ return body.publicKey;
128
+ }
129
+
130
+ function extractKeys(sub: PushSubscription): { p256dh?: string; auth?: string } {
131
+ return {
132
+ p256dh: arrayBufferToBase64Url(sub.getKey('p256dh')),
133
+ auth: arrayBufferToBase64Url(sub.getKey('auth')),
134
+ };
135
+ }
136
+
137
+ export async function registerPush(
138
+ opts: RegisterPushOptions = {},
139
+ ): Promise<RegisterPushResult> {
140
+ assertPushSupported();
141
+
142
+ const basePath = opts.basePath ?? DEFAULT_BASE_PATH;
143
+ const swPath = opts.swPath ?? DEFAULT_SW_PATH;
144
+ const topics = opts.topics ?? [];
145
+ const userId = opts.userId ?? null;
146
+ const visitorId = resolveVisitorId(userId, opts.visitorId);
147
+
148
+ // Whole-flow timeout. Without this, a stuck pushManager.subscribe() or a
149
+ // misbehaving push service can leave the UI hanging forever.
150
+ const timeout = new Promise<never>((_, reject) =>
151
+ setTimeout(
152
+ () => reject(new Error(`registerPush timed out after ${REGISTER_TIMEOUT_MS}ms`)),
153
+ REGISTER_TIMEOUT_MS,
154
+ ),
155
+ );
156
+
157
+ const flow = (async (): Promise<RegisterPushResult> => {
158
+ const publicKey = await fetchVapidPublicKey(basePath);
159
+ // Register the SW. We deliberately do NOT `await navigator.serviceWorker.ready`
160
+ // here — that promise only resolves when an active SW's scope covers the
161
+ // current page, but our SW is scoped to `/_rt/push/` and the page is at
162
+ // `/`. `pushManager.subscribe()` works against the registration returned
163
+ // by `register()` without needing the SW to control the page.
164
+ const registration = await navigator.serviceWorker.register(swPath);
165
+
166
+ const existing = await registration.pushManager.getSubscription();
167
+ const subscription =
168
+ existing ??
169
+ (await registration.pushManager.subscribe({
170
+ userVisibleOnly: true,
171
+ applicationServerKey: base64UrlToArrayBuffer(publicKey),
172
+ }));
173
+
174
+ const { p256dh, auth } = extractKeys(subscription);
175
+
176
+ const res = await fetch(`${basePath}/subscribe`, {
177
+ method: 'POST',
178
+ credentials: 'same-origin',
179
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
180
+ body: JSON.stringify({
181
+ provider: 'web-push',
182
+ endpoint: subscription.endpoint,
183
+ p256dh,
184
+ auth,
185
+ userId,
186
+ visitorId,
187
+ topics,
188
+ }),
189
+ });
190
+
191
+ if (!res.ok) {
192
+ throw new Error(`Subscribe failed: ${res.status} ${res.statusText}`);
193
+ }
194
+
195
+ const saved = (await res.json()) as ServerSubscription;
196
+ if (!saved || typeof saved.id !== 'string' || saved.id.length === 0) {
197
+ throw new Error('Subscribe response is missing `id`');
198
+ }
199
+
200
+ return { subscription, subscriptionId: saved.id };
201
+ })();
202
+
203
+ return Promise.race([flow, timeout]);
204
+ }
205
+
206
+ export async function unregisterPush(
207
+ subscriptionId: string,
208
+ opts: { basePath?: string } = {},
209
+ ): Promise<void> {
210
+ if (!subscriptionId) {
211
+ throw new Error('subscriptionId is required');
212
+ }
213
+ const basePath = opts.basePath ?? DEFAULT_BASE_PATH;
214
+
215
+ const res = await fetch(`${basePath}/unsubscribe`, {
216
+ method: 'POST',
217
+ credentials: 'same-origin',
218
+ headers: { 'Content-Type': 'application/json' },
219
+ body: JSON.stringify({ subscriptionId }),
220
+ });
221
+
222
+ if (!res.ok && res.status !== 404) {
223
+ throw new Error(`Unsubscribe failed: ${res.status} ${res.statusText}`);
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Compute a `Date` that is `offset` before `anchor` — handy for
229
+ * "X minutes/hours/days before the event" scheduling.
230
+ *
231
+ * `offset` is a short duration string:
232
+ * - `"30s"` — 30 seconds
233
+ * - `"15m"` — 15 minutes
234
+ * - `"1h"` — 1 hour
235
+ * - `"2d"` — 2 days
236
+ * - `"1w"` — 1 week
237
+ *
238
+ * Pure function — safe to call on the client. Works with `platform.push.schedule`:
239
+ *
240
+ * @example
241
+ * ```typescript
242
+ * import { offsetBefore } from '@maravilla-labs/platform';
243
+ *
244
+ * await platform.push.schedule(
245
+ * { topic: `invite:${invite.id}` },
246
+ * { title: invite.title, body: 'Your event is in one hour' },
247
+ * {
248
+ * at: offsetBefore(invite.event_date, '1h'),
249
+ * key: `invite:${invite.id}:reminder-1h`,
250
+ * }
251
+ * );
252
+ * ```
253
+ *
254
+ * @throws if `anchor` is an invalid date string or `offset` isn't a
255
+ * recognised duration.
256
+ */
257
+ export function offsetBefore(anchor: Date | string, offset: string): Date {
258
+ const anchorDate = anchor instanceof Date ? anchor : new Date(anchor);
259
+ if (Number.isNaN(anchorDate.getTime())) {
260
+ throw new Error(`offsetBefore: invalid anchor "${String(anchor)}"`);
261
+ }
262
+
263
+ const match = /^(\d+)\s*(s|m|h|d|w)$/i.exec(offset.trim());
264
+ if (!match) {
265
+ throw new Error(
266
+ `offsetBefore: invalid offset "${offset}" — expected something like "30m", "1h", "2d", "1w"`,
267
+ );
268
+ }
269
+
270
+ const amount = Number(match[1]);
271
+ const unit = match[2].toLowerCase();
272
+ const UNIT_MS: Record<string, number> = {
273
+ s: 1000,
274
+ m: 60_000,
275
+ h: 3_600_000,
276
+ d: 86_400_000,
277
+ w: 604_800_000,
278
+ };
279
+ return new Date(anchorDate.getTime() - amount * UNIT_MS[unit]);
280
+ }
@@ -1,4 +1,4 @@
1
- import type { KvNamespace, KvListResult, Database, DbFindOptions, Storage, RealtimeService, PresenceService, AuthService, AuthUser, AuthSession, AuthField, RegisterOptions, LoginOptions, UserListFilter, UserListResponse, UpdateUserOptions } from './types.js';
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 } from './types.js';
2
2
  import { RemoteMediaService } from './media.js';
3
3
 
4
4
  /**
@@ -152,6 +152,59 @@ class RemoteDatabase implements Database {
152
152
  // This would need to be implemented properly in the dev server
153
153
  return this.deleteOne(collection, filter);
154
154
  }
155
+
156
+ async createVectorIndex(collection: string, spec: VectorIndexSpec): Promise<void> {
157
+ await this.fetch(`${this.baseUrl}/api/db/${collection}/vectorIndexes`, {
158
+ method: 'POST',
159
+ body: JSON.stringify(spec),
160
+ });
161
+ }
162
+
163
+ async dropVectorIndex(collection: string, field: string): Promise<{ removed: boolean }> {
164
+ const response = await this.fetch(
165
+ `${this.baseUrl}/api/db/${collection}/vectorIndexes/${encodeURIComponent(field)}`,
166
+ { method: 'DELETE' },
167
+ );
168
+ return response.json() as Promise<{ removed: boolean }>;
169
+ }
170
+
171
+ async listVectorIndexes(collection: string): Promise<VectorIndexDescriptor[]> {
172
+ const response = await this.fetch(`${this.baseUrl}/api/db/${collection}/vectorIndexes`, {
173
+ method: 'GET',
174
+ });
175
+ return response.json() as Promise<VectorIndexDescriptor[]>;
176
+ }
177
+
178
+ async findSimilar(collection: string, query: VectorQueryWithFilter): Promise<VectorSearchHit[]> {
179
+ const response = await this.fetch(`${this.baseUrl}/api/db/${collection}/vectorSearch`, {
180
+ method: 'POST',
181
+ body: JSON.stringify(query),
182
+ });
183
+ return response.json() as Promise<VectorSearchHit[]>;
184
+ }
185
+
186
+ async createIndex(collection: string, spec: IndexSpec): Promise<IndexDescriptor> {
187
+ const response = await this.fetch(`${this.baseUrl}/api/db/${collection}/indexes`, {
188
+ method: 'POST',
189
+ body: JSON.stringify(spec),
190
+ });
191
+ return response.json() as Promise<IndexDescriptor>;
192
+ }
193
+
194
+ async dropIndex(collection: string, name: string): Promise<{ removed: boolean }> {
195
+ const response = await this.fetch(
196
+ `${this.baseUrl}/api/db/${collection}/indexes/${encodeURIComponent(name)}`,
197
+ { method: 'DELETE' },
198
+ );
199
+ return response.json() as Promise<{ removed: boolean }>;
200
+ }
201
+
202
+ async listIndexes(collection: string): Promise<IndexDescriptor[]> {
203
+ const response = await this.fetch(`${this.baseUrl}/api/db/${collection}/indexes`, {
204
+ method: 'GET',
205
+ });
206
+ return response.json() as Promise<IndexDescriptor[]>;
207
+ }
155
208
  }
156
209
 
157
210
  /**
@@ -528,6 +581,51 @@ class RemoteAuthService implements AuthService {
528
581
  }
529
582
  };
530
583
  }
584
+
585
+ // ── Request-scoped identity + authorization ──
586
+ //
587
+ // These APIs operate on per-request state inside the platform runtime and
588
+ // don't have a remote equivalent. Throw so callers get a clear message
589
+ // instead of silently wrong behavior.
590
+
591
+ setCurrentUser(_token: string | null): Promise<void> {
592
+ return Promise.reject(new Error(
593
+ 'platform.auth.setCurrentUser is only available inside the Maravilla runtime. ' +
594
+ 'Remote clients should pass the Authorization header with each request instead.'
595
+ ));
596
+ }
597
+
598
+ getCurrentUser(): AuthCaller {
599
+ throw new Error(
600
+ 'platform.auth.getCurrentUser is only available inside the Maravilla runtime. ' +
601
+ 'Remote clients have no per-request caller context.'
602
+ );
603
+ }
604
+
605
+ can(_action: string, _resourceId: string, _node?: Record<string, unknown> | null): Promise<boolean> {
606
+ return Promise.reject(new Error(
607
+ 'platform.auth.can is only available inside the Maravilla runtime. ' +
608
+ 'Remote clients cannot evaluate per-request policies because there is no bound caller.'
609
+ ));
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Remote stub for the per-request Layer 2 policy toggle. The toggle lives in
615
+ * per-request state inside the runtime and has no remote equivalent.
616
+ */
617
+ class RemotePolicyService implements PolicyService {
618
+ setEnabled(_enabled: boolean): void {
619
+ throw new Error(
620
+ 'platform.policy.setEnabled is only available inside the Maravilla runtime.'
621
+ );
622
+ }
623
+
624
+ isEnabled(): boolean {
625
+ throw new Error(
626
+ 'platform.policy.isEnabled is only available inside the Maravilla runtime.'
627
+ );
628
+ }
531
629
  }
532
630
 
533
631
  /**
@@ -572,6 +670,7 @@ export function createRemoteClient(baseUrl: string, tenant: string) {
572
670
  const media = new RemoteMediaService(baseUrl, headers);
573
671
  const realtime = new RemoteRealtimeService(baseUrl, headers);
574
672
  const auth = new RemoteAuthService(baseUrl, headers);
673
+ const policy = new RemotePolicyService();
575
674
 
576
675
  return {
577
676
  env: {
@@ -582,5 +681,6 @@ export function createRemoteClient(baseUrl: string, tenant: string) {
582
681
  media,
583
682
  realtime,
584
683
  auth,
684
+ policy,
585
685
  };
586
686
  }