mnfst 0.5.254 → 0.5.256

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.
@@ -6,6 +6,284 @@
6
6
  /* Requires Alpine JS (alpinejs.dev) to operate
7
7
  */
8
8
 
9
+ // mnfst:realtime.hub:start
10
+ /* Shared Appwrite Realtime hub — ONE Realtime instance (one socket) per tab
11
+ across the data, auth and presences plugins. The first carrier to load
12
+ defines it; the build syncs this block byte-identical into each carrier
13
+ from scripts/snippets/manifest.realtime.hub.js.
14
+
15
+ Subscriptions are keyed by a caller-chosen name and ref-counted by
16
+ (channel + canonical server-filter key); server-side realtime queries ride
17
+ per subscription where the instance supports them (verified SDK/server
18
+ contract: see the manifest.data.realtime.js header). Messages are
19
+ classified ONCE — Appwrite sends ~20 event-name variants per change —
20
+ so each handler gets one (action, payload, response) call per message. */
21
+ if (!window.ManifestRealtimeHub) {
22
+ window.ManifestRealtimeHub = (function () {
23
+ // Identity events drop the instance memo (the socket re-authenticates
24
+ // as the new session) and re-bind live subscriptions
25
+ const AUTH_EVENTS = ['manifest:auth:login', 'manifest:auth:logout', 'manifest:auth:anonymous', 'manifest:auth:session-cleared', 'manifest:auth:initialized'];
26
+
27
+ // One Realtime (one socket) per tab, keyed to the client it wraps
28
+ let realtimeCache = null;
29
+ let realtimeClient = null;
30
+ function acquire(client) {
31
+ if (!client || !window.Appwrite?.Realtime) return null;
32
+ if (!realtimeCache || realtimeClient !== client) {
33
+ realtimeCache = new window.Appwrite.Realtime(client);
34
+ realtimeClient = client;
35
+ }
36
+ return realtimeCache;
37
+ }
38
+
39
+ // Map<subKey, { channel, queries, refs, unsubscribe, handlers: Map<name, handler> }>
40
+ const entries = new Map();
41
+ // Map<name, { subKey, channel, token, handler, getFilters, getRealtime }>
42
+ const sources = new Map();
43
+ // Map<name, fn(action, payload, response)> — pre-filters, run once per message per name
44
+ const filters = new Map();
45
+ // Map<name, n> — explicit teardowns; aborts a subscribe in flight
46
+ const tombstones = new Map();
47
+ // Instance the current subscriptions live on
48
+ let activeRealtime = null;
49
+
50
+ // Reduce a message's event-name variants to the single most specific
51
+ // action: delete > update (upsert counts: presences) > create
52
+ function classify(events) {
53
+ const list = Array.isArray(events) ? events : (events ? [events] : []);
54
+ let action = null;
55
+ for (const event of list) {
56
+ if (typeof event !== 'string') continue;
57
+ // Verbs match as path segments only — event names embed
58
+ // user-chosen ids (a table named deleted_posts is not a delete)
59
+ const parts = event.split('.');
60
+ if (parts.includes('delete')) return 'delete';
61
+ if (parts.includes('update') || parts.includes('upsert')) action = 'update';
62
+ else if (!action && parts.includes('create')) action = 'create';
63
+ }
64
+ return action;
65
+ }
66
+
67
+ // Query-capable Realtime service: detect per instance, never assume
68
+ function supportsQueries(realtime) {
69
+ return !!(realtime && (realtime.pendingSubscribes || realtime.slotToSubscriptionId));
70
+ }
71
+
72
+ // (channel, resolved filter set) → stable subscription key; resolved.key
73
+ // is already canonical so equivalent filter sets share one subscription
74
+ function subKeyFor(channel, resolved) {
75
+ if (!resolved || !resolved.key) return channel;
76
+ return `${channel}\u001f${resolved.key}`;
77
+ }
78
+
79
+ // Fan one SDK message out, classified once: each registered handler gets
80
+ // ONE (action, payload, response) call per message, after that name's
81
+ // pre-filter (setFilter) — before any merge/dedup work
82
+ function dispatch(subKey, response) {
83
+ const entry = entries.get(subKey);
84
+ if (!entry || !response || !response.events) return;
85
+ const action = classify(response.events);
86
+ if (!action) return;
87
+ let payload = response.payload;
88
+ if (!payload || (!payload.$id && !payload.row)) payload = response;
89
+ for (const [name, handler] of entry.handlers) {
90
+ const filter = filters.get(name);
91
+ if (filter && filter(action, payload, response) === false) continue;
92
+ handler(action, payload, response);
93
+ }
94
+ }
95
+
96
+ // subscribe() returns an unsubscribe function (legacy client) or a
97
+ // Promise<RealtimeSubscription> (Realtime service): normalize teardown
98
+ function dropSub(sub) {
99
+ if (typeof sub === 'function') sub();
100
+ else if (sub && typeof sub.unsubscribe === 'function') sub.unsubscribe(); // per-subscription; never risks the shared socket
101
+ else if (sub && typeof sub.close === 'function') sub.close();
102
+ }
103
+
104
+ function makeUnsubscriber(result) {
105
+ if (!result || typeof result.then !== 'function') {
106
+ return () => { try { dropSub(result); } catch (e) { /* socket already gone */ } };
107
+ }
108
+ const settled = result.catch(() => null);
109
+ return () => { settled.then(dropSub).catch(() => { /* socket already gone */ }); };
110
+ }
111
+
112
+ function sdkSubscribe(realtime, subKey, channel, queries) {
113
+ const cb = response => dispatch(subKey, response);
114
+ const result = (queries && queries.length)
115
+ ? realtime.subscribe(channel, cb, queries)
116
+ : realtime.subscribe(channel, cb);
117
+ return makeUnsubscriber(result);
118
+ }
119
+
120
+ // Get or create the shared entry for (channel, filters); registered in the
121
+ // map before the SDK call so an event arriving mid-subscribe already dispatches
122
+ function ensureEntry(realtime, subKey, channel, queries) {
123
+ let entry = entries.get(subKey);
124
+ if (entry) return entry;
125
+ entry = { channel, queries: queries || [], refs: 0, unsubscribe: null, handlers: new Map() };
126
+ entries.set(subKey, entry);
127
+ try {
128
+ entry.unsubscribe = sdkSubscribe(realtime, subKey, channel, queries);
129
+ } catch (error) {
130
+ entries.delete(subKey);
131
+ throw error;
132
+ }
133
+ return entry;
134
+ }
135
+
136
+ function removeFromEntry(name, subKey) {
137
+ const entry = entries.get(subKey);
138
+ if (!entry) return;
139
+ entry.handlers.delete(name);
140
+ entry.refs = entry.handlers.size;
141
+ if (entry.refs === 0) {
142
+ entries.delete(subKey);
143
+ if (typeof entry.unsubscribe === 'function') {
144
+ try { entry.unsubscribe(); } catch (e) { /* socket already gone */ }
145
+ }
146
+ }
147
+ }
148
+
149
+ // Drop a name's registration; tear the subscription down when the last one leaves
150
+ function detach(name, token) {
151
+ const cur = sources.get(name);
152
+ if (!cur || (token && cur.token !== token)) return;
153
+ sources.delete(name);
154
+ removeFromEntry(name, cur.subKey);
155
+ }
156
+
157
+ // Instance changed (rebuilt on identity change): re-resolve each name's
158
+ // filters ($auth. values are stale) and regroup subscriptions on the new
159
+ // instance. Chained so overlapping rebuilds run in order; superseded stops.
160
+ let syncChain = Promise.resolve();
161
+ function syncInstance(realtime) {
162
+ if (activeRealtime === realtime) return syncChain;
163
+ activeRealtime = realtime;
164
+ syncChain = syncChain
165
+ .then(() => rebindAll(realtime))
166
+ .catch(error => console.error('[Manifest Realtime] Rebind failed:', error));
167
+ return syncChain;
168
+ }
169
+
170
+ async function rebindAll(realtime) {
171
+ for (const [, entry] of entries) {
172
+ if (typeof entry.unsubscribe === 'function') {
173
+ try { entry.unsubscribe(); } catch (e) { /* old instance */ }
174
+ }
175
+ }
176
+ entries.clear();
177
+ const canQuery = supportsQueries(realtime);
178
+ for (const [name, src] of sources) {
179
+ if (activeRealtime !== realtime) return; // superseded by a newer instance
180
+ try {
181
+ // Inside the try: one source's failing getFilters must not
182
+ // strand the sources after it unsubscribed
183
+ const resolved = (canQuery && src.getFilters) ? await src.getFilters() : null;
184
+ if (activeRealtime !== realtime) return;
185
+ const subKey = subKeyFor(src.channel, resolved);
186
+ const entry = ensureEntry(realtime, subKey, src.channel, resolved?.queries);
187
+ entry.handlers.set(name, src.handler);
188
+ entry.refs = entry.handlers.size;
189
+ src.subKey = subKey;
190
+ } catch (error) {
191
+ console.error('[Manifest Realtime] Failed to re-subscribe channel:', src.channel, error);
192
+ }
193
+ }
194
+ }
195
+
196
+ // One SDK subscribe per distinct (channel, filter set); names sharing both
197
+ // share it. getRealtime: async () => instance (re-run on identity rebuilds).
198
+ // isCurrent: false once the requesting load is superseded — skip, never
199
+ // touch a newer entry. getFilters: async () => ({ queries, key }) | null,
200
+ // re-run per (re)subscribe; null → channel-only subscribe. A replaced
201
+ // registration detaches only after the new subscription is live (no event
202
+ // gap); overlap double-delivery is the consumer's dedupe to absorb.
203
+ async function subscribe({ name, channel, onEvent, isCurrent, getFilters, getRealtime }) {
204
+ const epoch = tombstones.get(name) || 0;
205
+ const realtime = getRealtime ? await getRealtime() : null;
206
+ if (isCurrent && !isCurrent()) return null;
207
+ if (!realtime) {
208
+ detach(name);
209
+ return null;
210
+ }
211
+ await syncInstance(realtime);
212
+ if (isCurrent && !isCurrent()) return null;
213
+
214
+ const resolved = (supportsQueries(realtime) && getFilters) ? await getFilters() : null;
215
+ if (isCurrent && !isCurrent()) return null;
216
+ // Torn down while resolving: land nothing
217
+ if ((tombstones.get(name) || 0) !== epoch) return null;
218
+ // Instance replaced while resolving (identity rebuild): re-run against the live one
219
+ if (activeRealtime !== realtime) return subscribe({ name, channel, onEvent, isCurrent, getFilters, getRealtime });
220
+ const subKey = subKeyFor(channel, resolved);
221
+
222
+ const prev = sources.get(name);
223
+ let entry;
224
+ try {
225
+ entry = ensureEntry(realtime, subKey, channel, resolved?.queries);
226
+ } catch (error) {
227
+ console.error('[Manifest Realtime] Failed to subscribe:', channel, error);
228
+ detach(name);
229
+ return null;
230
+ }
231
+ const token = Symbol(name);
232
+ entry.handlers.set(name, onEvent);
233
+ entry.refs = entry.handlers.size;
234
+ sources.set(name, { subKey, channel, token, handler: onEvent, getFilters, getRealtime });
235
+ if (prev && prev.subKey !== subKey) removeFromEntry(name, prev.subKey);
236
+
237
+ return () => detach(name, token);
238
+ }
239
+
240
+ // Explicit teardown by name; also aborts a subscribe in flight and
241
+ // drops the name's pre-filter (a replacement re-registers its own)
242
+ function unsubscribe(name) {
243
+ tombstones.set(name, (tombstones.get(name) || 0) + 1);
244
+ filters.delete(name);
245
+ detach(name);
246
+ }
247
+
248
+ // Programmatic pre-filter: fn(action, payload, response) runs once per
249
+ // message per name BEFORE the handler; false drops it. null clears.
250
+ function setFilter(name, fn) {
251
+ if (fn == null) filters.delete(name);
252
+ else if (typeof fn === 'function') filters.set(name, fn);
253
+ }
254
+
255
+ // Identity events: drop the memo, then re-bind live subscriptions on a
256
+ // fresh instance from any registered source's getter
257
+ if (typeof window !== 'undefined') {
258
+ AUTH_EVENTS.forEach(type => window.addEventListener(type, async () => {
259
+ realtimeCache = null;
260
+ realtimeClient = null;
261
+ if (!sources.size) return;
262
+ try {
263
+ const src = [...sources.values()].find(s => s.getRealtime);
264
+ const realtime = src ? await src.getRealtime() : null;
265
+ if (realtime) syncInstance(realtime);
266
+ } catch (error) {
267
+ console.error('[Manifest Realtime] Identity rebuild failed:', error);
268
+ }
269
+ }));
270
+ }
271
+
272
+ return {
273
+ AUTH_EVENTS,
274
+ acquire,
275
+ subscribe,
276
+ unsubscribe,
277
+ setFilter,
278
+ classify,
279
+ supportsQueries,
280
+ _entries: entries, // Expose for debugging (keyed by channel + filter set)
281
+ _sources: sources
282
+ };
283
+ })();
284
+ }
285
+ // mnfst:realtime.hub:end
286
+
9
287
  (function () {
10
288
  'use strict';
11
289
 
@@ -14,12 +292,14 @@
14
292
  const ctx = {
15
293
  client: null,
16
294
  presences: null,
17
- realtime: null,
18
295
  sub: null,
19
296
  heartbeat: null,
20
297
  started: false,
21
298
  status: 'online',
22
299
  metadata: null,
300
+ scope: 'auto', // 'auto'/'team' = current team, 'global', or an explicit team id
301
+ teamsEnabled: false, // manifest.appwrite.auth.teams (read at start)
302
+ settleTimer: null, // bounded wait for teams to settle
23
303
  auto: true, // focus/blur auto-flip
24
304
  heartbeatMs: 25000, // TTL refresh cadence
25
305
  onFocus: null,
@@ -37,19 +317,14 @@
37
317
  });
38
318
  }
