@pylonsync/client 0.21.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -2
- package/dist/lib/api.d.ts +20 -0
- package/package.json +3 -3
- package/src/index.ts +3 -0
- package/src/lib/api.ts +44 -3
- package/src/lib/ensureGuestSession.test.ts +89 -0
package/dist/index.d.ts
CHANGED
|
@@ -34,5 +34,5 @@ export type { RouteSpec, MatchedRoute } from "./router/match";
|
|
|
34
34
|
export { useRouter, useParams, useSearchParams, usePathname, } from "./router/useRouter";
|
|
35
35
|
export { useAuth } from "./hooks/useAuth";
|
|
36
36
|
export type { UseAuthReturn } from "./hooks/useAuth";
|
|
37
|
-
export { acceptInvite, ApiError, changePassword, completePasswordReset, ensureGuestSession, connectionAuthUrl, createApiKey, createInvite, createOrg, deleteOrg, renameOrg, listActiveSessions, listApiKeys, listAuthProviders, listInvites, listOrgMembers, listOrgs, passwordLogin, passwordRegister, persistSession, removeMember, requestPasswordReset, revokeAllSessions, revokeApiKey, revokeInvite, sendMagicLink, updateMemberRole, verifyMagicLink, } from "./lib/api";
|
|
38
|
-
export type { ActiveSession, ApiKeyCreated, ApiKeySummary, AuthProvider, InviteResult, OrgMember, OrgSummary, PendingInvite, SessionResponse, } from "./lib/api";
|
|
37
|
+
export { acceptInvite, acceptInviteById, myInvites, ApiError, changePassword, completePasswordReset, ensureGuestSession, connectionAuthUrl, createApiKey, createInvite, createOrg, deleteOrg, renameOrg, listActiveSessions, listApiKeys, listAuthProviders, listInvites, listOrgMembers, listOrgs, passwordLogin, passwordRegister, persistSession, removeMember, requestPasswordReset, revokeAllSessions, revokeApiKey, revokeInvite, sendMagicLink, updateMemberRole, verifyMagicLink, } from "./lib/api";
|
|
38
|
+
export type { ActiveSession, ApiKeyCreated, ApiKeySummary, AuthProvider, InviteResult, OrgMember, OrgSummary, PendingInvite, MyInvite, SessionResponse, } from "./lib/api";
|
package/dist/lib/api.d.ts
CHANGED
|
@@ -114,6 +114,26 @@ export declare function acceptInvite(token: string): Promise<{
|
|
|
114
114
|
org_id: string;
|
|
115
115
|
role: string;
|
|
116
116
|
}>;
|
|
117
|
+
/** A pending invite addressed to the signed-in user's email. */
|
|
118
|
+
export interface MyInvite {
|
|
119
|
+
id: string;
|
|
120
|
+
org_id: string;
|
|
121
|
+
email: string;
|
|
122
|
+
role: string;
|
|
123
|
+
invited_by: string;
|
|
124
|
+
created_at: number;
|
|
125
|
+
expires_at: number;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Pending invites for the signed-in user's verified email, across orgs.
|
|
129
|
+
* Rejects with `EMAIL_NOT_VERIFIED` until the User row has `emailVerified`.
|
|
130
|
+
*/
|
|
131
|
+
export declare function myInvites(): Promise<MyInvite[]>;
|
|
132
|
+
/** Accept an invite from {@link myInvites} without the emailed token. */
|
|
133
|
+
export declare function acceptInviteById(inviteId: string): Promise<{
|
|
134
|
+
org_id: string;
|
|
135
|
+
role: string;
|
|
136
|
+
}>;
|
|
117
137
|
export interface ConnectionAuthUrl {
|
|
118
138
|
url: string;
|
|
119
139
|
}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.22.1",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "src/index.ts",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
"prepack": "bun run build"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@pylonsync/react": "0.
|
|
24
|
-
"@pylonsync/sync": "0.
|
|
23
|
+
"@pylonsync/react": "0.22.1",
|
|
24
|
+
"@pylonsync/sync": "0.22.1",
|
|
25
25
|
"clsx": "^2.1.1"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -91,6 +91,8 @@ export type { UseAuthReturn } from "./hooks/useAuth";
|
|
|
91
91
|
// + error-mapping logic the built-in components use.
|
|
92
92
|
export {
|
|
93
93
|
acceptInvite,
|
|
94
|
+
acceptInviteById,
|
|
95
|
+
myInvites,
|
|
94
96
|
ApiError,
|
|
95
97
|
changePassword,
|
|
96
98
|
completePasswordReset,
|
|
@@ -128,5 +130,6 @@ export type {
|
|
|
128
130
|
OrgMember,
|
|
129
131
|
OrgSummary,
|
|
130
132
|
PendingInvite,
|
|
133
|
+
MyInvite,
|
|
131
134
|
SessionResponse,
|
|
132
135
|
} from "./lib/api";
|
package/src/lib/api.ts
CHANGED
|
@@ -85,10 +85,22 @@ async function get<T>(path: string): Promise<T> {
|
|
|
85
85
|
* reset (e.g. "switch identity"), clear the token from storage and
|
|
86
86
|
* call this again.
|
|
87
87
|
*/
|
|
88
|
-
export
|
|
89
|
-
if (typeof window === "undefined") return null;
|
|
88
|
+
export function ensureGuestSession(): Promise<SessionResponse | null> {
|
|
89
|
+
if (typeof window === "undefined") return Promise.resolve(null);
|
|
90
90
|
const existing = window.localStorage?.getItem(storageKey("token"));
|
|
91
|
-
if (existing) return null;
|
|
91
|
+
if (existing) return Promise.resolve(null);
|
|
92
|
+
// Concurrent callers (two <EnsureGuest> mounts, StrictMode's double
|
|
93
|
+
// effect) share one request, so the browser gets one guest identity.
|
|
94
|
+
if (guestInFlight) return guestInFlight;
|
|
95
|
+
guestInFlight = mintGuestSession().finally(() => {
|
|
96
|
+
guestInFlight = null;
|
|
97
|
+
});
|
|
98
|
+
return guestInFlight;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let guestInFlight: Promise<SessionResponse | null> | null = null;
|
|
102
|
+
|
|
103
|
+
async function mintGuestSession(): Promise<SessionResponse | null> {
|
|
92
104
|
try {
|
|
93
105
|
const res = await fetch(`${getBaseUrl()}/api/auth/guest`, {
|
|
94
106
|
method: "POST",
|
|
@@ -288,6 +300,35 @@ export async function acceptInvite(
|
|
|
288
300
|
);
|
|
289
301
|
}
|
|
290
302
|
|
|
303
|
+
/** A pending invite addressed to the signed-in user's email. */
|
|
304
|
+
export interface MyInvite {
|
|
305
|
+
id: string;
|
|
306
|
+
org_id: string;
|
|
307
|
+
email: string;
|
|
308
|
+
role: string;
|
|
309
|
+
invited_by: string;
|
|
310
|
+
created_at: number;
|
|
311
|
+
expires_at: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Pending invites for the signed-in user's verified email, across orgs.
|
|
316
|
+
* Rejects with `EMAIL_NOT_VERIFIED` until the User row has `emailVerified`.
|
|
317
|
+
*/
|
|
318
|
+
export async function myInvites(): Promise<MyInvite[]> {
|
|
319
|
+
return get<MyInvite[]>("/api/auth/invites/mine");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Accept an invite from {@link myInvites} without the emailed token. */
|
|
323
|
+
export async function acceptInviteById(
|
|
324
|
+
inviteId: string,
|
|
325
|
+
): Promise<{ org_id: string; role: string }> {
|
|
326
|
+
return post<{ org_id: string; role: string }>(
|
|
327
|
+
`/api/auth/invites/by-id/${encodeURIComponent(inviteId)}/accept`,
|
|
328
|
+
{},
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
291
332
|
export interface ConnectionAuthUrl {
|
|
292
333
|
url: string;
|
|
293
334
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Two <EnsureGuest> mounts (or StrictMode's double effect) called
|
|
2
|
+
// ensureGuestSession() at the same time; both saw no token and each minted
|
|
3
|
+
// its own guest, so the browser ended up with two identities and the second
|
|
4
|
+
// overwrote the first's token.
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
7
|
+
|
|
8
|
+
const stored = new Map<string, string>();
|
|
9
|
+
let sessionRefreshes = 0;
|
|
10
|
+
|
|
11
|
+
mock.module("@pylonsync/react", () => ({
|
|
12
|
+
db: {
|
|
13
|
+
sync: {
|
|
14
|
+
notifySessionChanged: async () => {
|
|
15
|
+
sessionRefreshes += 1;
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
getBaseUrl: () => "http://test.invalid",
|
|
20
|
+
storageKey: (k: string) => `pylon:test:${k}`,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const { ensureGuestSession } = await import("./api");
|
|
24
|
+
|
|
25
|
+
const realFetch = globalThis.fetch;
|
|
26
|
+
const realWindow = (globalThis as { window?: unknown }).window;
|
|
27
|
+
let guestRequests = 0;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
stored.clear();
|
|
31
|
+
sessionRefreshes = 0;
|
|
32
|
+
guestRequests = 0;
|
|
33
|
+
(globalThis as { window?: unknown }).window = {
|
|
34
|
+
localStorage: {
|
|
35
|
+
getItem: (k: string) => stored.get(k) ?? null,
|
|
36
|
+
setItem: (k: string, v: string) => void stored.set(k, v),
|
|
37
|
+
removeItem: (k: string) => void stored.delete(k),
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
globalThis.fetch = (async (url: string) => {
|
|
41
|
+
if (String(url).endsWith("/api/auth/guest")) {
|
|
42
|
+
guestRequests += 1;
|
|
43
|
+
const n = guestRequests;
|
|
44
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
45
|
+
return new Response(JSON.stringify({ token: `tok-${n}`, user_id: `guest_${n}` }), {
|
|
46
|
+
status: 200,
|
|
47
|
+
headers: { "content-type": "application/json" },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return new Response("{}", { status: 404 });
|
|
51
|
+
}) as typeof fetch;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
globalThis.fetch = realFetch;
|
|
56
|
+
(globalThis as { window?: unknown }).window = realWindow;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("ensureGuestSession", () => {
|
|
60
|
+
test("concurrent calls mint one guest session", async () => {
|
|
61
|
+
const [a, b, c] = await Promise.all([
|
|
62
|
+
ensureGuestSession(),
|
|
63
|
+
ensureGuestSession(),
|
|
64
|
+
ensureGuestSession(),
|
|
65
|
+
]);
|
|
66
|
+
expect(guestRequests).toBe(1);
|
|
67
|
+
expect(a?.user_id).toBe("guest_1");
|
|
68
|
+
expect(b?.user_id).toBe("guest_1");
|
|
69
|
+
expect(c?.user_id).toBe("guest_1");
|
|
70
|
+
expect(stored.get("pylon:test:token")).toBe("tok-1");
|
|
71
|
+
expect(sessionRefreshes).toBe(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a call after the session exists does not mint again", async () => {
|
|
75
|
+
await ensureGuestSession();
|
|
76
|
+
expect(await ensureGuestSession()).toBeNull();
|
|
77
|
+
expect(guestRequests).toBe(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a failed mint does not block a later retry", async () => {
|
|
81
|
+
globalThis.fetch = (async () => {
|
|
82
|
+
guestRequests += 1;
|
|
83
|
+
return new Response("{}", { status: 503 });
|
|
84
|
+
}) as unknown as typeof fetch;
|
|
85
|
+
expect(await ensureGuestSession()).toBeNull();
|
|
86
|
+
expect(await ensureGuestSession()).toBeNull();
|
|
87
|
+
expect(guestRequests).toBe(2);
|
|
88
|
+
});
|
|
89
|
+
});
|