@unisim/sdk 0.122.2 → 0.123.0

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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The shape supabase-js wants for `auth.storage`. Every method may be async —
3
+ * the Keychain bridge is, and supabase-js awaits all three.
4
+ */
5
+ export interface SessionStorageAdapter {
6
+ getItem(key: string): string | null | Promise<string | null>;
7
+ setItem(key: string, value: string): void | Promise<void>;
8
+ removeItem(key: string): void | Promise<void>;
9
+ }
10
+ /**
11
+ * The synchronous subset — what the cookie adapter actually is.
12
+ *
13
+ * ⚠️ Keep this distinction. The active-org and language stores read their
14
+ * value inside a `useState` INITIALISER, which cannot await; typing the cookie
15
+ * adapter as merely "possibly async" made those reads compile as
16
+ * `Promise<string> | string | null` and the org id would have been a pending
17
+ * promise stringified into a cookie. The compiler caught it; the runtime would
18
+ * not have said anything.
19
+ */
20
+ export interface SyncSessionStorageAdapter {
21
+ getItem(key: string): string | null;
22
+ setItem(key: string, value: string): void;
23
+ removeItem(key: string): void;
24
+ }
25
+ /**
26
+ * True inside a Capacitor container (iOS/Android), false in any browser.
27
+ *
28
+ * ⚠️ Deliberately does NOT trust `cookieDomain` or the build mode to tell it
29
+ * apart. Every product computes `cookieDomain` from `import.meta.env.PROD`,
30
+ * which is true in the native bundle as well as the web one — that shared
31
+ * expression is exactly what made the native session storage silently wrong.
32
+ */
33
+ export declare function isNativeShell(): boolean;
34
+ interface SuiteAuthPlugin {
35
+ get(options: {
36
+ key: string;
37
+ }): Promise<{
38
+ value: string | null;
39
+ }>;
40
+ set(options: {
41
+ key: string;
42
+ value: string;
43
+ }): Promise<void>;
44
+ remove(options: {
45
+ key: string;
46
+ }): Promise<void>;
47
+ }
48
+ /** True when this native build can reach the shared-Keychain plugin. */
49
+ export declare function hasSharedKeychain(): boolean;
50
+ /**
51
+ * Keychain-backed storage, shared across every suite app signed by the same
52
+ * team. Async, which supabase-js supports.
53
+ *
54
+ * ⚠️ On a plugin ERROR this falls back to localStorage for that call, so a
55
+ * Keychain hiccup degrades to an app-local session rather than signing the
56
+ * user out. A plugin returning `null` is NOT an error — it means "no session
57
+ * in the shared store", and must be reported as such, or a sign-out in one app
58
+ * would never be seen by the others.
59
+ */
60
+ export declare function createSharedKeychainStorage(plugin: SuiteAuthPlugin): SessionStorageAdapter;
61
+ /**
62
+ * Cookie-backed storage — the load-bearing piece for cross-subdomain SSO.
63
+ * supabase-js defaults to localStorage which is origin-scoped, so a session
64
+ * created at app.unisim.co.uk wouldn't be visible to a product at
65
+ * assess.unisim.co.uk. Writing the session JSON into a cookie scoped to the
66
+ * parent zone (.unisim.co.uk) means every subdomain reads the same auth.
67
+ *
68
+ * ⚠️ Only usable from an origin that actually sits under `domain`. From
69
+ * anywhere else — `capacitor://localhost`, a dev server on localhost, a
70
+ * file:// Electron renderer — the browser rejects the write outright and every
71
+ * read returns null. `chooseSessionStorage()` is what keeps that from
72
+ * happening; don't reach for this adapter directly.
73
+ */
74
+ export declare function createCookieStorage(domain: string): SyncSessionStorageAdapter;
75
+ export type SessionStorageKind = 'shared-keychain' | 'native-local' | 'shared-cookie' | 'local';
76
+ export interface ChosenSessionStorage {
77
+ kind: SessionStorageKind;
78
+ /** `undefined` means "let supabase-js use its own localStorage adapter". */
79
+ storage: SessionStorageAdapter | undefined;
80
+ /** Whether sibling suite state (active org) may ride the same cookie. */
81
+ cookiesUsable: boolean;
82
+ }
83
+ /**
84
+ * Pick the session store for the platform this code is running on.
85
+ *
86
+ * The native check comes FIRST and overrides `cookieDomain`, because every
87
+ * native build sets `cookieDomain` and none of them can use it.
88
+ */
89
+ export declare function chooseSessionStorage(cookieDomain?: string): ChosenSessionStorage;
90
+ export {};
91
+ //# sourceMappingURL=sessionStorage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionStorage.d.ts","sourceRoot":"","sources":["../src/sessionStorage.ts"],"names":[],"mappings":"AAoCA;;;GAGG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IAC5D,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACzD,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9C;AAED;;;;;;;;;GASG;AACH,MAAM,WAAW,yBAAyB;IACxC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;IACnC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;IACzC,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAgBD;;;;;;;GAOG;AACH,wBAAgB,aAAa,IAAI,OAAO,CAcvC;AAID,UAAU,eAAe;IACvB,GAAG,CAAC,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC,CAAA;IAChE,GAAG,CAAC,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC3D,MAAM,CAAC,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CAChD;AAOD,wEAAwE;AACxE,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAQD;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,eAAe,GAAG,qBAAqB,CAmD1F;AAID;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,yBAAyB,CA0C7E;AAID,MAAM,MAAM,kBAAkB,GAC1B,iBAAiB,GACjB,cAAc,GACd,eAAe,GACf,OAAO,CAAA;AAEX,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,kBAAkB,CAAA;IACxB,4EAA4E;IAC5E,OAAO,EAAE,qBAAqB,GAAG,SAAS,CAAA;IAC1C,yEAAyE;IACzE,aAAa,EAAE,OAAO,CAAA;CACvB;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,oBAAoB,CAsBhF"}
@@ -0,0 +1,239 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // Where the Supabase session lives, per platform.
3
+ //
4
+ // supabase-js keeps the session in whatever `auth.storage` it is handed. The
5
+ // suite needs three different answers, and getting the wrong one is silent
6
+ // every time — a session that fails to persist looks exactly like a session
7
+ // that was never created:
8
+ //
9
+ // • **Browser, production** — a cookie scoped to the parent zone
10
+ // (.unisim.co.uk), so app.unisim.co.uk and assess.unisim.co.uk read the
11
+ // same auth. This is the cross-subdomain SSO mechanism and predates this
12
+ // module; it moved here unchanged.
13
+ //
14
+ // • **Browser, local dev / Electron** — localStorage. There is no parent
15
+ // zone to scope a cookie to on localhost or file://.
16
+ //
17
+ // • **Native (Capacitor)** — the iOS Keychain, in a group shared by every
18
+ // suite app, via the UnisimSuiteAuth plugin. Falls back to localStorage
19
+ // when the plugin is absent.
20
+ //
21
+ // ⚠️ **The native case is why this module exists.** A Capacitor app runs at
22
+ // `capacitor://localhost`, and a cookie written from there with
23
+ // `Domain=.unisim.co.uk` is REJECTED by the cookie domain-match rule — the
24
+ // write silently does nothing and the read comes back null. Every native build
25
+ // sets `cookieDomain` (it is gated on `import.meta.env.PROD`, which is true in
26
+ // the native bundle), so until this module landed **the native apps could not
27
+ // persist a session at all**: sign in, force-quit, and you were signed out
28
+ // again, with nothing logged. `sessionStorage.browser.test.mjs` pins that
29
+ // behaviour so it cannot come back.
30
+ //
31
+ // The shared Keychain then buys the thing that was actually asked for: sign in
32
+ // on Universal Images on the phone and Universal PDF is already signed in.
33
+ // Sign-out is suite-wide too, which matches how the shared cookie already
34
+ // behaves on the web.
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+ function capacitorGlobal() {
37
+ if (typeof window === 'undefined')
38
+ return null;
39
+ const c = window.Capacitor;
40
+ return c ?? null;
41
+ }
42
+ /**
43
+ * True inside a Capacitor container (iOS/Android), false in any browser.
44
+ *
45
+ * ⚠️ Deliberately does NOT trust `cookieDomain` or the build mode to tell it
46
+ * apart. Every product computes `cookieDomain` from `import.meta.env.PROD`,
47
+ * which is true in the native bundle as well as the web one — that shared
48
+ * expression is exactly what made the native session storage silently wrong.
49
+ */
50
+ export function isNativeShell() {
51
+ const c = capacitorGlobal();
52
+ if (c && typeof c.isNativePlatform === 'function') {
53
+ try {
54
+ return c.isNativePlatform();
55
+ }
56
+ catch {
57
+ /* fall through to the protocol check */
58
+ }
59
+ }
60
+ // A partially-initialised bridge still serves the app from the container's
61
+ // own scheme, which no browser uses.
62
+ if (typeof window === 'undefined')
63
+ return false;
64
+ const protocol = window.location?.protocol;
65
+ return protocol === 'capacitor:' || protocol === 'ionic:';
66
+ }
67
+ function suiteAuthPlugin() {
68
+ const plugin = capacitorGlobal()?.Plugins?.UnisimSuiteAuth;
69
+ return plugin ? plugin : null;
70
+ }
71
+ /** True when this native build can reach the shared-Keychain plugin. */
72
+ export function hasSharedKeychain() {
73
+ return suiteAuthPlugin() !== null;
74
+ }
75
+ function localStorageAdapter() {
76
+ // `undefined` lets supabase-js install its own localStorage adapter, which
77
+ // already handles the private-mode / storage-disabled throw.
78
+ return undefined;
79
+ }
80
+ /**
81
+ * Keychain-backed storage, shared across every suite app signed by the same
82
+ * team. Async, which supabase-js supports.
83
+ *
84
+ * ⚠️ On a plugin ERROR this falls back to localStorage for that call, so a
85
+ * Keychain hiccup degrades to an app-local session rather than signing the
86
+ * user out. A plugin returning `null` is NOT an error — it means "no session
87
+ * in the shared store", and must be reported as such, or a sign-out in one app
88
+ * would never be seen by the others.
89
+ */
90
+ export function createSharedKeychainStorage(plugin) {
91
+ let warned = false;
92
+ const warn = (op, err) => {
93
+ if (warned)
94
+ return;
95
+ warned = true;
96
+ console.warn(`[unisim/sdk] shared Keychain ${op} failed; falling back to app-local storage. ` +
97
+ 'Sign-in will not carry across the suite apps on this device.', err);
98
+ };
99
+ const local = {
100
+ get: (key) => {
101
+ try {
102
+ return window.localStorage.getItem(key);
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ },
108
+ set: (key, value) => {
109
+ try {
110
+ window.localStorage.setItem(key, value);
111
+ }
112
+ catch { /* private mode */ }
113
+ },
114
+ remove: (key) => {
115
+ try {
116
+ window.localStorage.removeItem(key);
117
+ }
118
+ catch { /* private mode */ }
119
+ },
120
+ };
121
+ return {
122
+ async getItem(key) {
123
+ try {
124
+ const { value } = await plugin.get({ key });
125
+ return value ?? null;
126
+ }
127
+ catch (err) {
128
+ warn('read', err);
129
+ return local.get(key);
130
+ }
131
+ },
132
+ async setItem(key, value) {
133
+ try {
134
+ await plugin.set({ key, value });
135
+ }
136
+ catch (err) {
137
+ warn('write', err);
138
+ local.set(key, value);
139
+ }
140
+ },
141
+ async removeItem(key) {
142
+ try {
143
+ await plugin.remove({ key });
144
+ }
145
+ catch (err) {
146
+ warn('delete', err);
147
+ local.remove(key);
148
+ }
149
+ },
150
+ };
151
+ }
152
+ // ── Browser: the cross-subdomain cookie ──────────────────────────────────────
153
+ /**
154
+ * Cookie-backed storage — the load-bearing piece for cross-subdomain SSO.
155
+ * supabase-js defaults to localStorage which is origin-scoped, so a session
156
+ * created at app.unisim.co.uk wouldn't be visible to a product at
157
+ * assess.unisim.co.uk. Writing the session JSON into a cookie scoped to the
158
+ * parent zone (.unisim.co.uk) means every subdomain reads the same auth.
159
+ *
160
+ * ⚠️ Only usable from an origin that actually sits under `domain`. From
161
+ * anywhere else — `capacitor://localhost`, a dev server on localhost, a
162
+ * file:// Electron renderer — the browser rejects the write outright and every
163
+ * read returns null. `chooseSessionStorage()` is what keeps that from
164
+ * happening; don't reach for this adapter directly.
165
+ */
166
+ export function createCookieStorage(domain) {
167
+ const secure = typeof window !== 'undefined' && window.location.protocol === 'https:';
168
+ return {
169
+ getItem(key) {
170
+ if (typeof document === 'undefined')
171
+ return null;
172
+ const encoded = encodeURIComponent(key);
173
+ for (const cookie of document.cookie.split('; ')) {
174
+ const eq = cookie.indexOf('=');
175
+ const name = eq === -1 ? cookie : cookie.slice(0, eq);
176
+ if (name === encoded) {
177
+ return eq === -1 ? '' : decodeURIComponent(cookie.slice(eq + 1));
178
+ }
179
+ }
180
+ return null;
181
+ },
182
+ setItem(key, value) {
183
+ if (typeof document === 'undefined')
184
+ return;
185
+ const parts = [
186
+ `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
187
+ 'Path=/',
188
+ `Domain=${domain}`,
189
+ `Max-Age=${60 * 60 * 24 * 365}`, // 1 year
190
+ 'SameSite=Lax',
191
+ ];
192
+ if (secure)
193
+ parts.push('Secure');
194
+ document.cookie = parts.join('; ');
195
+ },
196
+ removeItem(key) {
197
+ if (typeof document === 'undefined')
198
+ return;
199
+ const parts = [
200
+ `${encodeURIComponent(key)}=`,
201
+ 'Path=/',
202
+ `Domain=${domain}`,
203
+ 'Max-Age=0',
204
+ 'SameSite=Lax',
205
+ ];
206
+ if (secure)
207
+ parts.push('Secure');
208
+ document.cookie = parts.join('; ');
209
+ },
210
+ };
211
+ }
212
+ /**
213
+ * Pick the session store for the platform this code is running on.
214
+ *
215
+ * The native check comes FIRST and overrides `cookieDomain`, because every
216
+ * native build sets `cookieDomain` and none of them can use it.
217
+ */
218
+ export function chooseSessionStorage(cookieDomain) {
219
+ if (isNativeShell()) {
220
+ const plugin = suiteAuthPlugin();
221
+ if (plugin) {
222
+ return {
223
+ kind: 'shared-keychain',
224
+ storage: createSharedKeychainStorage(plugin),
225
+ cookiesUsable: false,
226
+ };
227
+ }
228
+ return { kind: 'native-local', storage: localStorageAdapter(), cookiesUsable: false };
229
+ }
230
+ if (cookieDomain) {
231
+ return {
232
+ kind: 'shared-cookie',
233
+ storage: createCookieStorage(cookieDomain),
234
+ cookiesUsable: true,
235
+ };
236
+ }
237
+ return { kind: 'local', storage: localStorageAdapter(), cookiesUsable: false };
238
+ }
239
+ //# sourceMappingURL=sessionStorage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionStorage.js","sourceRoot":"","sources":["../src/sessionStorage.ts"],"names":[],"mappings":"AAAA,gFAAgF;AAChF,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,2EAA2E;AAC3E,4EAA4E;AAC5E,0BAA0B;AAC1B,EAAE;AACF,mEAAmE;AACnE,4EAA4E;AAC5E,6EAA6E;AAC7E,uCAAuC;AACvC,EAAE;AACF,2EAA2E;AAC3E,yDAAyD;AACzD,EAAE;AACF,4EAA4E;AAC5E,4EAA4E;AAC5E,iCAAiC;AACjC,EAAE;AACF,4EAA4E;AAC5E,gEAAgE;AAChE,2EAA2E;AAC3E,+EAA+E;AAC/E,+EAA+E;AAC/E,8EAA8E;AAC9E,2EAA2E;AAC3E,0EAA0E;AAC1E,oCAAoC;AACpC,EAAE;AACF,+EAA+E;AAC/E,2EAA2E;AAC3E,0EAA0E;AAC1E,sBAAsB;AACtB,gFAAgF;AAoChF,SAAS,eAAe;IACtB,IAAI,OAAO,MAAM,KAAK,WAAW;QAAE,OAAO,IAAI,CAAA;IAC9C,MAAM,CAAC,GAAI,MAAqD,CAAC,SAAS,CAAA;IAC1E,OAAO,CAAC,IAAI,IAAI,CAAA;AAClB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa;IAC3B,MAAM,CAAC,GAAG,eAAe,EAAE,CAAA;IAC3B,IAAI,CAAC,IAAI,OAAO,CAAC,CAAC,gBAAgB,KAAK,UAAU,EAAE,CAAC;QAClD,IAAI,CAAC;YACH,OAAO,CAAC,CAAC,gBAAgB,EAAE,CAAA;QAC7B,CAAC;QAAC,MAAM,CAAC;YACP,wCAAwC;QAC1C,CAAC;IACH,CAAC;IACD,2EAA2E;IAC3E,qCAAqC;IACrC,IAAI,OAAO,MAAM,KAAK,WAAW;QAAE,OAAO,KAAK,CAAA;IAC/C,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,EAAE,QAAQ,CAAA;IAC1C,OAAO,QAAQ,KAAK,YAAY,IAAI,QAAQ,KAAK,QAAQ,CAAA;AAC3D,CAAC;AAUD,SAAS,eAAe;IACtB,MAAM,MAAM,GAAG,eAAe,EAAE,EAAE,OAAO,EAAE,eAAe,CAAA;IAC1D,OAAO,MAAM,CAAC,CAAC,CAAE,MAAqC,CAAC,CAAC,CAAC,IAAI,CAAA;AAC/D,CAAC;AAED,wEAAwE;AACxE,MAAM,UAAU,iBAAiB;IAC/B,OAAO,eAAe,EAAE,KAAK,IAAI,CAAA;AACnC,CAAC;AAED,SAAS,mBAAmB;IAC1B,2EAA2E;IAC3E,6DAA6D;IAC7D,OAAO,SAAS,CAAA;AAClB,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,2BAA2B,CAAC,MAAuB;IACjE,IAAI,MAAM,GAAG,KAAK,CAAA;IAClB,MAAM,IAAI,GAAG,CAAC,EAAU,EAAE,GAAY,EAAE,EAAE;QACxC,IAAI,MAAM;YAAE,OAAM;QAClB,MAAM,GAAG,IAAI,CAAA;QACb,OAAO,CAAC,IAAI,CACV,gCAAgC,EAAE,8CAA8C;YAChF,8DAA8D,EAC9D,GAAG,CACJ,CAAA;IACH,CAAC,CAAA;IAED,MAAM,KAAK,GAAG;QACZ,GAAG,EAAE,CAAC,GAAW,EAAE,EAAE;YACnB,IAAI,CAAC;gBAAC,OAAO,MAAM,CAAC,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;YAAC,CAAC;YAAC,MAAM,CAAC;gBAAC,OAAO,IAAI,CAAA;YAAC,CAAC;QACvE,CAAC;QACD,GAAG,EAAE,CAAC,GAAW,EAAE,KAAa,EAAE,EAAE;YAClC,IAAI,CAAC;gBAAC,MAAM,CAAC,YAAY,CAAC,OAAO,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;YAAC,CAAC;YAAC,MAAM,CAAC,CAAC,kBAAkB,CAAC,CAAC;QAC9E,CAAC;QACD,MAAM,EAAE,CAAC,GAAW,EAAE,EAAE;YACtB,IAAI,CAAC;gBAAC,MAAM,CAAC,YAAY,CAAC,UAAU,CAAC,GAAG,CAAC,CAAA;YAAC,CAAC;YAAC,MAAM,CAAC,CAAC,kBAAkB,CAAC,CAAC;QAC1E,CAAC;KACF,CAAA;IAED,OAAO;QACL,KAAK,CAAC,OAAO,CAAC,GAAG;YACf,IAAI,CAAC;gBACH,MAAM,EAAE,KAAK,EAAE,GAAG,MAAM,MAAM,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,CAAA;gBAC3C,OAAO,KAAK,IAAI,IAAI,CAAA;YACtB,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,IAAI,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;gBACjB,OAAO,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;YACvB,CAAC;QACH,CAAC;QACD,KAAK,CAAC,OAAO,CAAC,GAAG,EAAE,KAAK;YACtB,IAAI,CAAC;gBACH,MAAM,MAAM,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,CAAA;YAClC,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,IAAI,CAAC,OAAO,EAAE,GAAG,CAAC,CAAA;gBAClB,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;YACvB,CAAC;QACH,CAAC;QACD,KAAK,CAAC,UAAU,CAAC,GAAG;YAClB,IAAI,CAAC;gBACH,MAAM,MAAM,CAAC,MAAM,CAAC,EAAE,GAAG,EAAE,CAAC,CAAA;YAC9B,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,IAAI,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAA;gBACnB,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;YACnB,CAAC;QACH,CAAC;KACF,CAAA;AACH,CAAC;AAED,gFAAgF;AAEhF;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAc;IAChD,MAAM,MAAM,GACV,OAAO,MAAM,KAAK,WAAW,IAAI,MAAM,CAAC,QAAQ,CAAC,QAAQ,KAAK,QAAQ,CAAA;IAExE,OAAO;QACL,OAAO,CAAC,GAAW;YACjB,IAAI,OAAO,QAAQ,KAAK,WAAW;gBAAE,OAAO,IAAI,CAAA;YAChD,MAAM,OAAO,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAA;YACvC,KAAK,MAAM,MAAM,IAAI,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;gBACjD,MAAM,EAAE,GAAG,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;gBAC9B,MAAM,IAAI,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;gBACrD,IAAI,IAAI,KAAK,OAAO,EAAE,CAAC;oBACrB,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,kBAAkB,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,CAAA;gBAClE,CAAC;YACH,CAAC;YACD,OAAO,IAAI,CAAA;QACb,CAAC;QACD,OAAO,CAAC,GAAW,EAAE,KAAa;YAChC,IAAI,OAAO,QAAQ,KAAK,WAAW;gBAAE,OAAM;YAC3C,MAAM,KAAK,GAAG;gBACZ,GAAG,kBAAkB,CAAC,GAAG,CAAC,IAAI,kBAAkB,CAAC,KAAK,CAAC,EAAE;gBACzD,QAAQ;gBACR,UAAU,MAAM,EAAE;gBAClB,WAAW,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,GAAG,EAAE,EAAG,SAAS;gBAC3C,cAAc;aACf,CAAA;YACD,IAAI,MAAM;gBAAE,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;YAChC,QAAQ,CAAC,MAAM,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACpC,CAAC;QACD,UAAU,CAAC,GAAW;YACpB,IAAI,OAAO,QAAQ,KAAK,WAAW;gBAAE,OAAM;YAC3C,MAAM,KAAK,GAAG;gBACZ,GAAG,kBAAkB,CAAC,GAAG,CAAC,GAAG;gBAC7B,QAAQ;gBACR,UAAU,MAAM,EAAE;gBAClB,WAAW;gBACX,cAAc;aACf,CAAA;YACD,IAAI,MAAM;gBAAE,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;YAChC,QAAQ,CAAC,MAAM,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;QACpC,CAAC;KACF,CAAA;AACH,CAAC;AAkBD;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,YAAqB;IACxD,IAAI,aAAa,EAAE,EAAE,CAAC;QACpB,MAAM,MAAM,GAAG,eAAe,EAAE,CAAA;QAChC,IAAI,MAAM,EAAE,CAAC;YACX,OAAO;gBACL,IAAI,EAAE,iBAAiB;gBACvB,OAAO,EAAE,2BAA2B,CAAC,MAAM,CAAC;gBAC5C,aAAa,EAAE,KAAK;aACrB,CAAA;QACH,CAAC;QACD,OAAO,EAAE,IAAI,EAAE,cAAc,EAAE,OAAO,EAAE,mBAAmB,EAAE,EAAE,aAAa,EAAE,KAAK,EAAE,CAAA;IACvF,CAAC;IAED,IAAI,YAAY,EAAE,CAAC;QACjB,OAAO;YACL,IAAI,EAAE,eAAe;YACrB,OAAO,EAAE,mBAAmB,CAAC,YAAY,CAAC;YAC1C,aAAa,EAAE,IAAI;SACpB,CAAA;IACH,CAAC;IAED,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,mBAAmB,EAAE,EAAE,aAAa,EAAE,KAAK,EAAE,CAAA;AAChF,CAAC"}
@@ -0,0 +1,241 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import Security
4
+
5
+ /// Stores the Universal Suite session in the iOS Keychain, in a group shared by
6
+ /// every suite app signed by the same team — so signing in on Universal Images
7
+ /// leaves you signed in when you open Universal PDF.
8
+ ///
9
+ /// It exists because a Capacitor app runs at `capacitor://localhost`, where the
10
+ /// suite's cross-subdomain auth cookie cannot be written at all: the browser
11
+ /// rejects `Domain=.unisim.co.uk` from an origin outside that zone. Before this
12
+ /// plugin the native builds could not persist a session even to THEMSELVES —
13
+ /// sign in, force-quit, signed out again, nothing logged. See
14
+ /// `src/sessionStorage.ts` and its browser test.
15
+ ///
16
+ /// ⚠️ **Sharing needs an entitlement the plugin cannot add for you.** The host
17
+ /// app must carry an `.entitlements` file declaring the shared group:
18
+ ///
19
+ /// <key>keychain-access-groups</key>
20
+ /// <array><string>$(AppIdentifierPrefix)co.uk.unisim.suite</string></array>
21
+ ///
22
+ /// and reference it from `CODE_SIGN_ENTITLEMENTS` in the Xcode project. No
23
+ /// `capacitor.config.json` change is needed — the team prefix is resolved at
24
+ /// runtime. An app with NO entitlements file cannot use the Keychain at all:
25
+ /// every call comes back `errSecMissingEntitlement` (-34018), which is also
26
+ /// what an unsigned simulator build gets.
27
+ ///
28
+ /// Without a resolvable group the plugin still stores the session, app-locally:
29
+ /// it persists across launches but does NOT carry to the other suite apps.
30
+ /// `status()` reports which of the two you actually got, because nothing about
31
+ /// the behaviour makes that visible from the outside.
32
+ @objc(UnisimSuiteAuthPlugin)
33
+ public class UnisimSuiteAuthPlugin: CAPPlugin, CAPBridgedPlugin {
34
+ public let identifier = "UnisimSuiteAuthPlugin"
35
+ public let jsName = "UnisimSuiteAuth"
36
+ public let pluginMethods: [CAPPluginMethod] = [
37
+ CAPPluginMethod(name: "get", returnType: CAPPluginReturnPromise),
38
+ CAPPluginMethod(name: "set", returnType: CAPPluginReturnPromise),
39
+ CAPPluginMethod(name: "remove", returnType: CAPPluginReturnPromise),
40
+ CAPPluginMethod(name: "status", returnType: CAPPluginReturnPromise)
41
+ ]
42
+
43
+ /// One service for the whole suite. The `key` the JS side passes (Supabase's
44
+ /// `storageKey`, i.e. `universal-suite-auth`) becomes the account, so other
45
+ /// suite keys can share the same group later without colliding.
46
+ private static let service = "co.uk.unisim.suite.auth"
47
+
48
+ /// The group every suite app shares. Prefixed with the team's app-identifier
49
+ /// prefix at runtime — see `appIdentifierPrefix()`.
50
+ private static let defaultGroupSuffix = "co.uk.unisim.suite"
51
+
52
+ private static var cachedPrefix: String??
53
+
54
+ /// The team prefix iOS stamps on this app's keychain items, e.g. the
55
+ /// `ABCDE12345` in `ABCDE12345.co.uk.unisim.pdf`.
56
+ ///
57
+ /// ⚠️ Resolved at RUNTIME rather than hardcoded, and that is deliberate: the
58
+ /// entitlement is written as `$(AppIdentifierPrefix)co.uk.unisim.suite`, and
59
+ /// `kSecAttrAccessGroup` will only accept the expanded form. Baking a team
60
+ /// ID into the config of thirteen apps means thirteen places to be wrong,
61
+ /// and a wrong one fails as `errSecMissingEntitlement` at sign-in — far from
62
+ /// where the mistake was made.
63
+ ///
64
+ /// The trick is the documented one: write an item with NO access group, and
65
+ /// read back the group iOS assigned it, which is always
66
+ /// `<prefix>.<bundle id>`.
67
+ private static func appIdentifierPrefix() -> String? {
68
+ if let cached = cachedPrefix { return cached }
69
+
70
+ let base: [String: Any] = [
71
+ kSecClass as String: kSecClassGenericPassword,
72
+ kSecAttrService as String: service,
73
+ kSecAttrAccount as String: "unisim.suite.prefix-probe"
74
+ ]
75
+
76
+ var query = base
77
+ query[kSecReturnAttributes as String] = true
78
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
79
+
80
+ var item: CFTypeRef?
81
+ var status = SecItemCopyMatching(query as CFDictionary, &item)
82
+ if status == errSecItemNotFound {
83
+ var insert = base
84
+ insert[kSecValueData as String] = Data()
85
+ insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
86
+ insert[kSecReturnAttributes as String] = true
87
+ status = SecItemAdd(insert as CFDictionary, &item)
88
+ }
89
+
90
+ guard status == errSecSuccess,
91
+ let attributes = item as? [String: Any],
92
+ let group = attributes[kSecAttrAccessGroup as String] as? String,
93
+ let dot = group.firstIndex(of: ".") else {
94
+ cachedPrefix = .some(nil)
95
+ return nil
96
+ }
97
+
98
+ let prefix = String(group[group.startIndex..<dot])
99
+ cachedPrefix = .some(prefix)
100
+ return prefix
101
+ }
102
+
103
+ /// The group to store under, or nil to fall back to an app-local item.
104
+ private var accessGroup: String? {
105
+ // An explicit group wins — an escape hatch for an app that needs to sit
106
+ // outside the suite group, and the only way to override the runtime
107
+ // resolution if it ever gets this wrong.
108
+ if let explicit = getConfig().getString("accessGroup"), !explicit.isEmpty {
109
+ return explicit
110
+ }
111
+ let suffix = getConfig().getString("accessGroupSuffix") ?? Self.defaultGroupSuffix
112
+ guard let prefix = Self.appIdentifierPrefix() else { return nil }
113
+ return "\(prefix).\(suffix)"
114
+ }
115
+
116
+ private func baseQuery(for key: String) -> [String: Any] {
117
+ var query: [String: Any] = [
118
+ kSecClass as String: kSecClassGenericPassword,
119
+ kSecAttrService as String: Self.service,
120
+ kSecAttrAccount as String: key
121
+ ]
122
+ if let group = accessGroup {
123
+ query[kSecAttrAccessGroup as String] = group
124
+ }
125
+ return query
126
+ }
127
+
128
+ /// ⚠️ Put the OSStatus in the MESSAGE, not just the code. Every one of these
129
+ /// failures is indistinguishable from the JS side otherwise — "Keychain
130
+ /// update failed" was all that came back from the first simulator run, and
131
+ /// the number was the only thing that would have identified it.
132
+ ///
133
+ /// The one worth recognising is **-34018 `errSecMissingEntitlement`**, which
134
+ /// means the process has no keychain entitlement to work with at all. Two
135
+ /// ways to get there: an app built with code signing disabled (an unsigned
136
+ /// simulator build has no `application-identifier`, so the Keychain refuses
137
+ /// everything), or a configured `accessGroup` that is not listed in the
138
+ /// app's `keychain-access-groups` — including one missing the team prefix.
139
+ private static func describe(_ operation: String, _ status: OSStatus) -> String {
140
+ let detail = SecCopyErrorMessageString(status, nil) as String? ?? "unknown error"
141
+ let hint = status == errSecMissingEntitlement
142
+ ? " — the app has no usable keychain entitlement (unsigned build, or accessGroup not in keychain-access-groups)"
143
+ : ""
144
+ return "Keychain \(operation) failed: OSStatus \(status) (\(detail))\(hint)"
145
+ }
146
+
147
+ @objc func status(_ call: CAPPluginCall) {
148
+ let group = accessGroup
149
+ call.resolve([
150
+ "shared": group != nil,
151
+ "accessGroup": group ?? NSNull(),
152
+ "appIdentifierPrefix": Self.appIdentifierPrefix() ?? NSNull()
153
+ ])
154
+ }
155
+
156
+ @objc func get(_ call: CAPPluginCall) {
157
+ guard let key = call.getString("key") else {
158
+ call.reject("key is required")
159
+ return
160
+ }
161
+
162
+ var query = baseQuery(for: key)
163
+ query[kSecReturnData as String] = true
164
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
165
+
166
+ var item: CFTypeRef?
167
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
168
+
169
+ switch status {
170
+ case errSecSuccess:
171
+ guard let data = item as? Data, let value = String(data: data, encoding: .utf8) else {
172
+ // Present but unreadable — treat as absent rather than throwing,
173
+ // so a corrupt item shows as "signed out" and can be overwritten
174
+ // by the next sign-in instead of wedging the app.
175
+ call.resolve(["value": NSNull()])
176
+ return
177
+ }
178
+ call.resolve(["value": value])
179
+ case errSecItemNotFound:
180
+ // ⚠️ NOT an error, and the JS side must not treat it as one. "No
181
+ // session in the shared store" is exactly what another suite app
182
+ // signing out looks like.
183
+ call.resolve(["value": NSNull()])
184
+ default:
185
+ call.reject(Self.describe("read", status), String(status))
186
+ }
187
+ }
188
+
189
+ @objc func set(_ call: CAPPluginCall) {
190
+ guard let key = call.getString("key") else {
191
+ call.reject("key is required")
192
+ return
193
+ }
194
+ guard let value = call.getString("value"), let data = value.data(using: .utf8) else {
195
+ call.reject("value is required")
196
+ return
197
+ }
198
+
199
+ let query = baseQuery(for: key)
200
+ let attributes: [String: Any] = [
201
+ kSecValueData as String: data,
202
+ // Readable once the device has been unlocked after a reboot, and
203
+ // never synced to iCloud — a bearer token has no business on
204
+ // another device.
205
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
206
+ ]
207
+
208
+ let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
209
+ if updateStatus == errSecSuccess {
210
+ call.resolve()
211
+ return
212
+ }
213
+ if updateStatus != errSecItemNotFound {
214
+ call.reject(Self.describe("update", updateStatus), String(updateStatus))
215
+ return
216
+ }
217
+
218
+ var insert = query
219
+ insert.merge(attributes) { current, _ in current }
220
+ let addStatus = SecItemAdd(insert as CFDictionary, nil)
221
+ if addStatus == errSecSuccess {
222
+ call.resolve()
223
+ } else {
224
+ call.reject(Self.describe("write", addStatus), String(addStatus))
225
+ }
226
+ }
227
+
228
+ @objc func remove(_ call: CAPPluginCall) {
229
+ guard let key = call.getString("key") else {
230
+ call.reject("key is required")
231
+ return
232
+ }
233
+
234
+ let status = SecItemDelete(baseQuery(for: key) as CFDictionary)
235
+ if status == errSecSuccess || status == errSecItemNotFound {
236
+ call.resolve()
237
+ } else {
238
+ call.reject(Self.describe("delete", status), String(status))
239
+ }
240
+ }
241
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@unisim/sdk",
3
- "version": "0.122.2",
4
- "description": "Shared React SDK for the Universal Suite auth, entitlements, usage telemetry, changelog, org admin.",
3
+ "version": "0.123.0",
4
+ "description": "Shared React SDK for the Universal Suite \u2014 auth, entitlements, usage telemetry, changelog, org admin.",
5
5
  "license": "MIT",
6
6
  "author": "Universal Simulation Ltd",
7
7
  "keywords": [
@@ -28,6 +28,11 @@
28
28
  "module": "./dist/index.js",
29
29
  "types": "./dist/index.d.ts",
30
30
  "sideEffects": false,
31
+ "capacitor": {
32
+ "ios": {
33
+ "src": "ios"
34
+ }
35
+ },
31
36
  "exports": {
32
37
  ".": {
33
38
  "types": "./dist/index.d.ts",
@@ -40,12 +45,16 @@
40
45
  "files": [
41
46
  "dist",
42
47
  "electron",
48
+ "ios/Sources",
49
+ "Package.swift",
50
+ "UnisimSdk.podspec",
43
51
  "README.md"
44
52
  ],
45
53
  "scripts": {
46
54
  "build": "tsc -p tsconfig.build.json",
47
55
  "dev": "tsc -p tsconfig.build.json --watch",
48
56
  "typecheck": "tsc --noEmit",
57
+ "test:session-storage": "npm run build && node tests/session-storage.browser.mjs",
49
58
  "prepublishOnly": "npm run typecheck && npm run build"
50
59
  },
51
60
  "dependencies": {
@@ -62,6 +71,7 @@
62
71
  "@types/react": "^18.3.0",
63
72
  "@types/react-dom": "^18.3.7",
64
73
  "pdf-lib": "^1.17.1",
74
+ "playwright": "^1.62.1",
65
75
  "react": "^18.3.0",
66
76
  "react-dom": "^18.3.1",
67
77
  "typescript": "^5.7.2"