39
319
 
40
- async function getConfig() {
320
+ // manifest.json appwrite.presences block ({ scope } today; true = defaults)
321
+ async function getPresencesConfig() {
41
322
  try {
42
- const manifest = await window.ManifestDataConfig?.ensureManifest?.();
43
- const aw = manifest?.appwrite;
44
- if (!aw) return null;
45
- const endpoint = resolveEnv(aw.endpoint);
46
- const projectId = resolveEnv(aw.projectId);
47
- if (!endpoint || !projectId) return null;
48
- let devKey = aw.devKey ? resolveEnv(aw.devKey) : null;
49
- if (devKey && /\$\{/.test(devKey)) devKey = null;
50
- return { endpoint, projectId, devKey };
323
+ const manifest = await (window.ManifestDataConfig?.ensureManifest?.() || window.ManifestAppwriteAuthConfig?.ensureManifest?.());
324
+ const block = manifest?.appwrite?.presences ?? manifest?.appwrite?.presence;
325
+ return (block && typeof block === 'object') ? block : {};
51
326
  } catch (e) {
52
- return null;
327
+ return {};
53
328
  }
54
329
  }
55
330
 
@@ -79,20 +354,62 @@
79
354
  return `hsl(${Math.abs(hash) % 360}, 70%, 50%)`;
80
355
  }
81
356
 
82
- // Appwrite client + services — authed via the session cookie, not the dev key (which is the guests principal for user ops)
357
+ // Shared Appwrite client via the auth plugin (a hard dependency) + the
358
+ // realtime hub: presences never constructs its own Client or Realtime, so
359
+ // data + auth + presence ride one socket per tab
83
360
  async function ensureServices() {
84
- if (ctx.presences && ctx.realtime) return true;
361
+ if (ctx.presences && ctx.client) return true;
85
362
  const A = window.Appwrite;
86
- if (!A || !A.Presences || !A.Realtime) return false;
87
- const cfg = await getConfig();
88
- if (!cfg) return false;
89
- const client = new A.Client().setEndpoint(cfg.endpoint).setProject(cfg.projectId);
90
- ctx.client = client;
91
- ctx.presences = new A.Presences(client);
92
- ctx.realtime = new A.Realtime(client);
363
+ if (!A || !A.Presences || !A.Realtime || !window.ManifestRealtimeHub) return false;
364
+ const services = await window.ManifestAppwriteAuthConfig?.getAppwriteClient?.();
365
+ if (!services?.client) return false;
366
+ ctx.client = services.client;
367
+ ctx.presences = new A.Presences(services.client);
93
368
  return true;
94
369
  }
95
370
 
371
+ /* Scope */
372
+
373
+ // Teams load in the background after auth init; until they settle, a scoped
374
+ // write must not fall back to read(users()) — that is the cross-tenant
375
+ // broadcast window. Settles on the auth event, or after a bounded wait for
376
+ // sessions the background load never covers (e.g. guests without guestTeams).
377
+ let teamsSettled = false;
378
+ window.addEventListener('manifest:auth:teams-loaded', () => { settleTeams(); });
379
+ ['manifest:auth:logout', 'manifest:auth:session-cleared', 'manifest:auth:login', 'manifest:auth:anonymous'].forEach(type =>
380
+ window.addEventListener(type, () => { teamsSettled = false; }));
381
+ function settleTeams() {
382
+ if (teamsSettled) return;
383
+ teamsSettled = true;
384
+ if (ctx.settleTimer) { clearTimeout(ctx.settleTimer); ctx.settleTimer = null; }
385
+ // Settled with a team: the team-switch effect rescopes. Settled with
386
+ // none: rewrite here, from self-only to the global users() grant.
387
+ if (ctx.started && !scopeTeamId()) { hydrate(); rescope(); }
388
+ }
389
+
390
+ // Roster scope as a team id: explicit id > current team ('auto'/'team') > none ('global')
391
+ function scopeTeamId() {
392
+ const s = ctx.scope;
393
+ if (s === 'global' || s === false || s == null) return null;
394
+ if (typeof s === 'string' && s !== 'auto' && s !== 'team') return s;
395
+ return currentTeamId();
396
+ }
397
+
398
+ // Client-side scope filter (always on, defense in depth): a team scope is
399
+ // not expressible as a server-side realtime query — the payload has no team
400
+ // field and $permissions is an array the server's strict equal can't match —
401
+ // so out-of-scope records drop here, before the store. Matches the plugin's
402
+ // own write convention: read(Role.team(teamId)).
403
+ function inScope(raw, userId) {
404
+ const team = scopeTeamId();
405
+ if (!team) return true;
406
+ const meId = authUser()?.$id;
407
+ if (meId && userId === meId) return true;
408
+ const perms = Array.isArray(raw?.$permissions) ? raw.$permissions : [];
409
+ // read("any") is explicitly world-readable: in every scope
410
+ return perms.some(p => p === 'read("any")' || p === `read("team:${team}")` || p.startsWith(`read("team:${team}/`));
411
+ }
412
+
96
413
  /* Records */
97
414
 
98
415
  function normalize(rec) {
@@ -129,7 +446,7 @@
129
446
  const res = await ctx.presences.list();
130
447
  const rows = res?.presences || res?.rows || res?.documents || [];
131
448
  const map = {};
132
- rows.forEach(r => { const n = normalize(r); if (n) map[n.userId] = n; });
449
+ rows.forEach(r => { const n = normalize(r); if (n && inScope(r, n.userId)) map[n.userId] = n; });
133
450
  if (s) s.records = map;
134
451
  } catch (e) {
135
452
  if (s) s.error = e?.message || String(e);
@@ -138,30 +455,28 @@
138
455
 
139
456
  function subscribe() {
140
457
  const A = window.Appwrite;
141
- try {
142
- const handle = ctx.realtime.subscribe(A.Channel.presences(), (response) => {
143
- if (!response) return;
144
- const events = Array.isArray(response.events) ? response.events : (response.events ? [response.events] : []);
145
- const rec = normalize(response.payload || response);
458
+ const hub = window.ManifestRealtimeHub;
459
+ const channel = (A.Channel && typeof A.Channel.presences === 'function') ? A.Channel.presences() : 'presences';
460
+ hub.subscribe({
461
+ name: 'presence:roster',
462
+ channel,
463
+ getRealtime: async () => hub.acquire(ctx.client),
464
+ // One classified call per message (presence upserts classify as update)
465
+ onEvent: (action, payload) => {
466
+ const rec = normalize(payload);
146
467
  if (!rec) return;
147
468
  // Ignore echoes of our own presence (tracked locally; delayed echoes would clobber newer status)
148
469
  const meId = authUser()?.$id;
149
470
  if (meId && rec.userId === meId) return;
150
- if (events.some(e => typeof e === 'string' && e.includes('delete'))) removeRecord(rec.userId);
471
+ // A known record that LEAVES scope is removed, not just ignored
472
+ if (!inScope(payload, rec.userId)) { removeRecord(rec.userId); return; }
473
+ if (action === 'delete') removeRecord(rec.userId);
151
474
  else putRecord(rec);
152
- });
153
- // Normalise the unsubscribe handle
154
- if (handle && typeof handle.then === 'function') {
155
- handle.then(r => { ctx.sub = typeof r === 'function' ? r : (r && r.close ? () => r.close() : null); });
156
- ctx.sub = () => { handle.then(r => { if (typeof r === 'function') r(); else if (r && r.close) r.close(); }); };
157
- } else if (typeof handle === 'function') {
158
- ctx.sub = handle;
159
- } else if (handle && typeof handle.close === 'function') {
160
- ctx.sub = () => handle.close();
161
475
  }
162
- } catch (e) {
476
+ }).catch(e => {
163
477
  const s = store(); if (s) s.error = e?.message || String(e);
164
- }
478
+ });
479
+ ctx.sub = () => hub.unsubscribe('presence:roster');
165
480
  }
166
481
 
167
482
  /* Operations */
@@ -175,10 +490,16 @@
175
490
  ctx.status = status != null ? status : ctx.status;
176
491
  ctx.metadata = metadata !== undefined ? metadata : ctx.metadata;
177
492
 
178
- // Read-scope + owner write (owner update/delete required, else updates 401)
179
- const teamId = currentTeamId();
493
+ // Read-scope + owner write (owner update/delete required, else updates 401).
494
+ // Reads follow the roster scope: team role when scoped, all users when
495
+ // global. Scoped with teams NOT yet settled: self-only — never the
496
+ // users() broadcast (the leak window); settle/team-switch rewrites it.
497
+ const teamId = scopeTeamId();
498
+ const pending = !teamId && ctx.teamsEnabled && !teamsSettled
499
+ && ctx.scope !== 'global' && ctx.scope !== false;
500
+ const readRole = teamId ? A.Role.team(teamId) : (pending ? A.Role.user(user.$id) : A.Role.users());
180
501
  const permissions = [
181
- teamId ? A.Permission.read(A.Role.team(teamId)) : A.Permission.read(A.Role.users()),
502
+ A.Permission.read(readRole),
182
503
  A.Permission.update(A.Role.user(user.$id)),
183
504
  A.Permission.delete(A.Role.user(user.$id))
184
505
  ];
@@ -202,6 +523,15 @@
202
523
  }
203
524
  }
204
525
 
526
+ // Scope change: old-scope rosters never receive a perms rewrite (delivery
527
+ // follows the OLD read grant), so delete first — the delete event still
528
+ // carries it — then re-upsert under the new scope
529
+ async function rescope() {
530
+ const user = authUser();
531
+ if (user && ctx.presences) { try { await ctx.presences.delete({ presenceId: user.$id }); } catch (e) { } }
532
+ await set(ctx.status, ctx.metadata);
533
+ }
534
+
205
535
  async function clear() {
206
536
  const user = authUser();
207
537
  if (!user || !ctx.presences) return;
@@ -226,10 +556,28 @@
226
556
  if (typeof opts.heartbeatMs === 'number') ctx.heartbeatMs = opts.heartbeatMs;
227
557
  if (typeof opts.status === 'string') ctx.status = opts.status;
228
558
 
559
+ // Scope: start() option > manifest appwrite.presences.scope > 'auto'
560
+ const cfg = await getPresencesConfig();
561
+ if (cfg.scope != null) ctx.scope = resolveEnv(cfg.scope);
562
+ if (opts.scope != null) ctx.scope = opts.scope;
563
+
229
564
  const s = store();
230
565
  if (!authUser()) { if (s) s.error = 'sign in required'; return false; }
231
566
  if (!(await ensureServices())) { if (s) s.error = 'Appwrite Presences unavailable'; return false; }
232
567
  if (ctx.started) return true;
568
+
569
+ // Teams settle via the auth plugin's background load; disabled teams or
570
+ // an already-active team settle now, and a bounded wait covers sessions
571
+ // the load never fires for (e.g. guests without guestTeams)
572
+ try {
573
+ const authCfg = await window.ManifestAppwriteAuthConfig?.getAppwriteConfig?.();
574
+ ctx.teamsEnabled = !!authCfg?.teams;
575
+ } catch (e) { ctx.teamsEnabled = false; }
576
+ if (!ctx.teamsEnabled || currentTeamId()) settleTeams();
577
+ else if (!teamsSettled && !ctx.settleTimer) {
578
+ ctx.settleTimer = setTimeout(() => { ctx.settleTimer = null; settleTeams(); }, 8000);
579
+ }
580
+
233
581
  ctx.started = true;
234
582
 
235
583
  await hydrate();
@@ -252,6 +600,7 @@
252
600
  }
253
601
 
254
602
  function stop() {
603
+ if (ctx.settleTimer) { clearTimeout(ctx.settleTimer); ctx.settleTimer = null; }
255
604
  if (ctx.heartbeat) { clearInterval(ctx.heartbeat); ctx.heartbeat = null; }
256
605
  if (ctx.onBlur) { window.removeEventListener('blur', ctx.onBlur); ctx.onBlur = null; }
257
606
  if (ctx.onFocus) { window.removeEventListener('focus', ctx.onFocus); ctx.onFocus = null; }
@@ -273,6 +622,21 @@
273
622
  });
