@leisuresaas/react-native 0.6.2

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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/package.json +49 -0
  4. package/src/ads/ad-slot.tsx +131 -0
  5. package/src/ads/ad.tsx +91 -0
  6. package/src/ads/bundle-id.ts +9 -0
  7. package/src/ads/constants.ts +92 -0
  8. package/src/ads/context.tsx +76 -0
  9. package/src/ads/creative.tsx +105 -0
  10. package/src/ads/default-theme.ts +284 -0
  11. package/src/ads/hooks.ts +156 -0
  12. package/src/ads/index.ts +32 -0
  13. package/src/ads/layouts/image.tsx +101 -0
  14. package/src/ads/layouts/index.tsx +28 -0
  15. package/src/ads/layouts/shared.tsx +80 -0
  16. package/src/ads/layouts/text.tsx +77 -0
  17. package/src/ads/lineup-id.ts +6 -0
  18. package/src/ads/open-click.ts +61 -0
  19. package/src/ads/rotation/indicators-context.tsx +31 -0
  20. package/src/ads/rotation/indicators.tsx +22 -0
  21. package/src/ads/rotation/shell.tsx +98 -0
  22. package/src/ads/session-id.ts +18 -0
  23. package/src/ads/theme.ts +105 -0
  24. package/src/ads-ui.tsx +40 -0
  25. package/src/ads.ts +16 -0
  26. package/src/app-update/context.tsx +380 -0
  27. package/src/auth-errors.ts +50 -0
  28. package/src/auth-session.ts +129 -0
  29. package/src/auth-storage.ts +62 -0
  30. package/src/auth.tsx +249 -0
  31. package/src/dev.ts +16 -0
  32. package/src/drive/types.ts +62 -0
  33. package/src/drive.ts +355 -0
  34. package/src/errors.ts +11 -0
  35. package/src/gateway-url.ts +7 -0
  36. package/src/hosted-ui-link.ts +265 -0
  37. package/src/index.ts +161 -0
  38. package/src/platform-api-base.ts +72 -0
  39. package/src/platform.ts +16 -0
  40. package/src/public-ads.ts +105 -0
  41. package/src/public-app-config.ts +106 -0
  42. package/src/public-auth.ts +252 -0
  43. package/src/publishable-key.ts +19 -0
  44. package/src/push.ts +89 -0
  45. package/src/types.ts +123 -0
  46. package/src/userinfo.ts +34 -0
