@sentroy-co/client-sdk 2.13.7 → 2.13.8

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,344 @@
1
+ import {
2
+ type SentroyAuthUser,
3
+ type SignupResponse,
4
+ type LoginResponse,
5
+ type AuthTokensResponse,
6
+ } from "./types"
7
+ import { AuthHttp, type AuthHttpOptions } from "./http"
8
+
9
+ /**
10
+ * Browser-facing Sentroy Auth SDK — Firebase Auth tarzı session API.
11
+ *
12
+ * `apiKey` BROWSER'DA OLMAMALI; bu sınıf `apiKey`'i header'a koyacaktır.
13
+ * RP backend gerçek api-key tutar; browser'da end-user kendi access/refresh
14
+ * token'larıyla yaşar. Yine de DX için sınıf hem apiKey-less browser
15
+ * akışına (signup/login backend proxy üzerinden) hem apiKey'li server
16
+ * akışına (admin) tek tip API sunar — caller hangi mod'da olduğunu
17
+ * `SentroyAuthAdmin` (admin SDK, sunucu) vs `SentroyAuth` (browser SDK,
18
+ * apiKey-less) seçimiyle netleştirir.
19
+ *
20
+ * Storage: browser'da access + refresh `storage` adapter'a yazılır
21
+ * (default `localStorage`). Refresh expire'a 5dk kala arka planda
22
+ * yenilenir; fail olursa `onAuthStateChanged(null)` ve storage silinir.
23
+ *
24
+ * **Server-side rendering**: `typeof window === "undefined"` korumalı —
25
+ * Node ortamında `localStorage` yok, default `memory` storage'a düşer.
26
+ */
27
+
28
+ export type AuthStateChangeListener = (user: SentroyAuthUser | null) => void
29
+
30
+ export interface AuthStorageAdapter {
31
+ read(): { accessToken: string; refreshToken: string; user: SentroyAuthUser } | null
32
+ write(value: {
33
+ accessToken: string
34
+ refreshToken: string
35
+ user: SentroyAuthUser
36
+ }): void
37
+ clear(): void
38
+ }
39
+
40
+ const STORAGE_KEY_PREFIX = "sentroy.auth"
41
+
42
+ /**
43
+ * Base64URL → UTF-8 decode. Browser'da `atob` + manuel UTF-8 reconstruction;
44
+ * Node'da `Buffer.from(..., "base64url")`. Tek kod yolu, runtime detect.
45
+ */
46
+ function decodeBase64Url(s: string): string {
47
+ // Pad + standard base64
48
+ const padded = s.replace(/-/g, "+").replace(/_/g, "/")
49
+ const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4))
50
+ if (typeof atob === "function") {
51
+ const binary = atob(padded + pad)
52
+ // UTF-8 reconstruction (JWT claims yabancı karakter içerebilir)
53
+ const bytes = new Uint8Array(binary.length)
54
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
55
+ return new TextDecoder().decode(bytes)
56
+ }
57
+ // Node fallback
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ const B = (globalThis as any).Buffer
60
+ if (B) return B.from(padded + pad, "base64").toString("utf8")
61
+ throw new Error("No base64 decoder available")
62
+ }
63
+
64
+ function localStorageAdapter(projectSlug: string): AuthStorageAdapter {
65
+ if (typeof window === "undefined" || !window.localStorage) {
66
+ return memoryStorageAdapter()
67
+ }
68
+ const key = `${STORAGE_KEY_PREFIX}.${projectSlug}`
69
+ return {
70
+ read() {
71
+ try {
72
+ const raw = window.localStorage.getItem(key)
73
+ if (!raw) return null
74
+ return JSON.parse(raw) as {
75
+ accessToken: string
76
+ refreshToken: string
77
+ user: SentroyAuthUser
78
+ }
79
+ } catch {
80
+ return null
81
+ }
82
+ },
83
+ write(value) {
84
+ try {
85
+ window.localStorage.setItem(key, JSON.stringify(value))
86
+ } catch {
87
+ // QuotaExceeded, etc — degrade to memory silently.
88
+ }
89
+ },
90
+ clear() {
91
+ try {
92
+ window.localStorage.removeItem(key)
93
+ } catch {
94
+ // ignore
95
+ }
96
+ },
97
+ }
98
+ }
99
+
100
+ function memoryStorageAdapter(): AuthStorageAdapter {
101
+ let store: {
102
+ accessToken: string
103
+ refreshToken: string
104
+ user: SentroyAuthUser
105
+ } | null = null
106
+ return {
107
+ read: () => store,
108
+ write: (value) => {
109
+ store = value
110
+ },
111
+ clear: () => {
112
+ store = null
113
+ },
114
+ }
115
+ }
116
+
117
+ export interface SentroyAuthOptions extends AuthHttpOptions {
118
+ /** Token persistence stratejisi. Default `"localStorage"` browser'da,
119
+ * Node'da otomatik `"memory"`. Custom için adapter geçilebilir. */
120
+ storage?: "localStorage" | "memory" | AuthStorageAdapter
121
+ /** Background refresh tetikleme süresi (saniye, expiresIn altında).
122
+ * Default 300 (5dk). */
123
+ refreshSkew?: number
124
+ }
125
+
126
+ export class SentroyAuth {
127
+ private readonly http: AuthHttp
128
+ private readonly storage: AuthStorageAdapter
129
+ private readonly listeners = new Set<AuthStateChangeListener>()
130
+ private readonly refreshSkew: number
131
+ private refreshTimer: ReturnType<typeof setTimeout> | null = null
132
+ private currentUser: SentroyAuthUser | null = null
133
+
134
+ constructor(opts: SentroyAuthOptions) {
135
+ this.http = new AuthHttp(opts)
136
+ this.refreshSkew = opts.refreshSkew ?? 300
137
+
138
+ if (opts.storage === "memory") {
139
+ this.storage = memoryStorageAdapter()
140
+ } else if (
141
+ opts.storage &&
142
+ typeof opts.storage === "object" &&
143
+ "read" in opts.storage
144
+ ) {
145
+ this.storage = opts.storage
146
+ } else {
147
+ this.storage = localStorageAdapter(opts.projectSlug)
148
+ }
149
+
150
+ // Restore from storage on construct — `onAuthStateChanged` listener'ları
151
+ // henüz yok; ilk subscribe sırasında dispatch edilir.
152
+ const restored = this.storage.read()
153
+ if (restored) {
154
+ this.currentUser = restored.user
155
+ this.scheduleRefresh(this.estimateExpiry(restored.accessToken))
156
+ }
157
+ }
158
+
159
+ // ─── Public API ──────────────────────────────────────────────────────────
160
+
161
+ get user(): SentroyAuthUser | null {
162
+ return this.currentUser
163
+ }
164
+
165
+ get accessToken(): string | null {
166
+ return this.storage.read()?.accessToken ?? null
167
+ }
168
+
169
+ async signUp(input: {
170
+ email: string
171
+ password: string
172
+ displayName?: string
173
+ metadata?: Record<string, unknown>
174
+ }): Promise<SignupResponse> {
175
+ const res = await this.http.request<SignupResponse>("/signup", {
176
+ method: "POST",
177
+ json: input,
178
+ })
179
+ if (res.accessToken && res.refreshToken) {
180
+ this.persist({
181
+ accessToken: res.accessToken,
182
+ refreshToken: res.refreshToken,
183
+ user: res.user,
184
+ })
185
+ }
186
+ return res
187
+ }
188
+
189
+ async signIn(input: {
190
+ email: string
191
+ password: string
192
+ }): Promise<LoginResponse> {
193
+ const res = await this.http.request<LoginResponse>("/login", {
194
+ method: "POST",
195
+ json: input,
196
+ })
197
+ this.persist({
198
+ accessToken: res.accessToken,
199
+ refreshToken: res.refreshToken,
200
+ user: res.user,
201
+ })
202
+ return res
203
+ }
204
+
205
+ async signOut(): Promise<void> {
206
+ const restored = this.storage.read()
207
+ if (restored?.refreshToken) {
208
+ // Best-effort revoke — fail'ı sessizce yut (network problem
209
+ // sign-out'u bloklamasın).
210
+ await this.http
211
+ .request("/logout", {
212
+ method: "POST",
213
+ json: { refreshToken: restored.refreshToken },
214
+ })
215
+ .catch(() => {})
216
+ }
217
+ this.clearSession()
218
+ }
219
+
220
+ async sendPasswordReset(email: string): Promise<void> {
221
+ await this.http.request("/password-reset/request", {
222
+ method: "POST",
223
+ json: { email },
224
+ })
225
+ }
226
+
227
+ async verifyEmail(token: string): Promise<SentroyAuthUser> {
228
+ const res = await this.http.request<{ user: SentroyAuthUser }>(
229
+ "/verify-email",
230
+ { method: "POST", json: { token } },
231
+ )
232
+ if (this.currentUser && this.currentUser.id === res.user.id) {
233
+ const restored = this.storage.read()
234
+ if (restored) {
235
+ this.persist({ ...restored, user: res.user })
236
+ } else {
237
+ this.currentUser = res.user
238
+ this.notify()
239
+ }
240
+ }
241
+ return res.user
242
+ }
243
+
244
+ /**
245
+ * Subscription pattern — Firebase Auth uyumlu. Caller'ın hemen mevcut
246
+ * state'i alabilmesi için constructor'da restore edilen user
247
+ * subscribe sırasında bir kez dispatch edilir.
248
+ */
249
+ onAuthStateChanged(listener: AuthStateChangeListener): () => void {
250
+ this.listeners.add(listener)
251
+ // Microtask gibi async dispatch — caller's `useEffect` cleanup race
252
+ // problemlerini önler.
253
+ Promise.resolve().then(() => listener(this.currentUser))
254
+ return () => {
255
+ this.listeners.delete(listener)
256
+ }
257
+ }
258
+
259
+ // ─── Internals ───────────────────────────────────────────────────────────
260
+
261
+ private persist(value: {
262
+ accessToken: string
263
+ refreshToken: string
264
+ user: SentroyAuthUser
265
+ }): void {
266
+ this.storage.write(value)
267
+ this.currentUser = value.user
268
+ this.notify()
269
+ this.scheduleRefresh(this.estimateExpiry(value.accessToken))
270
+ }
271
+
272
+ private clearSession(): void {
273
+ this.storage.clear()
274
+ this.currentUser = null
275
+ if (this.refreshTimer) {
276
+ clearTimeout(this.refreshTimer)
277
+ this.refreshTimer = null
278
+ }
279
+ this.notify()
280
+ }
281
+
282
+ private notify(): void {
283
+ for (const l of this.listeners) {
284
+ try {
285
+ l(this.currentUser)
286
+ } catch {
287
+ // Listener hatası diğer subscriber'ları engellemesin.
288
+ }
289
+ }
290
+ }
291
+
292
+ /**
293
+ * JWT'nin `exp` claim'inden expiry'i tahmin et. Parsing fail ise
294
+ * 1 saat varsay (default access TTL). Refresh window: exp - skew.
295
+ *
296
+ * **Browser-safe**: `Buffer` Node'a özel, tarayıcıda yok. `atob`
297
+ * + URL-safe charset normalization ile decode ediyoruz.
298
+ */
299
+ private estimateExpiry(accessToken: string): number {
300
+ try {
301
+ const [, payloadB64] = accessToken.split(".")
302
+ const payload = JSON.parse(decodeBase64Url(payloadB64)) as {
303
+ exp?: number
304
+ }
305
+ if (typeof payload.exp === "number") {
306
+ return payload.exp * 1000
307
+ }
308
+ } catch {
309
+ // ignore — fall through to default
310
+ }
311
+ return Date.now() + 60 * 60 * 1000
312
+ }
313
+
314
+ private scheduleRefresh(expiryMs: number): void {
315
+ if (typeof window === "undefined") return // SSR'da auto-refresh yok
316
+ if (this.refreshTimer) clearTimeout(this.refreshTimer)
317
+ const fireAt = expiryMs - this.refreshSkew * 1000
318
+ const delay = Math.max(fireAt - Date.now(), 5_000)
319
+ this.refreshTimer = setTimeout(() => {
320
+ this.refresh().catch(() => {
321
+ // Refresh fail → session cleared, listener'lar null user görür
322
+ this.clearSession()
323
+ })
324
+ }, delay)
325
+ }
326
+
327
+ private async refresh(): Promise<void> {
328
+ const restored = this.storage.read()
329
+ if (!restored?.refreshToken) {
330
+ this.clearSession()
331
+ return
332
+ }
333
+ const res = await this.http.request<AuthTokensResponse>("/refresh", {
334
+ method: "POST",
335
+ json: { refreshToken: restored.refreshToken },
336
+ })
337
+ this.storage.write({
338
+ ...restored,
339
+ accessToken: res.accessToken,
340
+ refreshToken: res.refreshToken,
341
+ })
342
+ this.scheduleRefresh(this.estimateExpiry(res.accessToken))
343
+ }
344
+ }
@@ -0,0 +1,101 @@
1
+ import { SentroyAuthError } from "./types"
2
+
3
+ /**
4
+ * Auth-as-a-Service shared HTTP layer. Project's signup/login/refresh
5
+ * endpoints'iyle aynı format (JSON request + JSON response, 401/403/4xx
6
+ * tek-tip `{error, error_description}` shape).
7
+ *
8
+ * `apiKey` opsiyonel — browser SDK end-user akışında apiKey-less
9
+ * (server-only güvenlik), admin SDK her zaman set'li.
10
+ */
11
+
12
+ const DEFAULT_AUTH_BASE_URL = "https://auth.sentroy.com"
13
+
14
+ export interface AuthHttpOptions {
15
+ authBaseUrl?: string
16
+ projectSlug: string
17
+ apiKey?: string
18
+ /** Hata-fırlatma yerine raw response döndür — caller fine-grained handling. */
19
+ rawErrors?: boolean
20
+ }
21
+
22
+ export class AuthHttp {
23
+ readonly baseUrl: string
24
+ readonly projectSlug: string
25
+ readonly apiKey?: string
26
+
27
+ constructor(opts: AuthHttpOptions) {
28
+ this.baseUrl = (opts.authBaseUrl || DEFAULT_AUTH_BASE_URL).replace(
29
+ /\/+$/,
30
+ "",
31
+ )
32
+ this.projectSlug = opts.projectSlug
33
+ this.apiKey = opts.apiKey
34
+ }
35
+
36
+ url(path: string): string {
37
+ const p = path.startsWith("/") ? path : `/${path}`
38
+ return `${this.baseUrl}/api/v1/auth/${this.projectSlug}${p}`
39
+ }
40
+
41
+ async request<T>(
42
+ path: string,
43
+ init: RequestInit & {
44
+ json?: unknown
45
+ bearer?: string
46
+ } = {},
47
+ ): Promise<T> {
48
+ const headers = new Headers(init.headers)
49
+ headers.set("Accept", "application/json")
50
+ if (init.json !== undefined) {
51
+ headers.set("Content-Type", "application/json")
52
+ }
53
+ // Auth precedence: explicit `bearer` (user access token) > project `apiKey`.
54
+ // Caller chooses the one that fits the endpoint.
55
+ if (init.bearer) {
56
+ headers.set("Authorization", `Bearer ${init.bearer}`)
57
+ } else if (this.apiKey) {
58
+ headers.set("Authorization", `Bearer ${this.apiKey}`)
59
+ }
60
+
61
+ const res = await fetch(this.url(path), {
62
+ ...init,
63
+ headers,
64
+ body: init.json !== undefined ? JSON.stringify(init.json) : init.body,
65
+ })
66
+
67
+ let payload: unknown = null
68
+ const ct = res.headers.get("content-type") ?? ""
69
+ if (ct.includes("application/json")) {
70
+ try {
71
+ payload = await res.json()
72
+ } catch {
73
+ payload = null
74
+ }
75
+ }
76
+
77
+ if (!res.ok) {
78
+ const err =
79
+ payload && typeof payload === "object"
80
+ ? (payload as { error?: string; error_description?: string })
81
+ : {}
82
+ throw new SentroyAuthError(
83
+ err.error ?? "http_error",
84
+ err.error_description ?? `HTTP ${res.status}`,
85
+ res.status,
86
+ )
87
+ }
88
+
89
+ // Sentroy admin endpoints wrap in `{data}`; public endpoints sometimes
90
+ // too. SDK auto-unwraps when present so callers don't keep .data on
91
+ // every call.
92
+ if (
93
+ payload &&
94
+ typeof payload === "object" &&
95
+ "data" in (payload as Record<string, unknown>)
96
+ ) {
97
+ return (payload as { data: T }).data
98
+ }
99
+ return payload as T
100
+ }
101
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Sentroy Auth-as-a-Service — browser/server SDK entry.
3
+ *
4
+ * import { SentroyAuth } from "@sentroy-co/client-sdk/auth"
5
+ * const auth = new SentroyAuth({ projectSlug: "my-app" })
6
+ *
7
+ * For server admin operations (verifyIdToken, etc):
8
+ * import { SentroyAuthAdmin } from "@sentroy-co/client-sdk/auth/admin"
9
+ *
10
+ * For React integration:
11
+ * import { SentroyAuthProvider, useAuth } from "@sentroy-co/client-sdk/auth/react"
12
+ */
13
+
14
+ export { SentroyAuth } from "./client"
15
+ export type {
16
+ SentroyAuthOptions,
17
+ AuthStateChangeListener,
18
+ AuthStorageAdapter,
19
+ } from "./client"
20
+ export {
21
+ SentroyAuthError,
22
+ type SentroyAuthUser,
23
+ type SignupResponse,
24
+ type LoginResponse,
25
+ type AuthTokensResponse,
26
+ } from "./types"
@@ -0,0 +1,100 @@
1
+ "use client"
2
+
3
+ import {
4
+ createContext,
5
+ useContext,
6
+ useEffect,
7
+ useMemo,
8
+ useState,
9
+ type ReactNode,
10
+ } from "react"
11
+ import { SentroyAuth, type SentroyAuthOptions } from "../client"
12
+ import type { SentroyAuthUser } from "../types"
13
+
14
+ /**
15
+ * Sentroy Auth React integration.
16
+ *
17
+ * <SentroyAuthProvider projectSlug="my-app">
18
+ * <App />
19
+ * </SentroyAuthProvider>
20
+ *
21
+ * const { user, loading, signIn, signOut } = useAuth()
22
+ *
23
+ * Provider içeride tek bir `SentroyAuth` instance tutar (mount/unmount
24
+ * arasında stable), `onAuthStateChanged` ile React state'i senkron tutar.
25
+ * `loading` ilk render → restore tamam mı henüz değil ayrımı için.
26
+ */
27
+
28
+ interface AuthContextValue {
29
+ auth: SentroyAuth
30
+ user: SentroyAuthUser | null
31
+ /** True iken provider ilk state'i restore etmiş değil — UI'da
32
+ * "spinner" göster, "redirect to /login" tetikleme. */
33
+ loading: boolean
34
+ /** Convenience proxies — caller `auth.signIn(...)` yerine doğrudan
35
+ * `signIn(...)` kullanabilir. */
36
+ signIn: SentroyAuth["signIn"]
37
+ signUp: SentroyAuth["signUp"]
38
+ signOut: SentroyAuth["signOut"]
39
+ sendPasswordReset: SentroyAuth["sendPasswordReset"]
40
+ verifyEmail: SentroyAuth["verifyEmail"]
41
+ }
42
+
43
+ const AuthContext = createContext<AuthContextValue | null>(null)
44
+
45
+ export function SentroyAuthProvider({
46
+ children,
47
+ ...opts
48
+ }: SentroyAuthOptions & { children: ReactNode }) {
49
+ // Single instance — opts deep-compare'a girersek dependency drift'i
50
+ // restart'a yol açar. Caller `projectSlug` değiştirmemeli runtime'da.
51
+ const auth = useMemo(
52
+ () => new SentroyAuth(opts),
53
+ // eslint-disable-next-line react-hooks/exhaustive-deps
54
+ [opts.projectSlug, opts.authBaseUrl, opts.apiKey],
55
+ )
56
+ const [user, setUser] = useState<SentroyAuthUser | null>(auth.user)
57
+ const [loading, setLoading] = useState(true)
58
+
59
+ useEffect(() => {
60
+ const unsubscribe = auth.onAuthStateChanged((u) => {
61
+ setUser(u)
62
+ setLoading(false)
63
+ })
64
+ return unsubscribe
65
+ }, [auth])
66
+
67
+ const value = useMemo<AuthContextValue>(
68
+ () => ({
69
+ auth,
70
+ user,
71
+ loading,
72
+ signIn: (i) => auth.signIn(i),
73
+ signUp: (i) => auth.signUp(i),
74
+ signOut: () => auth.signOut(),
75
+ sendPasswordReset: (e) => auth.sendPasswordReset(e),
76
+ verifyEmail: (t) => auth.verifyEmail(t),
77
+ }),
78
+ [auth, user, loading],
79
+ )
80
+
81
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
82
+ }
83
+
84
+ export function useAuth(): AuthContextValue {
85
+ const ctx = useContext(AuthContext)
86
+ if (!ctx) {
87
+ throw new Error(
88
+ "useAuth must be used inside <SentroyAuthProvider> — wrap your app root.",
89
+ )
90
+ }
91
+ return ctx
92
+ }
93
+
94
+ /**
95
+ * Convenience: yalnızca current user istenirse. `loading` durumunda null
96
+ * dönerken bekleyebilirsin.
97
+ */
98
+ export function useUser(): SentroyAuthUser | null {
99
+ return useAuth().user
100
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Sentroy Auth-as-a-Service — SDK types.
3
+ *
4
+ * Public types are kept narrow on purpose: SDK shapes evolve with backend;
5
+ * caller code should depend on these names, not on hand-coded interfaces.
6
+ */
7
+
8
+ export interface SentroyAuthUser {
9
+ id: string
10
+ authProjectId: string
11
+ email: string
12
+ emailVerified: boolean
13
+ displayName: string | null
14
+ image: string | null
15
+ metadata: Record<string, unknown>
16
+ lastLoginAt: string | null
17
+ createdAt: string
18
+ updatedAt: string
19
+ }
20
+
21
+ export interface AuthTokensResponse {
22
+ accessToken: string
23
+ refreshToken: string
24
+ expiresIn: number
25
+ tokenType: "Bearer"
26
+ }
27
+
28
+ export interface SignupResponse {
29
+ user: SentroyAuthUser
30
+ /** Email verification gerekiyorsa undefined; aksi halde set. */
31
+ accessToken?: string
32
+ refreshToken?: string
33
+ expiresIn?: number
34
+ tokenType?: "Bearer"
35
+ emailVerificationRequired?: boolean
36
+ }
37
+
38
+ export interface LoginResponse {
39
+ user: SentroyAuthUser
40
+ accessToken: string
41
+ refreshToken: string
42
+ expiresIn: number
43
+ tokenType: "Bearer"
44
+ }
45
+
46
+ export interface AuthApiError {
47
+ error: string
48
+ error_description: string
49
+ }
50
+
51
+ export class SentroyAuthError extends Error {
52
+ readonly code: string
53
+ readonly status: number
54
+ constructor(code: string, message: string, status: number) {
55
+ super(message)
56
+ this.name = "SentroyAuthError"
57
+ this.code = code
58
+ this.status = status
59
+ }
60
+ }