274
623
  } catch (e) { }
275
624
 
625
+ // Re-scope on team switch: refilter the roster, delete + rewrite own
626
+ // presence (old teammates only see the delete; the rewrite isn't
627
+ // delivered to them once the read grant moves)
628
+ let lastTeam;
629
+ try {
630
+ Alpine.effect(() => {
631
+ const team = currentTeamId();
632
+ if (team === lastTeam) return;
633
+ lastTeam = team;
634
+ if (!ctx.started) return;
635
+ hydrate();
636
+ rescope();
637
+ });
638
+ } catch (e) { }
639
+
276
640
  const api = {
277
641
  start, stop, set, clear, of, all,
278
642
  get records() { return store()?.records || {}; },
@@ -2,7 +2,7 @@
2
2
 
3
3
  (function () {
4
4
 
5
- const MANIFEST_BUILD_VERSION = '0.5.254';
5
+ const MANIFEST_BUILD_VERSION = '0.5.256';
6
6
 
7
7
  /* Manifest Data Sources - Configuration */
8
8
 
@@ -15,6 +15,11 @@ function manifestNativePlugin(name) {
15
15
  const cap = window.Capacitor;
16
16
  return (cap && cap.Plugins && cap.Plugins[name]) || null;
17
17
  }
18
+ // Whether a plugin is in the native header list (no list: assume installed)
19
+ function manifestNativeInstalled(name) {
20
+ const headers = window.Capacitor && window.Capacitor.PluginHeaders;
21
+ return !Array.isArray(headers) || headers.some(h => h && h.name === name);
22
+ }
18
23
  // Whether a plugin exposes a native method. Bundled apps get a registerPlugin
19
24
  // Proxy where every property is a function, so prefer the native header; a
20
25
  // header list without this plugin means it isn't installed natively.
@@ -158,6 +163,7 @@ function initManifestNativeNetwork() {
158
163
  // namespaced localStorage fallback on the web. Intended for session tokens etc.
159
164
  // Native: a plugin registered as SecureStorage (@aparajita/capacitor-secure-storage,
160
165
  // or any getItem/setItem or get/set shape); $secure.use(adapter) overrides.
166
+ // Native without a plugin rejects; never localStorage.
161
167
  // The web fallback is NOT encrypted — it degrades honestly, not securely.
162
168
 
163
169
  const MANIFEST_SECURE_NS = 'mnfst:';
@@ -228,25 +234,32 @@ function manifestSecureNativeBackend(plugin, name) {
228
234
  };
229
235
  }
230
236
 
231
- // Registered in JS but absent from the native header list
232
- function manifestSecureInstalled(name) {
233
- const headers = window.Capacitor && window.Capacitor.PluginHeaders;
234
- return !Array.isArray(headers) || headers.some(h => h && h.name === name);
235
- }
237
+ const MANIFEST_SECURE_NONE = 'No secure-storage plugin is installed; install @aparajita/capacitor-secure-storage and run npx cap sync, or provide one with $secure.use(adapter)';
238
+ const MANIFEST_SECURE_UNSYNCED = 'SecureStorage plugin is not installed natively; run npx cap sync';
236
239
 
237
- // Rejects every call; never falls back to unencrypted web storage
238
- function manifestSecureMissingBackend(name) {
239
- const fail = (op) => async () => manifestSecureFail(op, new Error(name + ' plugin is not installed natively; run npx cap sync'));
240
+ // Rejects every call, logs once; never falls back to unencrypted web storage
241
+ let _manifestSecureMissingLogged = false;
242
+ function manifestSecureMissingBackend(msg) {
243
+ const fail = (op) => async () => {
244
+ const e = new Error(msg);
245
+ if (!_manifestSecureMissingLogged) {
246
+ _manifestSecureMissingLogged = true;
247
+ try { console.error('[Manifest] $secure.' + op + ' failed', e); } catch (_) {}
248
+ }
249
+ throw e;
250
+ };
240
251
  return { get: fail('get'), set: fail('set'), remove: fail('remove'), keys: fail('keys'), clear: fail('clear') };
241
252
  }
242
253
 
254
+ // Missing backends aren't cached, so a later-registered plugin is picked up
243
255
  let _manifestSecureBackend = null;
244
256
  function manifestSecureBackend() {
245
257
  if (_manifestSecureBackend) return _manifestSecureBackend;
246
- const plugin = manifestNativeIsNative() ? manifestNativePlugin('SecureStorage') : null;
247
- if (plugin && !manifestSecureInstalled('SecureStorage')) return manifestSecureMissingBackend('SecureStorage');
248
- _manifestSecureBackend = plugin ? manifestSecureNativeBackend(plugin, 'SecureStorage') : manifestSecureWebBackend();
249
- return _manifestSecureBackend;
258
+ if (!manifestNativeIsNative()) return (_manifestSecureBackend = manifestSecureWebBackend());
259
+ const plugin = manifestNativePlugin('SecureStorage');
260
+ if (!plugin) return manifestSecureMissingBackend(MANIFEST_SECURE_NONE);
261
+ if (!manifestNativeInstalled('SecureStorage')) return manifestSecureMissingBackend(MANIFEST_SECURE_UNSYNCED);
262
+ return (_manifestSecureBackend = manifestSecureNativeBackend(plugin, 'SecureStorage'));
250
263
  }
251
264
 
252
265
  function initManifestSecure() {
@@ -1,7 +1,7 @@
1
1
  {
2
- "manifest.appwrite.auth.js": "sha384-dsIWnH0qCAGSzAXMGrd8fvOrVl4tOIE6j5tC35JPTrQXZ56qLD36ARyvhR7vi3Id",
3
- "manifest.appwrite.data.js": "sha384-2emZSbGBrkbdSTRNOsctnzbJ/wd8onhdPH5pKh99u6QwIQ3tl7C4Pc47kEFeTerJ",
4
- "manifest.appwrite.presences.js": "sha384-JyVDWFT69b7Svq8J8p9tNWe2v9PZNQBY5UyhtOl1vnuHFAX0CRQ3MA9SiKq5wzNh",
2
+ "manifest.appwrite.auth.js": "sha384-9WNhIeSEzxxJm+yP7cV41kV2xyzyXgFCNpThqiREcNbb6tnIW6O7VRZPL2jwy9Nk",
3
+ "manifest.appwrite.data.js": "sha384-ot7BHrPKWwiWjT10bvg7/hVKHADiMVTG0zoC0zlG389de40O/L7YFzd3ZzDnKXN5",
4
+ "manifest.appwrite.presences.js": "sha384-qsRtzFCtW7pS+I95EQuZQoPAeZvBsNDSMFwDFBJriyCWcNv2Jo2KFvOweClJhowJ",
5
5
  "manifest.bindings.js": "sha384-lPtMf8Ykaic9gNVZKq8Scia1881ZeMAyxer3WCIkUNwwrgzGBWto1OXADUwjvORF",
6
6
  "manifest.charts.js": "sha384-G+huD7UvjF9zDovdVVlh4I5X1Mq4oNnJfoFg4NnGdP3R/4Skq9hy5cGuHNYLxDYm",
7
7
  "manifest.chat.js": "sha384-88DfMfJLoi2oxclVD2iIMDkIGAExMXq5k47mekUredlicLaP3oZY5bwtwqs+TqDF",
@@ -11,24 +11,24 @@
11
11
  "manifest.combobox.js": "sha384-MILTkrgsTzsYUjGo4ZuADqV85Xs4GmfB4xexfe9vbJMIoQTZ42W4zs+DTaVSPEsx",
12
12
  "manifest.components.js": "sha384-BUFA8h+G5Plr82sV7bKzO42pPoiRZwRQcKj1lt2ROFiKjjlkayU/vmkAFqEsdBA+",
13
13
  "manifest.computed.js": "sha384-GYML/e81gQfN+iDbqhPUgny8bINCyL+SrbTofoGk4hlCnLLjv51mCv42JMqq+ku5",
14
- "manifest.data.js": "sha384-mDAMnK8WbWh2J33JtpcKA5rqNUXmqvVp7KRqgbRc2TGcPB8La78OIhmWAn+/sEgv",
14
+ "manifest.data.js": "sha384-1BIeRvJ8LOJLtCX4uPCepikXSOaSisgTTwfdRYAbChowEGJX1WGC7cfSSBsld0sM",
15
15
  "manifest.datepicker.js": "sha384-2XwFFnS4Bg5FDvG/v8x6KUvrjcBQEOYyTxynX2K/D/Qb3PI6+Y04lEwCnVDJLzwL",
16
16
  "manifest.defer.js": "sha384-GK9qJkYtchtyzSQ+3zmDQUJE3+au4qthT2M8JnFjXxgfwCH5IVKc2r3tackgMryU",
17
- "manifest.device.js": "sha384-EYHIplzx+GmWqm3fgARB9BxtmJsNv9k+avS1RQo3PrIGku4shsMR7JtykWc3g2y1",
17
+ "manifest.device.js": "sha384-AWismZl/UjR/NqH7z4Sfg0DZLPpySDuS0tL34ZsbHdNYDD14JzBW9xp/ScmQKilQ",
18
18
  "manifest.dropdowns.js": "sha384-acufUps7R9WjqB4XXW5JLACqniuHJGM4CNTb2rF4B2Q/jv7ZRmsAwfr2McanOz3s",
19
19
  "manifest.edit.js": "sha384-MOgQNdNLeHijhR0u2W6ntoOulKFglsATCFDqkinesOU15tHoILk/JNzHKkGi5dUG",
20
20
  "manifest.export.js": "sha384-Rx6XvqBZc4apzP/FwcDr6yIln4/zNa08Q4ZqBZcEqUz1aNZQfl4h0+Ae7mqCQyuG",
21
21
  "manifest.icons.js": "sha384-kKOINpX70YzNO3FvkV6umrmO3f0KIu0DNbwB+ej55a7y/lhU7v/myQtXL0bwCTbt",
22
22
  "manifest.localization.js": "sha384-EImsIw8Z3l13V5eH9ZJF/yfsOfIvR1oPRNSArANkPji7V8O3IRf6zMwsNiAGj3Hk",
23
23
  "manifest.markdown.js": "sha384-heyDR40LXHaAmAuA+fa381uFwShk1oAqpFFY6E2Qq4T3VN0TQfRqyc++MF40K5Hj",
24
- "manifest.native.js": "sha384-EYHIplzx+GmWqm3fgARB9BxtmJsNv9k+avS1RQo3PrIGku4shsMR7JtykWc3g2y1",
24
+ "manifest.native.js": "sha384-AWismZl/UjR/NqH7z4Sfg0DZLPpySDuS0tL34ZsbHdNYDD14JzBW9xp/ScmQKilQ",
25
25
  "manifest.payments.js": "sha384-8pofk2zhxtapPMpwnFyTelPMrYhShNhDCE722uQh/EYFvZFuPLmSdgunqsarVQjv",
26
26
  "manifest.rich.text.js": "sha384-ZBV9jXmu2nmprEHicddRE/m7nR4RPZtVc969Jte0+EJYBcVph7itkjcgN/61PyeC",
27
27
  "manifest.router.js": "sha384-9LMIkwrgnxmmFDv70TND2MRJF1z6Nl4yqG7fH5DbJ8oUIhdu/u4yUPSime1/EYhP",
28
28
  "manifest.slides.js": "sha384-3uRTkyK9XPLmnxI2+igZlpi4EyPlU/7IHj5j3BZJJ2KN455vXyk99fiXV3feO/XY",
29
29
  "manifest.status.js": "sha384-MBO345ab7H6Uq1X2dZLt4Ge1Gcra+Z2qYApvlAeIV8CJhDDJrz2FPXpp+Kcp8zcM",
30
30
  "manifest.svg.js": "sha384-qr/8H9+2cIhSXNbRWRtIdrDlTOxKlNJqwEefHvCYoiBNW/kKhPTFa109Orv/EBnb",
31
- "manifest.sw.js": "sha384-tUvpqn6eVfGDN0UN1JJP/lNhiE6O4tukafpJdrWXt7jqO1WSSbCbcVMLTAKeozCG",
31
+ "manifest.sw.js": "sha384-BQY7u/VfJ7DZ23QutEXo4OU+EO646rWLdHPTLRB3JzwIY5InbZEtxAPYKMlQGu35",
32
32
  "manifest.tabs.js": "sha384-otU09H7jnawVKQdLAYpMCd3jx00tgyPVr42D2hLIBzN5dG4se505YesavdnMqEi1",
33
33
  "manifest.tailwind.js": "sha384-r/Jvqb+Piz21jUts10BbVAJoKkShOdJRVFpywyi4HPIi/OfOJq5CtPyWAPbdkMgt",
34
34
  "manifest.toasts.js": "sha384-3zkWxzw693pxg3QDFsoYCA3CqYaLKmBaMArULXODD66Utolpewl0OAeTB92aWUVv",