package/src/auth.tsx ADDED
@@ -0,0 +1,249 @@
1
+ import * as AuthSession from "expo-auth-session";
2
+ import * as WebBrowser from "expo-web-browser";
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ReactNode,
12
+ } from "react";
13
+
14
+ import { AuthLoginError, toAuthLoginError } from "./auth-errors";
15
+
16
+ export type { AuthLoginErrorCode } from "./auth-errors";
17
+ export { AuthLoginError } from "./auth-errors";
18
+ import { accessNeedsRefresh, refreshOAuthTokens } from "./auth-session";
19
+ import {
20
+ assertAuthStorageAllowed,
21
+ authDeleteItem,
22
+ authGetItem,
23
+ authSetItem,
24
+ isWebProduction,
25
+ } from "./auth-storage";
26
+ import { normalizeHostedUILocale, useHostedUIPasswordResetLink } from "./hosted-ui-link";
27
+ import type { AuthConfig } from "./types";
28
+
29
+ WebBrowser.maybeCompleteAuthSession();
30
+
31
+ const DEFAULT_SCOPES = ["openid", "profile"];
32
+ const DEFAULT_REFRESH_STORAGE_KEY = "leisuresaas_refresh_token";
33
+ const DEFAULT_TERMINAL = "mobile";
34
+
35
+ export type AuthContextValue = {
36
+ accessToken: string | null;
37
+ redirectUri: string;
38
+ loading: boolean;
39
+ /** Last login failure; cleared on success or logout. Prefer try/catch on `login()`. */
40
+ lastError: AuthLoginError | null;
41
+ login: () => Promise<void>;
42
+ logout: () => Promise<void>;
43
+ /** Returns a valid access token, refreshing when near expiry. */
44
+ resolveAccessToken: () => Promise<string | null>;
45
+ };
46
+
47
+ const AuthContext = createContext<AuthContextValue | null>(null);
48
+
49
+ export type AuthProviderProps = {
50
+ config: AuthConfig;
51
+ children: ReactNode;
52
+ };
53
+
54
+ export function AuthProvider({ config, children }: AuthProviderProps) {
55
+ const issuer = config.issuer.replace(/\/$/, "");
56
+ const storageKey = config.storageKey ?? "leisuresaas_access_token";
57
+ const refreshStorageKey = config.refreshStorageKey ?? DEFAULT_REFRESH_STORAGE_KEY;
58
+ const terminal = config.terminal ?? DEFAULT_TERMINAL;
59
+ // Any BCP 47 tag; invalid/empty→ omitted (or normalize to en when provided but invalid).
60
+ const uiLocale = config.locale?.trim() ? normalizeHostedUILocale(config.locale) : "";
61
+ const redirectUri = AuthSession.makeRedirectUri({
62
+ scheme: config.redirectScheme,
63
+ path: config.redirectPath ?? "auth/callback",
64
+ });
65
+ useHostedUIPasswordResetLink(config.handlePasswordResetLinks !== false, uiLocale || undefined);
66
+ const discovery = useMemo(
67
+ () => ({
68
+ authorizationEndpoint: `${issuer}/oauth/authorize`,
69
+ tokenEndpoint: `${issuer}/oauth/token`,
70
+ }),
71
+ [issuer],
72
+ );
73
+ const scopes = config.scopes ?? DEFAULT_SCOPES;
74
+
75
+ const [accessToken, setAccessToken] = useState<string | null>(null);
76
+ const [loading, setLoading] = useState(true);
77
+ const [lastError, setLastError] = useState<AuthLoginError | null>(null);
78
+ const refreshInFlight = useRef<Promise<string | null> | null>(null);
79
+
80
+ const persistTokens = useCallback(
81
+ async (access: string, refresh?: string) => {
82
+ assertAuthStorageAllowed();
83
+ await authSetItem(storageKey, access);
84
+ if (refresh) {
85
+ await authSetItem(refreshStorageKey, refresh);
86
+ }
87
+ setAccessToken(access);
88
+ },
89
+ [refreshStorageKey, storageKey],
90
+ );
91
+
92
+ const refreshStoredTokens = useCallback(async (): Promise<string | null> => {
93
+ if (refreshInFlight.current) {
94
+ return refreshInFlight.current;
95
+ }
96
+ const task = (async () => {
97
+ const refreshToken = (await authGetItem(refreshStorageKey))?.trim();
98
+ if (!refreshToken) {
99
+ return (await authGetItem(storageKey))?.trim() || null;
100
+ }
101
+ try {
102
+ const tokens = await refreshOAuthTokens(issuer, config.clientId, refreshToken);
103
+ await persistTokens(tokens.accessToken, tokens.refreshToken ?? refreshToken);
104
+ return tokens.accessToken;
105
+ } catch (err) {
106
+ console.warn("OAuth refresh failed", err);
107
+ return (await authGetItem(storageKey))?.trim() || null;
108
+ } finally {
109
+ refreshInFlight.current = null;
110
+ }
111
+ })();
112
+ refreshInFlight.current = task;
113
+ return task;
114
+ }, [config.clientId, issuer, persistTokens, refreshStorageKey, storageKey]);
115
+
116
+ const resolveAccessToken = useCallback(async (): Promise<string | null> => {
117
+ const current = (await authGetItem(storageKey))?.trim() || accessToken;
118
+ if (!current) {
119
+ return null;
120
+ }
121
+ if (!accessNeedsRefresh(current)) {
122
+ return current;
123
+ }
124
+ return refreshStoredTokens();
125
+ }, [accessToken, refreshStoredTokens, storageKey]);
126
+
127
+ useEffect(() => {
128
+ if (isWebProduction()) {
129
+ setAccessToken(null);
130
+ setLoading(false);
131
+ return;
132
+ }
133
+ (async () => {
134
+ const stored = (await authGetItem(storageKey))?.trim() || null;
135
+ if (!stored) {
136
+ setAccessToken(null);
137
+ setLoading(false);
138
+ return;
139
+ }
140
+ if (accessNeedsRefresh(stored)) {
141
+ const refreshed = await refreshStoredTokens();
142
+ setAccessToken(refreshed);
143
+ } else {
144
+ setAccessToken(stored);
145
+ }
146
+ setLoading(false);
147
+ })();
148
+ }, [refreshStoredTokens, storageKey]);
149
+
150
+ const [request, , promptAsync] = AuthSession.useAuthRequest(
151
+ {
152
+ clientId: config.clientId,
153
+ redirectUri,
154
+ responseType: AuthSession.ResponseType.Code,
155
+ usePKCE: true,
156
+ scopes,
157
+ extraParams: {
158
+ terminal,
159
+ ...(uiLocale ? { ui_locales: uiLocale } : {}),
160
+ },
161
+ },
162
+ discovery,
163
+ );
164
+
165
+ const login = useCallback(async () => {
166
+ assertAuthStorageAllowed();
167
+ setLastError(null);
168
+
169
+ function failLogin(code: AuthLoginError["code"], cause?: unknown): never {
170
+ const err = toAuthLoginError(code, cause);
171
+ setLastError(err);
172
+ throw err;
173
+ }
174
+
175
+ if (!request) {
176
+ failLogin("not_ready");
177
+ }
178
+
179
+ setLoading(true);
180
+ try {
181
+ const result = await promptAsync();
182
+ if (result.type === "cancel" || result.type === "dismiss") {
183
+ failLogin("cancelled");
184
+ }
185
+ if (result.type !== "success") {
186
+ failLogin("no_code", result);
187
+ }
188
+ const code = result.params.code?.trim();
189
+ if (!code) {
190
+ failLogin("no_code");
191
+ }
192
+
193
+ let tokenRes: AuthSession.TokenResponse;
194
+ try {
195
+ tokenRes = await AuthSession.exchangeCodeAsync(
196
+ {
197
+ clientId: config.clientId,
198
+ code,
199
+ redirectUri,
200
+ extraParams: { code_verifier: request.codeVerifier ?? "" },
201
+ },
202
+ discovery,
203
+ );
204
+ } catch (err) {
205
+ failLogin("exchange_failed", err);
206
+ }
207
+
208
+ const token = tokenRes.accessToken?.trim();
209
+ if (!token) {
210
+ failLogin("exchange_failed", new Error("missing access_token"));
211
+ }
212
+ await persistTokens(token, tokenRes.refreshToken?.trim());
213
+ setLastError(null);
214
+ } finally {
215
+ setLoading(false);
216
+ }
217
+ }, [config.clientId, discovery, persistTokens, promptAsync, redirectUri, request]);
218
+
219
+ const logout = useCallback(async () => {
220
+ await authDeleteItem(storageKey);
221
+ await authDeleteItem(refreshStorageKey);
222
+ setAccessToken(null);
223
+ setLastError(null);
224
+ }, [refreshStorageKey, storageKey]);
225
+
226
+ const value = useMemo(
227
+ () => ({ accessToken, redirectUri, loading, lastError, login, logout, resolveAccessToken }),
228
+ [accessToken, redirectUri, loading, lastError, login, logout, resolveAccessToken],
229
+ );
230
+
231
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
232
+ }
233
+
234
+ export function useAuth(): AuthContextValue {
235
+ const ctx = useContext(AuthContext);
236
+ if (!ctx) {
237
+ throw new Error("useAuth must be used within AuthProvider");
238
+ }
239
+ return ctx;
240
+ }
241
+
242
+ /** @deprecated Use AuthProvider */
243
+ export const LeisureSaasAuthProvider = AuthProvider;
244
+ /** @deprecated Use useAuth */
245
+ export const useLeisureSaasAuth = useAuth;
246
+ /** @deprecated Use AuthContextValue */
247
+ export type LeisureSaasAuthContextValue = AuthContextValue;
248
+ /** @deprecated Use AuthProviderProps */
249
+ export type LeisureSaasAuthProviderProps = AuthProviderProps;
package/src/dev.ts ADDED
@@ -0,0 +1,16 @@
1
+ /** Dev-mode Apple signed_transaction for store.verification_mode=dev. */
2
+ export function devAppleSignedTransaction(storeProductId: string, suffix?: string): string {
3
+ const tx = suffix?.trim() || `expo-${Date.now()}`;
4
+ return `dev:${storeProductId.trim()}:${tx}`;
5
+ }
6
+
7
+ /** Dev-mode Google purchase_token for store.verification_mode=dev. */
8
+ export function devGooglePurchaseToken(suffix?: string): string {
9
+ return `dev-token-${suffix?.trim() || Date.now()}`;
10
+ }
11
+
12
+ /** Dev-mode push device token for notification integration smoke tests. */
13
+ export function devDeviceToken(platform: "ios" | "android", suffix?: string): string {
14
+ const id = suffix?.trim() || String(Date.now());
15
+ return `dev:${platform}:${id}`;
16
+ }
@@ -0,0 +1,62 @@
1
+ export type FsNode = {
2
+ id: string;
3
+ product_id: string;
4
+ tenant_id: string;
5
+ owner_subject: string;
6
+ parent_id: string;
7
+ name: string;
8
+ kind: "folder" | "file" | string;
9
+ object_id: string;
10
+ content_type: string;
11
+ size: number;
12
+ status: "uploading" | "active" | string;
13
+ created_at_unix: number;
14
+ updated_at_unix: number;
15
+ };
16
+
17
+ export type FsUploadSessionResult = {
18
+ node: FsNode;
19
+ upload_url: string;
20
+ upload_headers?: Record<string, string>;
21
+ expires_unix: number;
22
+ expires_at: string;
23
+ };
24
+
25
+ export type FsContentURLResult = {
26
+ signed_url: string;
27
+ expires_unix: number;
28
+ expires_at: string;
29
+ intent: string;
30
+ };
31
+
32
+ export type FsContentURLIntent = "preview" | "download" | "";
33
+
34
+ /** Client-side upload progress (bytes PUT never go through storage HTTP). */
35
+ export type UploadProgress = {
36
+ loaded: number;
37
+ total: number;
38
+ /**
39
+ * 0..1 — byte transfer to object store only.
40
+ * Reaches 1 when the request body has been fully sent (not when Finish completes).
41
+ */
42
+ ratio: number;
43
+ /**
44
+ * put — sending bytes (ratio 0→1)
45
+ * storing — body sent; waiting for object store 2xx (ratio stays 1)
46
+ * finishing — store OK; platform Finish in flight (ratio stays 1)
47
+ * done — Finish succeeded
48
+ */
49
+ phase?: "put" | "storing" | "finishing" | "done";
50
+ };
51
+
52
+ /** Tenant Drive occupancy vs Plan byte Limit (same ledger as Complete). */
53
+ export type FsStorageUsage = {
54
+ product_id: string;
55
+ tenant_id: string;
56
+ metric_key: string;
57
+ bytes_used: number;
58
+ objects_used: number;
59
+ bytes_limit: number;
60
+ /** null when bytes_limit < 0 (unlimited) */
61
+ bytes_remaining: number | null;
62
+ };
package/src/drive.ts ADDED
@@ -0,0 +1,355 @@
1
+ import { LeisureSaasHttpError } from "./errors";
2
+ import { appBundleId } from "./ads/bundle-id";
3
+ import { mobilePlatform } from "./platform";
4
+ import { applyPublishableKeyHeaders } from "./publishable-key";
5
+ import type { MobilePlatform } from "./types";
6
+ import type {
7
+ FsContentURLIntent,
8
+ FsContentURLResult,
9
+ FsNode,
10
+ FsStorageUsage,
11
+ FsUploadSessionResult,
12
+ UploadProgress,
13
+ } from "./drive/types";
14
+
15
+ export type {
16
+ FsContentURLIntent,
17
+ FsContentURLResult,
18
+ FsNode,
19
+ FsStorageUsage,
20
+ FsUploadSessionResult,
21
+ UploadProgress,
22
+ } from "./drive/types";
23
+
24
+ /** Max body size for uploadFsFile auto PUT (matches Go SDK). */
25
+ export const DRIVE_UPLOAD_MAX_AUTO_PUT_BYTES = 20 * 1024 * 1024;
26
+
27
+ export type DriveRequestContext = {
28
+ gatewayUrl: string;
29
+ publishableKey: string;
30
+ /** Required user OAuth access token. */
31
+ accessToken: string;
32
+ platform?: MobilePlatform | "web";
33
+ bundleId?: string;
34
+ origin?: string;
35
+ };
36
+
37
+ export type UploadProgressHandler = (progress: UploadProgress) => void;
38
+
39
+ function trimSlash(url: string): string {
40
+ return url.replace(/\/$/, "");
41
+ }
42
+
43
+ function resolvePlatform(ctx: DriveRequestContext): MobilePlatform | "web" {
44
+ return ctx.platform ?? mobilePlatform();
45
+ }
46
+
47
+ function driveHeaders(ctx: DriveRequestContext): Record<string, string> {
48
+ const platform = resolvePlatform(ctx);
49
+ const token = ctx.accessToken.trim();
50
+ if (!token) {
51
+ throw new LeisureSaasHttpError(401, "accessToken is required for Drive User Plane");
52
+ }
53
+ const headers: Record<string, string> = {
54
+ Accept: "application/json",
55
+ Authorization: `Bearer ${token}`,
56
+ "X-Client-Platform": platform,
57
+ };
58
+ applyPublishableKeyHeaders(headers, ctx.publishableKey);
59
+ if (platform === "web") {
60
+ if (ctx.origin?.trim()) {
61
+ headers.Origin = ctx.origin.trim();
62
+ }
63
+ } else {
64
+ const bundle = (ctx.bundleId ?? appBundleId())?.trim();
65
+ if (bundle) {
66
+ headers["X-Ads-Bundle-Id"] = bundle;
67
+ }
68
+ }
69
+ return headers;
70
+ }
71
+
72
+ async function driveRequest<T>(
73
+ ctx: DriveRequestContext,
74
+ method: "GET" | "POST" | "PATCH" | "DELETE",
75
+ path: string,
76
+ body?: unknown,
77
+ ): Promise<T> {
78
+ const base = `${trimSlash(ctx.gatewayUrl)}/v1/user/storage`;
79
+ const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
80
+ const headers = driveHeaders(ctx);
81
+ let payload: string | undefined;
82
+ if (body !== undefined) {
83
+ headers["Content-Type"] = "application/json";
84
+ payload = JSON.stringify(body);
85
+ }
86
+ const res = await fetch(url, { method, headers, body: payload });
87
+ const text = await res.text();
88
+ if (!res.ok) {
89
+ throw new LeisureSaasHttpError(res.status, text.trim());
90
+ }
91
+ if (res.status === 204 || !text) {
92
+ return {} as T;
93
+ }
94
+ return JSON.parse(text) as T;
95
+ }
96
+
97
+ export async function listFsNodes(ctx: DriveRequestContext, parentId = ""): Promise<FsNode[]> {
98
+ const q = parentId.trim() ? `?parent_id=${encodeURIComponent(parentId.trim())}` : "";
99
+ const resp = await driveRequest<{ nodes?: FsNode[] }>(ctx, "GET", `/fs/nodes${q}`);
100
+ return resp.nodes ?? [];
101
+ }
102
+
103
+ /** Tenant storage occupancy vs Plan Limit (User Plane GET /usage). */
104
+ export async function getFsStorageUsage(ctx: DriveRequestContext): Promise<FsStorageUsage> {
105
+ return driveRequest<FsStorageUsage>(ctx, "GET", "/usage");
106
+ }
107
+
108
+ export async function getFsNode(ctx: DriveRequestContext, id: string): Promise<FsNode> {
109
+ const nodeId = id.trim();
110
+ if (!nodeId) throw new LeisureSaasHttpError(400, "id is required");
111
+ return driveRequest<FsNode>(ctx, "GET", `/fs/nodes/${encodeURIComponent(nodeId)}`);
112
+ }
113
+
114
+ export async function createFsFolder(
115
+ ctx: DriveRequestContext,
116
+ input: { name: string; parentId?: string },
117
+ ): Promise<FsNode> {
118
+ const body: Record<string, string> = { name: input.name };
119
+ if (input.parentId?.trim()) body.parent_id = input.parentId.trim();
120
+ return driveRequest<FsNode>(ctx, "POST", "/fs/folders", body);
121
+ }
122
+
123
+ export async function renameFsNode(ctx: DriveRequestContext, id: string, name: string): Promise<FsNode> {
124
+ const nodeId = id.trim();
125
+ if (!nodeId) throw new LeisureSaasHttpError(400, "id is required");
126
+ return driveRequest<FsNode>(ctx, "PATCH", `/fs/nodes/${encodeURIComponent(nodeId)}`, { name });
127
+ }
128
+
129
+ export async function moveFsNode(
130
+ ctx: DriveRequestContext,
131
+ id: string,
132
+ parentId = "",
133
+ ): Promise<FsNode> {
134
+ const nodeId = id.trim();
135
+ if (!nodeId) throw new LeisureSaasHttpError(400, "id is required");
136
+ const body: Record<string, string> = {};
137
+ if (parentId.trim()) body.parent_id = parentId.trim();
138
+ return driveRequest<FsNode>(ctx, "POST", `/fs/nodes/${encodeURIComponent(nodeId)}/move`, body);
139
+ }
140
+
141
+ export async function deleteFsNode(
142
+ ctx: DriveRequestContext,
143
+ id: string,
144
+ recursive = false,
145
+ ): Promise<void> {
146
+ const nodeId = id.trim();
147
+ if (!nodeId) throw new LeisureSaasHttpError(400, "id is required");
148
+ let path = `/fs/nodes/${encodeURIComponent(nodeId)}`;
149
+ if (recursive) path += "?recursive=true";
150
+ await driveRequest(ctx, "DELETE", path);
151
+ }
152
+
153
+ export async function createFsUploadSession(
154
+ ctx: DriveRequestContext,
155
+ input: { name: string; contentType: string; size: number; parentId?: string },
156
+ ): Promise<FsUploadSessionResult> {
157
+ // On active-name conflict the server auto-renames; use result.node.name as final display name.
158
+ const body: Record<string, unknown> = {
159
+ name: input.name,
160
+ content_type: input.contentType,
161
+ size: input.size,
162
+ };
163
+ if (input.parentId?.trim()) body.parent_id = input.parentId.trim();
164
+ return driveRequest<FsUploadSessionResult>(ctx, "POST", "/fs/files/upload-session", body);
165
+ }
166
+
167
+ export async function finishFsUploadSession(ctx: DriveRequestContext, nodeId: string): Promise<FsNode> {
168
+ const id = nodeId.trim();
169
+ if (!id) throw new LeisureSaasHttpError(400, "node id is required");
170
+ return driveRequest<FsNode>(ctx, "POST", `/fs/files/upload-session/${encodeURIComponent(id)}/finish`, {});
171
+ }
172
+
173
+ export async function createFsContentURL(
174
+ ctx: DriveRequestContext,
175
+ id: string,
176
+ input?: { intent?: FsContentURLIntent; ttlSeconds?: number },
177
+ ): Promise<FsContentURLResult> {
178
+ const nodeId = id.trim();
179
+ if (!nodeId) throw new LeisureSaasHttpError(400, "id is required");
180
+ const body: Record<string, unknown> = {};
181
+ if (input?.intent) body.intent = input.intent;
182
+ if (input?.ttlSeconds && input.ttlSeconds > 0) body.ttl_seconds = input.ttlSeconds;
183
+ return driveRequest<FsContentURLResult>(
184
+ ctx,
185
+ "POST",
186
+ `/fs/nodes/${encodeURIComponent(nodeId)}/content-url`,
187
+ body,
188
+ );
189
+ }
190
+
191
+ function reportProgress(
192
+ onProgress: UploadProgressHandler | undefined,
193
+ loaded: number,
194
+ total: number,
195
+ phase?: UploadProgress["phase"],
196
+ ) {
197
+ if (!onProgress) return;
198
+ const t = total > 0 ? total : 0;
199
+ const ratio = t > 0 ? Math.min(1, Math.max(0, loaded / t)) : 0;
200
+ onProgress({ loaded, total: t, ratio, phase });
201
+ }
202
+
203
+ /** Monotonic byte progress reporter. */
204
+ function createProgressReporter(onProgress: UploadProgressHandler | undefined, total: number) {
205
+ let maxLoaded = 0;
206
+ return {
207
+ report(loaded: number, opts?: { complete?: boolean; phase?: UploadProgress["phase"] }) {
208
+ if (!onProgress) return;
209
+ const t = total > 0 ? total : 0;
210
+ let next = Math.max(0, loaded);
211
+ if (next < maxLoaded) next = maxLoaded;
212
+ if (opts?.complete && t > 0) next = t;
213
+ maxLoaded = next;
214
+ reportProgress(onProgress, next, t, opts?.phase ?? "put");
215
+ },
216
+ };
217
+ }
218
+
219
+ function asUploadBody(body: ArrayBuffer | Blob | Uint8Array): BodyInit {
220
+ if (body instanceof Uint8Array) return body as unknown as BodyInit;
221
+ if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) {
222
+ return new Uint8Array(body) as unknown as BodyInit;
223
+ }
224
+ return body as BodyInit;
225
+ }
226
+
227
+ /**
228
+ * PUT to Presigned upload_url (object store). Progress is client-local; body never hits storage HTTP.
229
+ * ratio → 1 when the body has been fully sent; then phase "storing" until object store returns 2xx.
230
+ * If the bar resets many times, check that upload_url has no HTTP redirects.
231
+ */
232
+ export async function putFsUploadURL(
233
+ uploadURL: string,
234
+ uploadHeaders: Record<string, string> | undefined,
235
+ contentType: string,
236
+ body: ArrayBuffer | Blob | Uint8Array,
237
+ opts?: { size?: number; onProgress?: UploadProgressHandler },
238
+ ): Promise<void> {
239
+ const headers: Record<string, string> = { ...(uploadHeaders ?? {}) };
240
+ if (!headers["Content-Type"] && !headers["content-type"] && contentType) {
241
+ headers["Content-Type"] = contentType;
242
+ }
243
+ let total = opts?.size ?? 0;
244
+ if (total <= 0) {
245
+ if (typeof Blob !== "undefined" && body instanceof Blob) total = body.size;
246
+ else if (body instanceof Uint8Array) total = body.byteLength;
247
+ else if (typeof ArrayBuffer !== "undefined" && body instanceof ArrayBuffer) total = body.byteLength;
248
+ }
249
+ const progress = createProgressReporter(opts?.onProgress, total);
250
+
251
+ if (opts?.onProgress && typeof XMLHttpRequest !== "undefined") {
252
+ await new Promise<void>((resolve, reject) => {
253
+ const xhr = new XMLHttpRequest();
254
+ xhr.open("PUT", uploadURL);
255
+ for (const [k, v] of Object.entries(headers)) {
256
+ if (v != null && v !== "") xhr.setRequestHeader(k, v);
257
+ }
258
+ xhr.upload.onprogress = (ev) => {
259
+ progress.report(ev.loaded, { phase: "put" });
260
+ };
261
+ // Body fully handed to the stack — bar at 100%; wait for object-store 2xx under "storing".
262
+ xhr.upload.onload = () => {
263
+ progress.report(total > 0 ? total : 1, { complete: true, phase: "storing" });
264
+ };
265
+ xhr.onload = () => {
266
+ if (xhr.status >= 200 && xhr.status < 300) {
267
+ progress.report(total > 0 ? total : 1, { complete: true, phase: "storing" });
268
+ resolve();
269
+ return;
270
+ }
271
+ reject(new LeisureSaasHttpError(xhr.status, (xhr.responseText || "upload PUT failed").trim()));
272
+ };
273
+ xhr.onerror = () => reject(new LeisureSaasHttpError(0, "upload PUT network error"));
274
+ xhr.onabort = () => reject(new LeisureSaasHttpError(0, "upload PUT aborted"));
275
+ progress.report(0, { phase: "put" });
276
+ xhr.send(asUploadBody(body) as XMLHttpRequestBodyInit);
277
+ });
278
+ return;
279
+ }
280
+
281
+ // fetch: no body-sent signal — jump to storing only after 2xx (put+storing collapsed).
282
+ progress.report(0, { phase: "put" });
283
+ const res = await fetch(uploadURL, {
284
+ method: "PUT",
285
+ headers,
286
+ body: asUploadBody(body),
287
+ redirect: "manual",
288
+ });
289
+ if (res.type === "opaqueredirect" || (res.status >= 300 && res.status < 400)) {
290
+ throw new LeisureSaasHttpError(
291
+ res.status || 0,
292
+ "upload PUT redirected; set object_store public/client endpoint to the final S3 API host with no redirects",
293
+ );
294
+ }
295
+ if (!res.ok) {
296
+ const text = await res.text().catch(() => "");
297
+ throw new LeisureSaasHttpError(res.status, text.trim() || "upload PUT failed");
298
+ }
299
+ progress.report(total > 0 ? total : 1, { complete: true, phase: "storing" });
300
+ }
301
+
302
+ /** CreateFsUploadSession → PUT upload_url → FinishFsUploadSession (≤ 20 MiB). */
303
+ export async function uploadFsFile(
304
+ ctx: DriveRequestContext,
305
+ input: {
306
+ name: string;
307
+ contentType: string;
308
+ size: number;
309
+ body: ArrayBuffer | Blob | Uint8Array;
310
+ parentId?: string;
311
+ /**
312
+ * put: ratio 0→1 as bytes send; storing: wait for object-store 2xx (ratio 1);
313
+ * finishing: platform Finish; done: success. Use phase for copy; bar only tracks put.
314
+ */
315
+ onProgress?: UploadProgressHandler;
316
+ },
317
+ ): Promise<FsNode> {
318
+ if (input.size <= 0) {
319
+ throw new LeisureSaasHttpError(400, "size must be positive");
320
+ }
321
+ if (input.size > DRIVE_UPLOAD_MAX_AUTO_PUT_BYTES) {
322
+ throw new LeisureSaasHttpError(
323
+ 400,
324
+ `body exceeds max auto-PUT size (${DRIVE_UPLOAD_MAX_AUTO_PUT_BYTES} bytes)`,
325
+ );
326
+ }
327
+ const sess = await createFsUploadSession(ctx, {
328
+ name: input.name,
329
+ contentType: input.contentType,
330
+ size: input.size,
331
+ parentId: input.parentId,
332
+ });
333
+ await putFsUploadURL(sess.upload_url, sess.upload_headers, input.contentType, input.body, {
334
+ size: input.size,
335
+ onProgress: input.onProgress,
336
+ });
337
+ if (input.onProgress) {
338
+ input.onProgress({
339
+ loaded: input.size,
340
+ total: input.size,
341
+ ratio: 1,
342
+ phase: "finishing",
343
+ });
344
+ }
345
+ const node = await finishFsUploadSession(ctx, sess.node.id);
346
+ if (input.onProgress) {
347
+ input.onProgress({
348
+ loaded: input.size,
349
+ total: input.size,
350
+ ratio: 1,
351
+ phase: "done",
352
+ });
353
+ }
354
+ return node;
355
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,11 @@
1
+ export class LeisureSaasHttpError extends Error {
2
+ readonly statusCode: number;
3
+ readonly body: string;
4
+
5
+ constructor(statusCode: number, body: string) {
6
+ super(body || `HTTP ${statusCode}`);
7
+ this.name = "LeisureSaasHttpError";
8
+ this.statusCode = statusCode;
9
+ this.body = body;
10
+ }
11
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Platform API base for Public Ads / App Config (not OAuth).
3
+ * Never fall back to EXPO_PUBLIC_OAUTH_ISSUER — that may be a Hosted UI login host.
4
+ */
5
+ export function resolveGatewayUrlFromEnv(env: Record<string, string | undefined> = process.env): string {
6
+ return env.EXPO_PUBLIC_GATEWAY_URL?.trim() || "";
7
+ }