@edgespark/web 0.0.1-beta.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.
package/README.md ADDED
@@ -0,0 +1,130 @@
1
+ # `@edgespark/web`
2
+
3
+ Parallel rewrite of the EdgeSpark browser SDK.
4
+
5
+ This package is intentionally:
6
+
7
+ - browser-only
8
+ - same-origin only
9
+ - cookie-session only
10
+ - zero-config
11
+ - optimized for agent-written code
12
+
13
+ ## Use in this repo
14
+
15
+ ```bash
16
+ pnpm install
17
+ pnpm --filter @edgespark/web build
18
+ ```
19
+
20
+ ## Quick start
21
+
22
+ ```ts
23
+ import { createEdgeSpark } from "@edgespark/web";
24
+ import "@edgespark/web/styles.css";
25
+
26
+ const es = createEdgeSpark();
27
+ const container = document.getElementById("auth")!;
28
+
29
+ es.authUI.mount(container, {
30
+ redirectTo: "/dashboard",
31
+ });
32
+ ```
33
+
34
+ ## Public surface
35
+
36
+ ```ts
37
+ const es = createEdgeSpark();
38
+ const container = document.getElementById("auth")!;
39
+
40
+ es.auth;
41
+ es.api.fetch("/api/todos");
42
+ es.authUI.mount(container, { redirectTo: "/dashboard" });
43
+ ```
44
+
45
+ ### `auth`
46
+
47
+ `es.auth` is the better-auth client, with two small EdgeSpark additions:
48
+
49
+ ```ts
50
+ const session = await es.auth.requireSession();
51
+ const unsubscribe = es.auth.onSessionChange((session) => {
52
+ console.log(session?.user.email ?? "signed out");
53
+ });
54
+ ```
55
+
56
+ Use the upstream better-auth auth methods directly:
57
+
58
+ ```ts
59
+ await es.auth.signIn.email({ email, password });
60
+ await es.auth.signUp.email({ name, email, password });
61
+ await es.auth.signOut();
62
+ await es.auth.getSession();
63
+ ```
64
+
65
+ ### `api`
66
+
67
+ `es.api.fetch(...)` is the same-origin fetch wrapper for authenticated app requests.
68
+
69
+ ```ts
70
+ const response = await es.api.fetch("/api/todos");
71
+ const todos = await response.json();
72
+ ```
73
+
74
+ The wrapper always uses cookie credentials, rejects cross-origin URLs, and does not accept a `credentials` override in its `init` type.
75
+
76
+ ### `authUI`
77
+
78
+ The managed auth UI supports exactly two post-auth strategies.
79
+
80
+ #### Redirect mode
81
+
82
+ ```ts
83
+ const container = document.getElementById("auth")!;
84
+
85
+ const ui = es.authUI.mount(container, {
86
+ redirectTo: "/dashboard",
87
+ });
88
+ ```
89
+
90
+ #### Controlled mode
91
+
92
+ ```ts
93
+ const container = document.getElementById("auth")!;
94
+
95
+ const ui = es.authUI.mount(container, {
96
+ onSuccess(event) {
97
+ if (event.action === "password-reset") {
98
+ return;
99
+ }
100
+
101
+ window.location.assign("/dashboard");
102
+ },
103
+ });
104
+ ```
105
+
106
+ `ui.destroy()` removes the mounted UI and clears timers/listeners.
107
+
108
+ Password reset has one special rule:
109
+
110
+ - in controlled mode, `onSuccess({ action: "password-reset" })` fires and the caller owns the next step
111
+ - in redirect mode, the UI returns to sign-in with a success notice instead of redirecting to an authenticated page
112
+
113
+ ## Flows covered by the managed UI
114
+
115
+ - email/password sign-in
116
+ - email/password sign-up
117
+ - OAuth sign-in
118
+ - email verification instructions + resend
119
+ - forgot password
120
+ - reset password with email OTP
121
+
122
+ The forgot-password path only appears when the project config enables email-OTP password reset.
123
+
124
+ ## Design notes
125
+
126
+ - `createEdgeSpark()` takes no options
127
+ - current page origin is used internally
128
+ - auth base path is internal
129
+ - credentials mode is internal
130
+ - local dev with separate web/backend ports should use a dev proxy so the browser still sees same-origin requests
@@ -0,0 +1,245 @@
1
+ [data-edgespark-auth] {
2
+ --edgespark-auth-bg: #ffffff;
3
+ --edgespark-auth-bg-muted: #f7f7f8;
4
+ --edgespark-auth-border: rgba(15, 23, 42, 0.08);
5
+ --edgespark-auth-text: #111827;
6
+ --edgespark-auth-text-muted: #6b7280;
7
+ --edgespark-auth-primary: #0f766e;
8
+ --edgespark-auth-primary-hover: #115e59;
9
+ --edgespark-auth-error: #b91c1c;
10
+ --edgespark-auth-info: #0f766e;
11
+ --edgespark-auth-radius-card: 20px;
12
+ --edgespark-auth-radius-control: 12px;
13
+ --edgespark-auth-shadow: 0 24px 60px rgba(15, 23, 42, 0.08);
14
+ --edgespark-auth-font: "IBM Plex Sans", "Inter", ui-sans-serif, system-ui, sans-serif;
15
+ color: var(--edgespark-auth-text);
16
+ font-family: var(--edgespark-auth-font);
17
+ }
18
+
19
+ [data-edgespark-auth] *,
20
+ [data-edgespark-auth] *::before,
21
+ [data-edgespark-auth] *::after {
22
+ box-sizing: border-box;
23
+ }
24
+
25
+ .edgespark-auth__shell {
26
+ width: 100%;
27
+ }
28
+
29
+ .edgespark-auth__card {
30
+ display: grid;
31
+ gap: 16px;
32
+ width: 100%;
33
+ max-width: 420px;
34
+ padding: 24px;
35
+ border: 1px solid var(--edgespark-auth-border);
36
+ border-radius: var(--edgespark-auth-radius-card);
37
+ background: linear-gradient(180deg, #ffffff 0%, #fcfcfc 100%);
38
+ box-shadow: var(--edgespark-auth-shadow);
39
+ }
40
+
41
+ .edgespark-auth__section,
42
+ .edgespark-auth__form,
43
+ .edgespark-auth__stack {
44
+ display: grid;
45
+ gap: 14px;
46
+ }
47
+
48
+ .edgespark-auth__header {
49
+ display: grid;
50
+ gap: 8px;
51
+ }
52
+
53
+ .edgespark-auth__title {
54
+ margin: 0;
55
+ font-size: 1.75rem;
56
+ line-height: 1.1;
57
+ font-weight: 700;
58
+ letter-spacing: -0.03em;
59
+ }
60
+
61
+ .edgespark-auth__copy,
62
+ .edgespark-auth__meta {
63
+ margin: 0;
64
+ color: var(--edgespark-auth-text-muted);
65
+ font-size: 0.95rem;
66
+ line-height: 1.5;
67
+ }
68
+
69
+ .edgespark-auth__field {
70
+ display: grid;
71
+ gap: 8px;
72
+ }
73
+
74
+ .edgespark-auth__label {
75
+ font-size: 0.9rem;
76
+ font-weight: 600;
77
+ }
78
+
79
+ .edgespark-auth__input {
80
+ width: 100%;
81
+ min-height: 48px;
82
+ padding: 0 14px;
83
+ border: 1px solid var(--edgespark-auth-border);
84
+ border-radius: var(--edgespark-auth-radius-control);
85
+ background: var(--edgespark-auth-bg);
86
+ color: var(--edgespark-auth-text);
87
+ font: inherit;
88
+ }
89
+
90
+ .edgespark-auth__input:focus {
91
+ outline: 2px solid color-mix(in srgb, var(--edgespark-auth-primary) 35%, transparent);
92
+ outline-offset: 1px;
93
+ border-color: var(--edgespark-auth-primary);
94
+ }
95
+
96
+ .edgespark-auth__button,
97
+ .edgespark-auth__link {
98
+ font: inherit;
99
+ }
100
+
101
+ .edgespark-auth__button {
102
+ display: inline-flex;
103
+ align-items: center;
104
+ justify-content: center;
105
+ min-height: 48px;
106
+ padding: 0 16px;
107
+ border-radius: 999px;
108
+ border: 1px solid transparent;
109
+ cursor: pointer;
110
+ transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, opacity 120ms ease;
111
+ }
112
+
113
+ .edgespark-auth__button:disabled,
114
+ .edgespark-auth__link:disabled {
115
+ cursor: not-allowed;
116
+ opacity: 0.6;
117
+ }
118
+
119
+ .edgespark-auth__button--primary {
120
+ background: var(--edgespark-auth-primary);
121
+ color: #ffffff;
122
+ }
123
+
124
+ .edgespark-auth__button--primary:hover:not(:disabled) {
125
+ background: var(--edgespark-auth-primary-hover);
126
+ }
127
+
128
+ .edgespark-auth__button--secondary {
129
+ border-color: var(--edgespark-auth-border);
130
+ background: var(--edgespark-auth-bg);
131
+ color: var(--edgespark-auth-text);
132
+ }
133
+
134
+ .edgespark-auth__button--secondary:hover:not(:disabled) {
135
+ background: var(--edgespark-auth-bg-muted);
136
+ }
137
+
138
+ .edgespark-auth__actions {
139
+ display: grid;
140
+ gap: 10px;
141
+ }
142
+
143
+ .edgespark-auth__link {
144
+ justify-self: start;
145
+ padding: 0;
146
+ border: 0;
147
+ background: transparent;
148
+ color: var(--edgespark-auth-primary);
149
+ cursor: pointer;
150
+ }
151
+
152
+ .edgespark-auth__divider {
153
+ position: relative;
154
+ text-align: center;
155
+ color: var(--edgespark-auth-text-muted);
156
+ font-size: 0.85rem;
157
+ }
158
+
159
+ .edgespark-auth__divider::before {
160
+ content: "";
161
+ position: absolute;
162
+ left: 0;
163
+ right: 0;
164
+ top: 50%;
165
+ border-top: 1px solid var(--edgespark-auth-border);
166
+ }
167
+
168
+ .edgespark-auth__divider span {
169
+ position: relative;
170
+ padding: 0 10px;
171
+ background: var(--edgespark-auth-bg);
172
+ }
173
+
174
+ .edgespark-auth__status {
175
+ padding: 12px 14px;
176
+ border-radius: 14px;
177
+ font-size: 0.92rem;
178
+ line-height: 1.5;
179
+ }
180
+
181
+ .edgespark-auth__status--error {
182
+ color: var(--edgespark-auth-error);
183
+ background: color-mix(in srgb, var(--edgespark-auth-error) 10%, #ffffff);
184
+ }
185
+
186
+ .edgespark-auth__status--info {
187
+ color: var(--edgespark-auth-info);
188
+ background: color-mix(in srgb, var(--edgespark-auth-info) 10%, #ffffff);
189
+ }
190
+
191
+ .edgespark-auth__status--muted {
192
+ color: var(--edgespark-auth-text-muted);
193
+ background: var(--edgespark-auth-bg-muted);
194
+ }
195
+
196
+ .edgespark-auth__iframe-modal {
197
+ position: fixed;
198
+ inset: 0;
199
+ z-index: 9999;
200
+ display: flex;
201
+ align-items: center;
202
+ justify-content: center;
203
+ padding: 24px;
204
+ background: rgba(15, 23, 42, 0.22);
205
+ }
206
+
207
+ .edgespark-auth__iframe-modal-card {
208
+ position: relative;
209
+ display: grid;
210
+ gap: 16px;
211
+ width: min(520px, 100%);
212
+ padding: 28px;
213
+ border-radius: 24px;
214
+ background: #ffffff;
215
+ box-shadow: 0 24px 60px rgba(15, 23, 42, 0.18);
216
+ }
217
+
218
+ .edgespark-auth__iframe-modal-close {
219
+ position: absolute;
220
+ top: 12px;
221
+ right: 12px;
222
+ width: 36px;
223
+ height: 36px;
224
+ border: 0;
225
+ border-radius: 999px;
226
+ background: transparent;
227
+ color: var(--edgespark-auth-text-muted);
228
+ font-size: 1.5rem;
229
+ cursor: pointer;
230
+ }
231
+
232
+ .edgespark-auth__iframe-modal-close:hover {
233
+ background: var(--edgespark-auth-bg-muted);
234
+ }
235
+
236
+ @media (max-width: 480px) {
237
+ .edgespark-auth__card {
238
+ padding: 18px;
239
+ border-radius: 16px;
240
+ }
241
+
242
+ .edgespark-auth__title {
243
+ font-size: 1.45rem;
244
+ }
245
+ }
@@ -0,0 +1,8 @@
1
+ import { EdgeSparkApi } from './types';
2
+ /**
3
+ * Creates the API module used by EdgeSpark apps.
4
+ *
5
+ * The wrapper keeps cookie credentials enabled and rejects cross-origin URLs so
6
+ * the SDK stays honest about its same-origin product boundary.
7
+ */
8
+ export declare function createApiModule(): EdgeSparkApi;
@@ -0,0 +1,13 @@
1
+ export type EdgeSparkRequestInit = Omit<RequestInit, "credentials">;
2
+ /**
3
+ * Same-origin fetch wrapper for authenticated app requests.
4
+ */
5
+ export interface EdgeSparkApi {
6
+ /**
7
+ * Same-origin `fetch` with cookie credentials always enabled.
8
+ *
9
+ * `credentials` is intentionally omitted from the accepted init shape because
10
+ * the SDK never allows callers to disable cookie auth.
11
+ */
12
+ fetch(input: RequestInfo | URL, init?: EdgeSparkRequestInit): Promise<Response>;
13
+ }
@@ -0,0 +1,17 @@
1
+ import { BetterAuthClientInstance, EdgeSparkAuth } from './types';
2
+ export declare const AUTH_BASE_PATH: "/api/_es/auth";
3
+ export declare function syncEdgeSparkAuthSession(authClient: BetterAuthClientInstance): Promise<void>;
4
+ /**
5
+ * Creates the headless auth module.
6
+ *
7
+ * This intentionally mirrors better-auth so agents can rely on upstream API
8
+ * knowledge instead of learning a second parallel auth surface.
9
+ */
10
+ export declare function createEdgeSparkAuth(): EdgeSparkAuth;
11
+ /**
12
+ * Creates the internal auth client used by the managed UI.
13
+ *
14
+ * Redirect handling is intentionally disabled here so `authUI` can own
15
+ * callback timing, loading state, and post-auth navigation semantics.
16
+ */
17
+ export declare function createManagedAuthUIClient(): BetterAuthClientInstance;
@@ -0,0 +1,55 @@
1
+ import { createAuthClient } from 'better-auth/client';
2
+ /**
3
+ * Direct better-auth client instance returned by createAuthClient().
4
+ */
5
+ export type BetterAuthClientInstance = ReturnType<typeof createAuthClient>;
6
+ export type SessionResult = Awaited<ReturnType<BetterAuthClientInstance["getSession"]>>;
7
+ export type AuthSession = NonNullable<SessionResult["data"]>;
8
+ export type AuthUser = AuthSession["user"];
9
+ export type SessionChangeCallback = (session: AuthSession | null) => void;
10
+ export interface AuthEndpointError {
11
+ code?: string;
12
+ message?: string;
13
+ status?: number;
14
+ }
15
+ export interface AuthEndpointResult<TData = unknown> {
16
+ data?: TData | null;
17
+ error?: AuthEndpointError | null;
18
+ }
19
+ /**
20
+ * EdgeSpark keeps auth primitive usage aligned with better-auth.
21
+ *
22
+ * All better-auth client methods work directly. EdgeSpark adds three
23
+ * small conveniences on top: `requireSession`, `onSessionChange`, and
24
+ * a wrapped `signOut` that guarantees same-tab session invalidation.
25
+ */
26
+ export type EdgeSparkAuth = BetterAuthClientInstance & {
27
+ /**
28
+ * Resolves with the current session, or throws when the browser is not authenticated.
29
+ *
30
+ * Use this for route guards and other flows where nullable session checks add noise.
31
+ */
32
+ requireSession(): Promise<AuthSession>;
33
+ /**
34
+ * Subscribes to session changes for the current tab.
35
+ *
36
+ * The first emission is **hydration-safe**: it waits for the initial server
37
+ * session check to complete before firing, so subscribers never receive a
38
+ * premature `null` that looks like "not authenticated" while the cookie
39
+ * session is still being verified.
40
+ *
41
+ * After the initial emission, every session atom update is forwarded
42
+ * immediately (sign-in from another tab, sign-out, token refresh, etc.).
43
+ *
44
+ * Returns an unsubscribe function.
45
+ */
46
+ onSessionChange(callback: SessionChangeCallback): () => void;
47
+ /**
48
+ * Signs out and invalidates the session for the current tab.
49
+ *
50
+ * This wraps better-auth's `signOut` and additionally forces the session
51
+ * atom to refetch, ensuring `onSessionChange` subscribers are notified
52
+ * synchronously after the call completes.
53
+ */
54
+ signOut(...args: Parameters<BetterAuthClientInstance["signOut"]>): ReturnType<BetterAuthClientInstance["signOut"]>;
55
+ };
@@ -0,0 +1,80 @@
1
+ import { BetterAuthClientInstance } from '../auth/types';
2
+ import { NormalizedAuthUIConfig } from './config';
3
+ import { MountAuthUIOptions, MountedAuthUI } from './types';
4
+ interface ManagedAuthUIRuntimeOptions {
5
+ syncPublicSession?: () => Promise<void> | void;
6
+ }
7
+ /**
8
+ * Internal managed auth UI implementation.
9
+ *
10
+ * The public API only exposes `authUI.mount(...)`; this class owns the DOM,
11
+ * form flows, and lifecycle.
12
+ */
13
+ export declare class ManagedAuthUI implements MountedAuthUI {
14
+ private readonly container;
15
+ private readonly auth;
16
+ private readonly options;
17
+ private readonly loadConfig;
18
+ private readonly runtimeOptions?;
19
+ private readonly labels;
20
+ private readonly root;
21
+ private readonly state;
22
+ private destroyed;
23
+ private terminalActionInProgress;
24
+ private config;
25
+ private countdownUntil;
26
+ private countdownTimer;
27
+ private pendingFocusSnapshot;
28
+ private focusRestoreTimer;
29
+ private verificationCompletionAction;
30
+ constructor(container: HTMLElement, auth: BetterAuthClientInstance, options: MountAuthUIOptions, loadConfig: () => Promise<NormalizedAuthUIConfig>, runtimeOptions?: ManagedAuthUIRuntimeOptions | undefined);
31
+ destroy(): void;
32
+ private initialize;
33
+ private render;
34
+ private renderLoading;
35
+ private renderStatus;
36
+ private renderCurrentView;
37
+ private renderSignInView;
38
+ private renderSignUpView;
39
+ private renderVerifyEmailView;
40
+ private renderForgotPasswordView;
41
+ private renderResetPasswordView;
42
+ private renderOAuthProviders;
43
+ private renderInput;
44
+ private bindDOM;
45
+ private switchView;
46
+ private handleSignIn;
47
+ private handleSignUp;
48
+ private handleOAuthSignIn;
49
+ private handleForgotPassword;
50
+ private resendResetCode;
51
+ private handleResetPassword;
52
+ private resendVerificationEmail;
53
+ private sendVerificationEmail;
54
+ private completeAuth;
55
+ private resolveAuthCompletionData;
56
+ private completePasswordReset;
57
+ private handleExistingSessionOnMount;
58
+ private runAction;
59
+ private maybeHandleVerificationRequired;
60
+ private handleVerificationEmailSent;
61
+ private handleError;
62
+ private failValidation;
63
+ private getManagedReturnURL;
64
+ private consumeManagedReturnContext;
65
+ private startCountdown;
66
+ private clearCountdown;
67
+ private captureFocusSnapshot;
68
+ private scheduleFocusRestore;
69
+ private clearFocusRestoreTimer;
70
+ private passwordResetEndpoints;
71
+ private extractRedirectURL;
72
+ private extractUser;
73
+ private canUsePasswordResetEmailOtp;
74
+ private handleSessionlessManagedReturn;
75
+ private isCountdownActive;
76
+ private getCountdownSecondsRemaining;
77
+ private countdownText;
78
+ private escape;
79
+ }
80
+ export {};
@@ -0,0 +1,33 @@
1
+ export type OAuthProviderId = "google" | "apple" | "microsoft" | "github" | "gitlab" | "discord";
2
+ interface OAuthProviderBlock {
3
+ enabled?: boolean;
4
+ }
5
+ interface ProviderEmailPassword {
6
+ enabled?: boolean;
7
+ config?: {
8
+ minPasswordLength?: number;
9
+ requireEmailVerification?: boolean;
10
+ requirePasswordResetEmailVerification?: boolean;
11
+ };
12
+ }
13
+ interface AuthConfigResponse {
14
+ disableSignUp?: boolean;
15
+ providerEmailPassword?: ProviderEmailPassword;
16
+ providerGoogle?: OAuthProviderBlock;
17
+ providerApple?: OAuthProviderBlock;
18
+ providerMicrosoft?: OAuthProviderBlock;
19
+ providerGithub?: OAuthProviderBlock;
20
+ providerGitlab?: OAuthProviderBlock;
21
+ providerDiscord?: OAuthProviderBlock;
22
+ }
23
+ export interface NormalizedAuthUIConfig {
24
+ disableSignUp: boolean;
25
+ emailPasswordEnabled: boolean;
26
+ minPasswordLength: number;
27
+ requireEmailVerification: boolean;
28
+ passwordResetEmailOtpEnabled: boolean;
29
+ oauthProviders: OAuthProviderId[];
30
+ }
31
+ export declare function loadNormalizedAuthUIConfig(): Promise<NormalizedAuthUIConfig>;
32
+ export declare function normalizeAuthUIConfig(config: AuthConfigResponse): NormalizedAuthUIConfig;
33
+ export {};
@@ -0,0 +1,2 @@
1
+ import { AuthUILabelOverrides, AuthUILabels } from './types';
2
+ export declare function mergeAuthUILabels(base: AuthUILabels, overrides?: AuthUILabelOverrides): AuthUILabels;
@@ -0,0 +1,2 @@
1
+ export { createAuthUIModule } from './mount';
2
+ export type { AuthSuccessEvent, AuthUILabelOverrides, AuthUILabels, ControlledAuthUIOptions, EdgeSparkAuthUI, MountAuthUIOptions, MountedAuthUI, RedirectAuthUIOptions, } from './types';
@@ -0,0 +1,7 @@
1
+ import { BetterAuthClientInstance } from '../auth/types';
2
+ import { EdgeSparkAuthUI } from './types';
3
+ interface ManagedAuthUIRuntimeOptions {
4
+ syncPublicSession?: () => Promise<void> | void;
5
+ }
6
+ export declare function createAuthUIModule(auth: BetterAuthClientInstance, runtimeOptions?: ManagedAuthUIRuntimeOptions): EdgeSparkAuthUI;
7
+ export {};
@@ -0,0 +1,7 @@
1
+ import { AuthSession, AuthUser, BetterAuthClientInstance } from '../auth/types';
2
+ import { EdgeSparkAuthError } from '../errors';
3
+ export declare function normalizeAuthError(error: unknown, fallbackMessage?: string): EdgeSparkAuthError;
4
+ export declare function readCurrentSession(auth: BetterAuthClientInstance): Promise<{
5
+ user: AuthUser | null;
6
+ session: AuthSession | null;
7
+ }>;