@layers/amba-web 4.0.7 → 4.0.9

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.
@@ -94,10 +94,9 @@ interface AuthResult {
94
94
  /**
95
95
  * Snapshot of the current session as visible to the SDK consumer.
96
96
  *
97
- * `sessionToken` / `refreshToken` / `expiresAt` are SDK-managed and held
98
- * internally for the auto-refresh dance, so they're empty strings on the
99
- * consumer side — present for shape-compatibility. Callers who need to
100
- * introspect should branch on `user` / `Amba.isAuthenticated`.
97
+ * `sessionToken` / `refreshToken` are live credentials from the SDK-managed
98
+ * durable browser session. Do not log them or duplicate them into app-owned
99
+ * storage. Callers that only need auth state should use `Amba.isAuthenticated`.
101
100
  */
102
101
  interface Session {
103
102
  sessionToken: string;
@@ -656,12 +655,17 @@ interface GroupCreate {
656
655
  name: string;
657
656
  description?: string;
658
657
  avatar_url?: string;
658
+ /** Set false for a private group that can own group-scoped collection rows. */
659
+ is_public?: boolean;
660
+ max_members?: number;
659
661
  metadata?: unknown;
660
662
  }
661
663
  interface GroupUpdate {
662
664
  name?: string;
663
665
  description?: string;
664
666
  avatar_url?: string;
667
+ is_public?: boolean;
668
+ max_members?: number;
665
669
  metadata?: unknown;
666
670
  }
667
671
  type ConversationType = "direct" | "group";
@@ -1396,6 +1400,69 @@ interface InvokeOptions {
1396
1400
  */
1397
1401
  onTraceId?: (traceId: string | null) => void;
1398
1402
  }
1403
+ /**
1404
+ * Immutable browser collection scope for a server-verified group context.
1405
+ * Each method carries its own `groupId` into the Rust request path; no mutable
1406
+ * global header state is used, so parallel group A/group B calls cannot leak.
1407
+ *
1408
+ * Group-scoped writes deliberately bypass the global offline outbox. Replaying
1409
+ * a queued group mutation without its membership context would be unsafe;
1410
+ * callers receive the real network outcome and can retry in the same scope.
1411
+ */
1412
+ declare class AmbaGroupScopedClient {
1413
+ readonly groupId: string;
1414
+ constructor(groupId: string);
1415
+ asGroup(groupId: string): AmbaGroupScopedClient;
1416
+ inGroup(groupId: string): AmbaGroupScopedClient;
1417
+ collection<T = unknown>(name: string): CollectionSubscription<T>;
1418
+ collections: {
1419
+ find: <T = unknown>(name: string, options?: FindOptions) => Promise<FindResponse<T>>;
1420
+ findOne: <T = unknown>(name: string, id: string) => Promise<T>;
1421
+ insert: <T = unknown>(name: string, row: Record<string, unknown>) => Promise<T>;
1422
+ update: <T = unknown>(name: string, id: string, set: Record<string, unknown>) => Promise<T>;
1423
+ delete: (name: string, id: string) => Promise<{
1424
+ data: {
1425
+ deleted: boolean;
1426
+ };
1427
+ }>;
1428
+ upsert: <T = unknown>(name: string, row: Record<string, unknown>, options: UpsertOptions) => Promise<T>;
1429
+ deleteWhere: (name: string, filter: Filter) => Promise<DeleteManyResult>;
1430
+ restore: <T = unknown>(name: string, id: string) => Promise<T>;
1431
+ transaction: (operations: TransactionOp[]) => Promise<TransactionOpResult[]>;
1432
+ findNearest: <T = unknown>(name: string, options: NearestOptions) => Promise<NearestResponse<T>>;
1433
+ count: (name: string, filter?: Filter) => Promise<number>;
1434
+ optimisticInsert: <T = unknown, TApply = unknown>(name: string, row: Record<string, unknown>, view: {
1435
+ apply: () => TApply;
1436
+ rollback: (applied: TApply) => void;
1437
+ }) => Promise<T>;
1438
+ optimisticUpdate: <T = unknown, TApply = unknown>(name: string, id: string, set: Record<string, unknown>, view: {
1439
+ apply: () => TApply;
1440
+ rollback: (applied: TApply) => void;
1441
+ }) => Promise<T>;
1442
+ optimisticDelete: <TApply = unknown>(name: string, id: string, view: {
1443
+ apply: () => TApply;
1444
+ rollback: (applied: TApply) => void;
1445
+ }) => Promise<{
1446
+ data: {
1447
+ deleted: boolean;
1448
+ };
1449
+ }>;
1450
+ where: {
1451
+ eq: (column: string, value: FilterValue) => Filter;
1452
+ ne: (column: string, value: FilterValue) => Filter;
1453
+ gt: (column: string, value: FilterValue) => Filter;
1454
+ gte: (column: string, value: FilterValue) => Filter;
1455
+ lt: (column: string, value: FilterValue) => Filter;
1456
+ lte: (column: string, value: FilterValue) => Filter;
1457
+ in: (column: string, values: FilterValue[]) => Filter;
1458
+ isNull: (column: string) => Filter;
1459
+ isNotNull: (column: string) => Filter;
1460
+ and: (...filters: Filter[]) => Filter;
1461
+ or: (...filters: Filter[]) => Filter;
1462
+ not: (filter: Filter) => Filter;
1463
+ };
1464
+ };
1465
+ }
1399
1466
  /**
1400
1467
  * Top-level SDK namespace.
1401
1468
  *
@@ -1623,9 +1690,9 @@ declare const Amba: {
1623
1690
  me: () => Promise<User>;
1624
1691
  /**
1625
1692
  * Snapshot the current session, or `null` if no session is live.
1626
- * sessionToken/refreshToken/expiresAt come back as empty strings —
1627
- * they're SDK-managed and not read-back-able from the core. Use
1628
- * `Amba.isAuthenticated` if you only need the boolean.
1693
+ * Tokens are returned from the browser's durable session snapshot. Treat
1694
+ * them as credentials: do not log them or copy them into app-managed
1695
+ * storage.
1629
1696
  */
1630
1697
  getSession: () => Promise<Session | null>;
1631
1698
  /**
@@ -1680,6 +1747,15 @@ declare const Amba: {
1680
1747
  */
1681
1748
  revoke: (sessionId: string) => Promise<void>;
1682
1749
  };
1750
+ /**
1751
+ * Scope collection calls to a group the current session belongs to.
1752
+ * Membership and role are verified by the API; an unknown/unauthorized
1753
+ * group is rejected server-side. The returned caller is immutable and safe
1754
+ * to use concurrently with callers scoped to other groups.
1755
+ */
1756
+ asGroup: (groupId: string) => AmbaGroupScopedClient;
1757
+ /** Alias of {@link Amba.asGroup}. */
1758
+ inGroup: (groupId: string) => AmbaGroupScopedClient;
1683
1759
  /**
1684
1760
  * Live collection subscriptions (SDK 4.0). Returns a builder — chain
1685
1761
  * `.where(column, value)` to narrow, then `.subscribe(cb)` to start
@@ -2063,6 +2139,10 @@ declare const Amba: {
2063
2139
  * hand-rolling `fetch` against a function URL, which leaves the call
2064
2140
  * unauthenticated (a footgun when the function holds secrets).
2065
2141
  *
2142
+ * The function host is selected in order from `functionsBaseUrl`, a
2143
+ * UUID-shaped configured project id, or a deduped diagnostics ping that
2144
+ * supplies the authoritative server-side project id.
2145
+ *
2066
2146
  * The return value is unchanged (the parsed body, typed `T`). To
2067
2147
  * correlate a run with server logs, read the trace id additively:
2068
2148
  * pass `options.onTraceId` to receive the `X-Amba-Request-Id` on every
@@ -2431,7 +2511,7 @@ declare function getCoreWasm(): AmbaCoreWasm;
2431
2511
  * `verify-sdk-version-coherence` GH action gates every publish on the
2432
2512
  * pair agreeing (and matching the `web-v<X.Y.Z>` tag when tag-triggered).
2433
2513
  */
2434
- declare const SDK_VERSION = "4.0.7";
2514
+ declare const SDK_VERSION = "4.0.9";
2435
2515
  //#endregion
2436
- export { Achievement, AchievementProgress, AiContentBlock, AiImageInput, AiMessage, AiMessageRequest, AiMessageResponse, type AiStreamDelta, type AiStreamDone, type AiStreamEvent, type AiStreamOptions, AiUsage, Amba, AmbaApiError, type AmbaApiErrorCode, AmbaConfig, AndFilter, AppSession, AuthResult, CatalogItem, Challenge, ChallengeProgress, CollectionSubscription, ConditionFilter, ConfigBundle, ConsumeRequest, ContentItem, Conversation, ConversationParticipant, ConversationSubscription, ConversationType, CreateConversationRequest, CurrencyBalance, CurrencyTransaction, DeepLink, DeepLinkCreate, DeleteManyResult, DiagnosticsEnvironment, EntitlementPeriodType, EntitlementStore, EventExplainResult, ExplainMatchedRule, FeedItem, FeedResponse, Filter, FilterValue, FindOptions, FindResponse, FlagAssignment, type FlushResult, Friendship, FriendshipState, type GamificationChange, type GamificationKind, GamificationSubscription, Group, GroupCreate, GroupMember, GroupUpdate, IncludeClause, InventoryItem, InvokeOptions, type KvStore, Leaderboard, LeaderboardEntry, LeagueCohort, LeagueCohortMember, LeagueCohortView, LeagueMembership, LeagueTier, LinkedIdentifier, ListMessagesOptions, LocalNotificationHandle, LocalNotificationTrigger, type LocalNotificationsNamespace, MarkReadResult, MediaAsset, MediaCatalog, MediaCatalogItem, MediaListResponse, Message, type MessageChange, MessageType, NearestOptions, NearestResponse, NotFilter, Offering, OfferingPackage, OfferingProduct, OfferingsResult, OnboardingStatus, OrFilter, OrderBy, type OutboxOp, type OutboxOpKind, PingResult, PresignData, PullChangesCursor, PullChangesResult, PurchaseOption, PurchaseRequest, PurchaseResult, PushChangesResult, PushPlatform, PushToken, type RealtimeChange, ReferralClaim, ReferralCode, Report, ReportRequest, RestoreResult, Review, ReviewCreate, ReviewUpdate, Role, type RowChangeOp, SDK_VERSION, ScheduleLocalNotificationInput, ScheduledLocalNotification, SendMessageRequest, Session, SocialProvider, Store, Streak, type SubscribeOptions, SyncChange, SyncConflict, TransactionOp, TransactionOpResult, type Unsubscribe, UpsertOptions, User, UserEntitlement, XpBalance, XpTransaction, getCoreWasm, withOptimistic };
2516
+ export { Achievement, AchievementProgress, AiContentBlock, AiImageInput, AiMessage, AiMessageRequest, AiMessageResponse, type AiStreamDelta, type AiStreamDone, type AiStreamEvent, type AiStreamOptions, AiUsage, Amba, AmbaApiError, type AmbaApiErrorCode, AmbaConfig, AmbaGroupScopedClient, AndFilter, AppSession, AuthResult, CatalogItem, Challenge, ChallengeProgress, CollectionSubscription, ConditionFilter, ConfigBundle, ConsumeRequest, ContentItem, Conversation, ConversationParticipant, ConversationSubscription, ConversationType, CreateConversationRequest, CurrencyBalance, CurrencyTransaction, DeepLink, DeepLinkCreate, DeleteManyResult, DiagnosticsEnvironment, EntitlementPeriodType, EntitlementStore, EventExplainResult, ExplainMatchedRule, FeedItem, FeedResponse, Filter, FilterValue, FindOptions, FindResponse, FlagAssignment, type FlushResult, Friendship, FriendshipState, type GamificationChange, type GamificationKind, GamificationSubscription, Group, GroupCreate, GroupMember, GroupUpdate, IncludeClause, InventoryItem, InvokeOptions, type KvStore, Leaderboard, LeaderboardEntry, LeagueCohort, LeagueCohortMember, LeagueCohortView, LeagueMembership, LeagueTier, LinkedIdentifier, ListMessagesOptions, LocalNotificationHandle, LocalNotificationTrigger, type LocalNotificationsNamespace, MarkReadResult, MediaAsset, MediaCatalog, MediaCatalogItem, MediaListResponse, Message, type MessageChange, MessageType, NearestOptions, NearestResponse, NotFilter, Offering, OfferingPackage, OfferingProduct, OfferingsResult, OnboardingStatus, OrFilter, OrderBy, type OutboxOp, type OutboxOpKind, PingResult, PresignData, PullChangesCursor, PullChangesResult, PurchaseOption, PurchaseRequest, PurchaseResult, PushChangesResult, PushPlatform, PushToken, type RealtimeChange, ReferralClaim, ReferralCode, Report, ReportRequest, RestoreResult, Review, ReviewCreate, ReviewUpdate, Role, type RowChangeOp, SDK_VERSION, ScheduleLocalNotificationInput, ScheduledLocalNotification, SendMessageRequest, Session, SocialProvider, Store, Streak, type SubscribeOptions, SyncChange, SyncConflict, TransactionOp, TransactionOpResult, type Unsubscribe, UpsertOptions, User, UserEntitlement, XpBalance, XpTransaction, getCoreWasm, withOptimistic };
2437
2517
  //# sourceMappingURL=index.d.ts.map
package/dist-rn/index.js CHANGED
@@ -2,28 +2,46 @@
2
2
  /**
3
3
  * Browser localStorage adapter for SDK identity persistence.
4
4
  *
5
- * The SDK runtime currently re-seeds an `anonymous_id` on every page load.
6
- * This adapter is the documented home for storage-prefix handling so
7
- * customers can override the localStorage key prefix used by the
8
- * (eventual) identity cache.
5
+ * Browsers can deny or throw on localStorage access (privacy mode, sandboxed
6
+ * iframes, quota exhaustion). Persistence is best-effort: storage failures
7
+ * must never turn a successful auth/network operation into an SDK failure.
9
8
  */
10
9
  var LocalStoragePersistence = class {
11
10
  constructor(prefix) {
12
11
  this.prefix = prefix;
13
12
  }
14
- /** Read everything we'd want to hydrate at init. No-op until wired through. */
15
- hydrate() {}
13
+ get storage() {
14
+ try {
15
+ return globalThis.localStorage ?? null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+ get available() {
21
+ return this.storage !== null;
22
+ }
16
23
  read(key) {
17
- if (typeof localStorage === "undefined") return null;
18
- return localStorage.getItem(`${this.prefix}:${key}`);
24
+ const storage = this.storage;
25
+ if (!storage) return null;
26
+ try {
27
+ return storage.getItem(`${this.prefix}:${key}`);
28
+ } catch {
29
+ return null;
30
+ }
19
31
  }
20
32
  write(key, value) {
21
- if (typeof localStorage === "undefined") return;
22
- localStorage.setItem(`${this.prefix}:${key}`, value);
33
+ const storage = this.storage;
34
+ if (!storage) return;
35
+ try {
36
+ storage.setItem(`${this.prefix}:${key}`, value);
37
+ } catch {}
23
38
  }
24
39
  delete(key) {
25
- if (typeof localStorage === "undefined") return;
26
- localStorage.removeItem(`${this.prefix}:${key}`);
40
+ const storage = this.storage;
41
+ if (!storage) return;
42
+ try {
43
+ storage.removeItem(`${this.prefix}:${key}`);
44
+ } catch {}
27
45
  }
28
46
  };
29
47
 
@@ -1340,11 +1358,10 @@ const DEFAULT_API_URL = "https://api.amba.dev";
1340
1358
  /**
1341
1359
  * Default host that serves deployed functions. `Amba.functions.invoke(name)`
1342
1360
  * targets `https://<projectId>.<DEFAULT_FUNCTIONS_HOST>/<name>` unless a
1343
- * `functionsBaseUrl` override is configured. The `<projectId>` label is the
1344
- * server-resolved id (from `diagnostics.ping`), which the function router
1345
- * accepts directly.
1361
+ * `functionsBaseUrl` override is configured.
1346
1362
  */
1347
1363
  const DEFAULT_FUNCTIONS_HOST = "fn.amba.host";
1364
+ const PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1348
1365
  /**
1349
1366
  * Internal state — one instance per page. Created lazily by `Amba.configure()`.
1350
1367
  */
@@ -1366,15 +1383,73 @@ let _configuredProjectId = null;
1366
1383
  let _configuredApiUrl = DEFAULT_API_URL;
1367
1384
  let _configuredClientKey = null;
1368
1385
  let _currentSessionToken = null;
1386
+ let _currentRefreshToken = null;
1387
+ let _currentSessionExpiresAt = null;
1388
+ let _currentUser = null;
1389
+ const BROWSER_IDENTITY_VERSION = 1;
1390
+ const DEFAULT_SESSION_TTL_MS = 3600 * 1e3;
1391
+ function identityStorageKey(projectId) {
1392
+ return `identity:v${BROWSER_IDENTITY_VERSION}:project:${encodeURIComponent(projectId)}`;
1393
+ }
1394
+ function isNonEmptyString(value) {
1395
+ return typeof value === "string" && value.trim().length > 0;
1396
+ }
1397
+ function containsControlCharacter(value) {
1398
+ return Array.from(value).some((char) => {
1399
+ const code = char.charCodeAt(0);
1400
+ return code <= 31 || code === 127;
1401
+ });
1402
+ }
1403
+ /** Read + validate a project-bound identity snapshot. Corrupt or cross-project
1404
+ * data is removed instead of being handed to the core. */
1405
+ function readPersistedIdentity(persistence, projectId) {
1406
+ const key = identityStorageKey(projectId);
1407
+ const raw = persistence.read(key);
1408
+ if (!raw) return null;
1409
+ try {
1410
+ const value = JSON.parse(raw);
1411
+ const hasTokens = value.session_token != null || value.refresh_token != null;
1412
+ if (value.version !== BROWSER_IDENTITY_VERSION || value.project_id !== projectId || !isNonEmptyString(value.anonymous_id) || hasTokens && !isNonEmptyString(value.app_user_id) || value.session_token != null && !isNonEmptyString(value.refresh_token) || value.session_token != null && !isNonEmptyString(value.session_token) || value.refresh_token != null && !isNonEmptyString(value.refresh_token) || value.expires_at != null && (!isNonEmptyString(value.expires_at) || !Number.isFinite(Date.parse(value.expires_at)))) throw new Error("invalid identity snapshot");
1413
+ const expiresAt = isNonEmptyString(value.expires_at) ? Date.parse(value.expires_at) : NaN;
1414
+ const sessionExpired = value.session_token != null && Number.isFinite(expiresAt) && expiresAt <= Date.now();
1415
+ return {
1416
+ version: BROWSER_IDENTITY_VERSION,
1417
+ project_id: projectId,
1418
+ anonymous_id: value.anonymous_id,
1419
+ app_user_id: isNonEmptyString(value.app_user_id) ? value.app_user_id : null,
1420
+ session_token: sessionExpired ? null : value.session_token ?? null,
1421
+ refresh_token: value.refresh_token ?? null,
1422
+ expires_at: sessionExpired ? null : value.expires_at ?? null,
1423
+ user: value.user && typeof value.user === "object" && isNonEmptyString(value.user.id) ? value.user : null
1424
+ };
1425
+ } catch {
1426
+ persistence.delete(key);
1427
+ return null;
1428
+ }
1429
+ }
1430
+ function writeIdentitySnapshot() {
1431
+ if (!_persistence?.available || !_configuredProjectId || !coreInstance) return;
1432
+ const snapshot = {
1433
+ version: BROWSER_IDENTITY_VERSION,
1434
+ project_id: _configuredProjectId,
1435
+ anonymous_id: coreInstance.anonymousId(),
1436
+ app_user_id: coreInstance.appUserId() ?? null,
1437
+ session_token: _currentSessionToken,
1438
+ refresh_token: _currentRefreshToken,
1439
+ expires_at: _currentSessionExpiresAt,
1440
+ user: _currentUser
1441
+ };
1442
+ _persistence.write(identityStorageKey(_configuredProjectId), JSON.stringify(snapshot));
1443
+ }
1369
1444
  /**
1370
1445
  * Functions invocation state. `_configuredFunctionsBaseUrl` is the optional
1371
- * override from `Amba.configure({ functionsBaseUrl })`. When it's null, the
1372
- * default-host path resolves the project id once via `diagnostics.ping` and
1373
- * caches it in `_resolvedFunctionsProjectId` so subsequent invokes skip the
1374
- * round-trip. Both reset on every `configure()`.
1446
+ * override from `Amba.configure({ functionsBaseUrl })`. The default-host path
1447
+ * caches its resolved project id and shares an in-flight diagnostics fallback
1448
+ * across concurrent invokes. All three reset on every `configure()`.
1375
1449
  */
1376
1450
  let _configuredFunctionsBaseUrl = null;
1377
1451
  let _resolvedFunctionsProjectId = null;
1452
+ let _functionsProjectIdInflight = null;
1378
1453
  /**
1379
1454
  * Offline-resilience state. `_persistence` is the localStorage-backed store
1380
1455
  * captured at configure-time; `_offlineQueue` is lazily built the first time
@@ -1442,7 +1517,7 @@ async function replayOutboxOp(op) {
1442
1517
  switch (op.kind) {
1443
1518
  case "collection.insert":
1444
1519
  await coreCall((c) => c.collectionsInsert(op.collection, encodeJson({
1445
- ...op.payload ?? {},
1520
+ ...op.payload,
1446
1521
  idempotency_key: op.idempotencyKey
1447
1522
  })));
1448
1523
  return;
@@ -1563,12 +1638,12 @@ function toWireTransactionOp(op) {
1563
1638
  return wire;
1564
1639
  }
1565
1640
  function snapshotSession(user) {
1566
- if (!user) return null;
1641
+ if (!user || !_currentSessionToken) return null;
1567
1642
  return {
1568
- sessionToken: "",
1569
- refreshToken: "",
1643
+ sessionToken: _currentSessionToken,
1644
+ refreshToken: _currentRefreshToken ?? "",
1570
1645
  user,
1571
- expiresAt: ""
1646
+ expiresAt: _currentSessionExpiresAt ?? ""
1572
1647
  };
1573
1648
  }
1574
1649
  function notifyAuthSubscribers(session) {
@@ -1579,7 +1654,11 @@ function notifyAuthSubscribers(session) {
1579
1654
  }
1580
1655
  }
1581
1656
  async function notifyFromAuthResult(result) {
1582
- if (result?.session_token) _currentSessionToken = result.session_token;
1657
+ _currentSessionToken = result.session_token;
1658
+ _currentRefreshToken = result.refresh_token;
1659
+ _currentSessionExpiresAt = result.expires_at && Number.isFinite(Date.parse(result.expires_at)) ? result.expires_at : new Date(Date.now() + DEFAULT_SESSION_TTL_MS).toISOString();
1660
+ _currentUser = result.user;
1661
+ writeIdentitySnapshot();
1583
1662
  notifyAuthSubscribers(snapshotSession(result.user));
1584
1663
  }
1585
1664
  /**
@@ -1626,22 +1705,156 @@ function buildAiStreamRequest(path, request) {
1626
1705
  /**
1627
1706
  * Resolve the full invocation URL for a deployed function.
1628
1707
  *
1629
- * With `functionsBaseUrl` configured we POST to `<base>/<name>` verbatim
1630
- * (custom domains / non-prod). Otherwise we resolve the server-side project
1631
- * id once (via `diagnostics.ping`, cached) and target
1632
- * `https://<projectId>.fn.amba.host/<name>` — the function router accepts the
1633
- * project id as the host label directly.
1708
+ * Resolution checks `functionsBaseUrl` first, then uses a UUID-shaped
1709
+ * configured project id directly, and finally falls back to a deduped
1710
+ * `diagnostics.ping` for the authoritative server-side project id. The
1711
+ * resolved default-host id is cached for subsequent invokes.
1634
1712
  */
1635
1713
  async function resolveFunctionUrl(name) {
1636
1714
  if (_configuredFunctionsBaseUrl) return `${_configuredFunctionsBaseUrl.replace(/\/$/, "")}/${encodeURIComponent(name)}`;
1637
- if (!_resolvedFunctionsProjectId) {
1638
- const ping = await Amba.diagnostics.ping();
1639
- if (!ping.ok || !ping.server_project_id) throw new AmbaApiError("FUNCTIONS_HOST_UNRESOLVED", "Could not resolve the functions host for this project. Pass `functionsBaseUrl` to Amba.configure() to invoke functions explicitly.", ping);
1640
- _resolvedFunctionsProjectId = ping.server_project_id;
1715
+ return `https://${await resolveFunctionsProjectId()}.${DEFAULT_FUNCTIONS_HOST}/${encodeURIComponent(name)}`;
1716
+ }
1717
+ async function resolveFunctionsProjectId() {
1718
+ if (_resolvedFunctionsProjectId) return _resolvedFunctionsProjectId;
1719
+ if (_configuredProjectId && PROJECT_ID_PATTERN.test(_configuredProjectId)) {
1720
+ _resolvedFunctionsProjectId = _configuredProjectId.toLowerCase();
1721
+ return _resolvedFunctionsProjectId;
1641
1722
  }
1642
- return `https://${_resolvedFunctionsProjectId}.${DEFAULT_FUNCTIONS_HOST}/${encodeURIComponent(name)}`;
1723
+ if (!_functionsProjectIdInflight) {
1724
+ const configuredProjectId = _configuredProjectId;
1725
+ const resolution = (async () => {
1726
+ const ping = await Amba.diagnostics.ping();
1727
+ if (!ping.ok || !ping.server_project_id) throw new AmbaApiError("FUNCTIONS_HOST_UNRESOLVED", "Could not resolve the functions host for this project. Pass `functionsBaseUrl` to Amba.configure() to invoke functions explicitly.", ping);
1728
+ if (_configuredProjectId === configuredProjectId && !_configuredFunctionsBaseUrl) _resolvedFunctionsProjectId = ping.server_project_id;
1729
+ return ping.server_project_id;
1730
+ })();
1731
+ _functionsProjectIdInflight = resolution;
1732
+ resolution.then(() => {
1733
+ if (_functionsProjectIdInflight === resolution) _functionsProjectIdInflight = null;
1734
+ }, () => {
1735
+ if (_functionsProjectIdInflight === resolution) _functionsProjectIdInflight = null;
1736
+ });
1737
+ }
1738
+ return _functionsProjectIdInflight;
1643
1739
  }
1644
1740
  /**
1741
+ * Immutable browser collection scope for a server-verified group context.
1742
+ * Each method carries its own `groupId` into the Rust request path; no mutable
1743
+ * global header state is used, so parallel group A/group B calls cannot leak.
1744
+ *
1745
+ * Group-scoped writes deliberately bypass the global offline outbox. Replaying
1746
+ * a queued group mutation without its membership context would be unsafe;
1747
+ * callers receive the real network outcome and can retry in the same scope.
1748
+ */
1749
+ var AmbaGroupScopedClient = class AmbaGroupScopedClient {
1750
+ groupId;
1751
+ constructor(groupId) {
1752
+ const normalized = groupId.trim();
1753
+ if (!normalized || containsControlCharacter(normalized)) throw new AmbaApiError("VALIDATION_ERROR", "Amba.asGroup: groupId must be non-empty and contain no control characters");
1754
+ this.groupId = normalized;
1755
+ }
1756
+ asGroup(groupId) {
1757
+ return new AmbaGroupScopedClient(groupId);
1758
+ }
1759
+ inGroup(groupId) {
1760
+ return this.asGroup(groupId);
1761
+ }
1762
+ collection(name) {
1763
+ const groupId = this.groupId;
1764
+ return new CollectionSubscription({
1765
+ baseUrl: _configuredApiUrl,
1766
+ authHeaders: () => {
1767
+ const headers = realtimeAuthHeaders();
1768
+ return headers ? {
1769
+ ...headers,
1770
+ "X-Group-Context": groupId
1771
+ } : null;
1772
+ }
1773
+ }, name);
1774
+ }
1775
+ collections = {
1776
+ find: async (name, options = {}) => await coreCall((c) => c.collectionsFind(name, encodeJson(options), this.groupId)),
1777
+ findOne: async (name, id) => await coreCall((c) => c.collectionsFindOne(name, id, this.groupId)),
1778
+ insert: async (name, row) => await coreCall((c) => c.collectionsInsert(name, encodeJson(row), this.groupId)),
1779
+ update: async (name, id, set) => await coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set), this.groupId)),
1780
+ delete: async (name, id) => await coreCall((c) => c.collectionsDelete(name, id, this.groupId)),
1781
+ upsert: async (name, row, options) => await coreCall((c) => c.collectionsUpsert(name, encodeJson(row), encodeJson({
1782
+ on_conflict: options.onConflict,
1783
+ conflict_target: options.conflictTarget
1784
+ }), this.groupId)),
1785
+ deleteWhere: async (name, filter) => await coreCall((c) => c.collectionsDeleteWhere(name, encodeJson(filter), this.groupId)),
1786
+ restore: async (name, id) => await coreCall((c) => c.collectionsRestore(name, id, this.groupId)),
1787
+ transaction: async (operations) => {
1788
+ return (await coreCall((c) => c.collectionsTransaction(encodeJson(operations.map(toWireTransactionOp)), this.groupId))).data;
1789
+ },
1790
+ findNearest: async (name, options) => await coreCall((c) => c.collectionsFindNearest(name, encodeJson(options), this.groupId)),
1791
+ count: async (name, filter) => await coreCall((c) => c.collectionsCount(name, filter ? encodeJson(filter) : void 0, this.groupId)),
1792
+ optimisticInsert: async (name, row, view) => withOptimistic({
1793
+ apply: view.apply,
1794
+ mutate: () => coreCall((c) => c.collectionsInsert(name, encodeJson(row), this.groupId)),
1795
+ rollback: view.rollback
1796
+ }),
1797
+ optimisticUpdate: async (name, id, set, view) => withOptimistic({
1798
+ apply: view.apply,
1799
+ mutate: () => coreCall((c) => c.collectionsUpdate(name, id, encodeJson(set), this.groupId)),
1800
+ rollback: view.rollback
1801
+ }),
1802
+ optimisticDelete: async (name, id, view) => withOptimistic({
1803
+ apply: view.apply,
1804
+ mutate: () => coreCall((c) => c.collectionsDelete(name, id, this.groupId)),
1805
+ rollback: view.rollback
1806
+ }),
1807
+ where: {
1808
+ eq: (column, value) => ({
1809
+ column,
1810
+ op: "eq",
1811
+ value
1812
+ }),
1813
+ ne: (column, value) => ({
1814
+ column,
1815
+ op: "ne",
1816
+ value
1817
+ }),
1818
+ gt: (column, value) => ({
1819
+ column,
1820
+ op: "gt",
1821
+ value
1822
+ }),
1823
+ gte: (column, value) => ({
1824
+ column,
1825
+ op: "gte",
1826
+ value
1827
+ }),
1828
+ lt: (column, value) => ({
1829
+ column,
1830
+ op: "lt",
1831
+ value
1832
+ }),
1833
+ lte: (column, value) => ({
1834
+ column,
1835
+ op: "lte",
1836
+ value
1837
+ }),
1838
+ in: (column, values) => ({
1839
+ column,
1840
+ op: "in",
1841
+ value: values
1842
+ }),
1843
+ isNull: (column) => ({
1844
+ column,
1845
+ op: "is_null"
1846
+ }),
1847
+ isNotNull: (column) => ({
1848
+ column,
1849
+ op: "is_not_null"
1850
+ }),
1851
+ and: (...filters) => ({ and: filters }),
1852
+ or: (...filters) => ({ or: filters }),
1853
+ not: (filter) => ({ not: filter })
1854
+ }
1855
+ };
1856
+ };
1857
+ /**
1645
1858
  * Top-level SDK namespace.
1646
1859
  *
1647
1860
  * Stateless from the JS side — all state lives in the SDK runtime. Calling
@@ -1659,7 +1872,7 @@ const Amba = {
1659
1872
  else await initCore(); // Hermes-stripped Node-init block (DX-15) — see scripts/build-rn.mjs
1660
1873
  }
1661
1874
  const persistence = new LocalStoragePersistence(config.storagePrefix ?? "amba");
1662
- persistence.hydrate();
1875
+ const restoredIdentity = readPersistedIdentity(persistence, config.projectId);
1663
1876
  let nextCore;
1664
1877
  try {
1665
1878
  const { wasmClass } = await getCoreRuntime();
@@ -1672,6 +1885,13 @@ const Amba = {
1672
1885
  consent_required: config.consentRequired,
1673
1886
  debug: config.debug
1674
1887
  }), globalThis.fetch.bind(globalThis));
1888
+ if (restoredIdentity) nextCore.hydrateIdentity(encodeJson({
1889
+ anonymous_id: restoredIdentity.anonymous_id,
1890
+ app_user_id: restoredIdentity.app_user_id,
1891
+ session_token: restoredIdentity.session_token,
1892
+ refresh_token: restoredIdentity.refresh_token,
1893
+ expires_at: restoredIdentity.expires_at
1894
+ }));
1675
1895
  } catch (err) {
1676
1896
  throw toAmbaApiError(err);
1677
1897
  }
@@ -1681,8 +1901,15 @@ const Amba = {
1681
1901
  _configuredProjectId = config.projectId;
1682
1902
  _configuredApiUrl = config.apiUrl ?? DEFAULT_API_URL;
1683
1903
  _configuredClientKey = config.clientKey;
1904
+ _currentSessionToken = nextCore.isAuthenticated() ? restoredIdentity?.session_token ?? null : null;
1905
+ _currentRefreshToken = restoredIdentity?.refresh_token ?? null;
1906
+ _currentSessionExpiresAt = nextCore.isAuthenticated() ? restoredIdentity?.expires_at ?? null : null;
1907
+ _currentUser = nextCore.isAuthenticated() ? restoredIdentity?.user ?? null : null;
1684
1908
  _configuredFunctionsBaseUrl = config.functionsBaseUrl ?? null;
1685
1909
  _resolvedFunctionsProjectId = null;
1910
+ _functionsProjectIdInflight = null;
1911
+ if (!_configuredFunctionsBaseUrl && !PROJECT_ID_PATTERN.test(config.projectId)) resolveFunctionsProjectId().catch(() => {});
1912
+ writeIdentitySnapshot();
1686
1913
  },
1687
1914
  get projectId() {
1688
1915
  if (!_configuredProjectId) throw new AmbaApiError("NOT_INITIALIZED", "amba SDK not configured. Call Amba.configure({ projectId, clientKey }) at app start before any other API.");
@@ -1835,6 +2062,10 @@ const Amba = {
1835
2062
  signOut: async (rotateAnonymousId = false) => {
1836
2063
  await coreCall((c) => c.signOut(rotateAnonymousId));
1837
2064
  _currentSessionToken = null;
2065
+ _currentRefreshToken = null;
2066
+ _currentSessionExpiresAt = null;
2067
+ _currentUser = null;
2068
+ writeIdentitySnapshot();
1838
2069
  notifyAuthSubscribers(null);
1839
2070
  },
1840
2071
  refresh: async () => {
@@ -1843,11 +2074,17 @@ const Amba = {
1843
2074
  return r;
1844
2075
  },
1845
2076
  me: async () => {
1846
- return await coreCall((c) => c.me());
2077
+ const user = await coreCall((c) => c.me());
2078
+ _currentUser = user;
2079
+ writeIdentitySnapshot();
2080
+ return user;
1847
2081
  },
1848
2082
  getSession: async () => {
1849
2083
  if (!requireCore().isAuthenticated()) return null;
1850
- return snapshotSession(await coreCall((c) => c.me()));
2084
+ const user = await coreCall((c) => c.me());
2085
+ _currentUser = user;
2086
+ writeIdentitySnapshot();
2087
+ return snapshotSession(user);
1851
2088
  },
1852
2089
  getAnonymousId: async () => {
1853
2090
  return requireCore().anonymousId();
@@ -1878,6 +2115,14 @@ const Amba = {
1878
2115
  await coreCall((c) => c.sessionsRevoke(sessionId));
1879
2116
  }
1880
2117
  },
2118
+ asGroup: (groupId) => {
2119
+ requireCore();
2120
+ return new AmbaGroupScopedClient(groupId);
2121
+ },
2122
+ inGroup: (groupId) => {
2123
+ requireCore();
2124
+ return new AmbaGroupScopedClient(groupId);
2125
+ },
1881
2126
  collection: (name) => new CollectionSubscription({
1882
2127
  baseUrl: _configuredApiUrl,
1883
2128
  authHeaders: realtimeAuthHeaders
@@ -2216,7 +2461,7 @@ const Amba = {
2216
2461
  const method = options?.method ?? "POST";
2217
2462
  const headers = {
2218
2463
  "X-Api-Key": _configuredClientKey,
2219
- ...options?.headers ?? {}
2464
+ ...options?.headers
2220
2465
  };
2221
2466
  if (_currentSessionToken) headers["Authorization"] = `Bearer ${_currentSessionToken}`;
2222
2467
  let requestBody;
@@ -2427,8 +2672,8 @@ function getCoreWasm() {
2427
2672
  * `verify-sdk-version-coherence` GH action gates every publish on the
2428
2673
  * pair agreeing (and matching the `web-v<X.Y.Z>` tag when tag-triggered).
2429
2674
  */
2430
- const SDK_VERSION = "4.0.7";
2675
+ const SDK_VERSION = "4.0.9";
2431
2676
 
2432
2677
  //#endregion
2433
- export { Amba, AmbaApiError, CollectionSubscription, ConversationSubscription, GamificationSubscription, SDK_VERSION, getCoreWasm, withOptimistic };
2678
+ export { Amba, AmbaApiError, AmbaGroupScopedClient, CollectionSubscription, ConversationSubscription, GamificationSubscription, SDK_VERSION, getCoreWasm, withOptimistic };
2434
2679
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@layers/amba-web",
3
- "version": "4.0.7",
3
+ "version": "4.0.9",
4
4
  "description": "amba SDK for browser apps — full SDK 4.0 surface (34 modules: auth + magic link, users, sessions, sync, collections + vector search, storage + list/delete/download, push, entitlements, flags, config, deep links, onboarding, content, messaging, friends, groups, feeds, reviews, moderation, roles, referrals, achievements, xp, streaks, challenges, leaderboards, leagues, currencies, inventory, stores, catalog, ai, diagnostics).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "sideEffects": false,
34
34
  "dependencies": {
35
- "@layers/amba-core": "^4.0.7"
35
+ "@layers/amba-core": "^4.0.9"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^22.13.14",