@playcademy/better-auth 0.0.1-alpha.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/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @playcademy/better-auth
2
+
3
+ Better Auth integration for Playcademy games with automatic platform/standalone mode detection and Safari Storage Access API support.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @playcademy/better-auth better-auth
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Server
14
+
15
+ ```typescript
16
+ import { betterAuth } from 'better-auth'
17
+
18
+ import { playcademy } from '@playcademy/better-auth/server'
19
+
20
+ export const auth = betterAuth({
21
+ database: drizzleAdapter(db, { provider: 'sqlite' }),
22
+ plugins: [playcademy()],
23
+ advanced: {
24
+ defaultCookieAttributes: {
25
+ sameSite: 'none',
26
+ secure: true,
27
+ path: '/',
28
+ partitioned: true, // CHIPS for Chrome/Edge
29
+ },
30
+ },
31
+ })
32
+ ```
33
+
34
+ ### Client
35
+
36
+ ```typescript
37
+ import { createAuthClient } from 'better-auth/react'
38
+
39
+ import { playcademy } from '@playcademy/better-auth/client'
40
+
41
+ const auth = createAuthClient({
42
+ plugins: [playcademy()],
43
+ })
44
+ ```
45
+
46
+ ## Features
47
+
48
+ - **Automatic mode detection**: Seamlessly switches between platform (iframe) and standalone modes
49
+ - **Cookie-based auth**: Uses HttpOnly cookies for security
50
+ - **CHIPS support**: Partitioned cookies for Chrome/Edge in iframes
51
+ - **Safari support**: Automatic Storage Access API prompts for Safari users
52
+ - **Zero configuration**: Works out of the box with sensible defaults
53
+
54
+ ## Requirements
55
+
56
+ - **HTTPS required**: Both platform and game must use HTTPS (Safari requirement)
57
+ - **Storage Access API**: Safari users see a one-time prompt to allow cookies in iframes
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Playcademy Auth Client Plugin
3
+ *
4
+ * Handles platform/standalone auth automatically:
5
+ * - Platform mode: Uses cookies with automatic Storage Access API for Safari
6
+ * - Standalone mode: Uses cookies
7
+ *
8
+ * Safari in iframes requires Storage Access API - this plugin handles it automatically!
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * import { createAuthClient } from 'better-auth/react'
13
+ * import { playcademy } from '@playcademy/better-auth/client'
14
+ *
15
+ * // That's it! Safari Storage Access API handled automatically
16
+ * const auth = createAuthClient({
17
+ * plugins: [playcademy()]
18
+ * })
19
+ * ```
20
+ */
21
+ import { playcademy as playcademyServerPlugin } from './server';
22
+ export interface PlaycademyClientOptions {
23
+ /**
24
+ * Automatically show storage access prompt for Safari users
25
+ *
26
+ * When true (default), Safari users in iframes will see an automatic
27
+ * prompt to grant storage access (required for cookies in Safari).
28
+ *
29
+ * Set to false if you want to handle Safari storage access yourself.
30
+ *
31
+ * @default true
32
+ */
33
+ safari?: {
34
+ autoPrompt?: boolean;
35
+ };
36
+ }
37
+ /**
38
+ * Playcademy client plugin for Better Auth
39
+ *
40
+ * Automatically handles:
41
+ * - Platform vs standalone mode detection
42
+ * - Token exchange for platform mode
43
+ * - Safari Storage Access API (auto-prompt)
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * // Default: Auto-handles everything including Safari
48
+ * const auth = createAuthClient({
49
+ * plugins: [playcademy()]
50
+ * })
51
+ * ```
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * // Custom: Disable auto Safari prompt
56
+ * const auth = createAuthClient({
57
+ * plugins: [
58
+ * playcademy({
59
+ * safari: { autoPrompt: false }
60
+ * })
61
+ * ]
62
+ * })
63
+ * ```
64
+ */
65
+ export declare function playcademy(options?: PlaycademyClientOptions): {
66
+ id: "playcademy-client";
67
+ $InferServerPlugin: ReturnType<typeof playcademyServerPlugin>;
68
+ fetchPlugins: import("@better-fetch/fetch").BetterFetchPlugin[];
69
+ };
package/dist/client.js ADDED
@@ -0,0 +1,278 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, {
5
+ get: all[name],
6
+ enumerable: true,
7
+ configurable: true,
8
+ set: (newValue) => all[name] = () => newValue
9
+ });
10
+ };
11
+
12
+ // src/utils.ts
13
+ function isPlatformMode() {
14
+ if (typeof window === "undefined")
15
+ return false;
16
+ if (window.self === window.top)
17
+ return false;
18
+ return true;
19
+ }
20
+ function isSafari() {
21
+ if (typeof window === "undefined")
22
+ return false;
23
+ const ua = navigator.userAgent;
24
+ return ua.includes("Safari") && !ua.includes("Chrome") && !ua.includes("Chromium");
25
+ }
26
+ function getPlatformToken() {
27
+ if (typeof window === "undefined")
28
+ return null;
29
+ try {
30
+ return window.PLAYCADEMY?.token || null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+ function hasStorageAccessAPI() {
36
+ return typeof document !== "undefined" && "hasStorageAccess" in document;
37
+ }
38
+ async function needsStorageAccess() {
39
+ if (!isPlatformMode())
40
+ return false;
41
+ if (!isSafari())
42
+ return false;
43
+ if (!hasStorageAccessAPI())
44
+ return false;
45
+ try {
46
+ const hasAccess = await document.hasStorageAccess();
47
+ return !hasAccess;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+ async function requestStorageAccess() {
53
+ if (!hasStorageAccessAPI())
54
+ return false;
55
+ try {
56
+ await document.requestStorageAccess();
57
+ return true;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ // src/fetch.ts
64
+ async function ensureStorageAccess() {
65
+ if (!isSafari())
66
+ return true;
67
+ const needs = await needsStorageAccess();
68
+ if (needs) {
69
+ return false;
70
+ }
71
+ try {
72
+ await document.requestStorageAccess();
73
+ await new Promise((resolve) => setTimeout(resolve, 50));
74
+ return true;
75
+ } catch (err) {
76
+ console.error("[Playcademy Auth] Safari Storage Access activation failed:", err);
77
+ return false;
78
+ }
79
+ }
80
+ var exchangeComplete = false;
81
+ var exchangePromise = null;
82
+ var TOKEN_POLL_INTERVAL_MS = 50;
83
+ var TOKEN_POLL_MAX_DURATION_MS = 3000;
84
+ async function waitForToken() {
85
+ const startTime = Date.now();
86
+ return new Promise((resolve) => {
87
+ const checkToken = () => {
88
+ const token = getPlatformToken();
89
+ if (token) {
90
+ resolve(token);
91
+ return;
92
+ }
93
+ if (Date.now() - startTime >= TOKEN_POLL_MAX_DURATION_MS) {
94
+ resolve(null);
95
+ return;
96
+ }
97
+ setTimeout(checkToken, TOKEN_POLL_INTERVAL_MS);
98
+ };
99
+ checkToken();
100
+ });
101
+ }
102
+ function playcademyExchangePlugin(_opts) {
103
+ return {
104
+ id: "playcademy-exchange",
105
+ name: "Playcademy Exchange",
106
+ async init(url, options) {
107
+ if (exchangeComplete)
108
+ return { url, options };
109
+ if (exchangePromise) {
110
+ await exchangePromise;
111
+ return { url, options };
112
+ }
113
+ exchangePromise = (async () => {
114
+ const token = await waitForToken();
115
+ if (!token)
116
+ return;
117
+ const canProceed = await ensureStorageAccess();
118
+ if (!canProceed) {
119
+ return;
120
+ }
121
+ await fetch("/api/auth/playcademy", {
122
+ method: "POST",
123
+ headers: { Authorization: `Bearer ${token}` },
124
+ credentials: "include"
125
+ });
126
+ exchangeComplete = true;
127
+ })();
128
+ await exchangePromise;
129
+ return { url, options };
130
+ }
131
+ };
132
+ }
133
+
134
+ // src/client.ts
135
+ var OVERLAY_ID = "playcademy-storage-access-overlay";
136
+ function removeOverlay() {
137
+ const overlay = document.getElementById(OVERLAY_ID);
138
+ if (overlay) {
139
+ overlay.remove();
140
+ }
141
+ }
142
+ function injectStorageAccessOverlay() {
143
+ if (document.getElementById(OVERLAY_ID))
144
+ return;
145
+ if (!document.body) {
146
+ document.addEventListener("DOMContentLoaded", injectStorageAccessOverlay);
147
+ return;
148
+ }
149
+ const overlay = document.createElement("div");
150
+ overlay.id = OVERLAY_ID;
151
+ overlay.innerHTML = `
152
+ <style>
153
+ #${OVERLAY_ID} {
154
+ position: fixed;
155
+ top: 0;
156
+ left: 0;
157
+ width: 100%;
158
+ height: 100%;
159
+ background: rgba(0, 0, 0, 0.8);
160
+ display: flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ z-index: 999999;
164
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
165
+ }
166
+
167
+ #${OVERLAY_ID}-modal {
168
+ background: white;
169
+ border-radius: 12px;
170
+ padding: 32px;
171
+ max-width: 400px;
172
+ text-align: center;
173
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
174
+ }
175
+
176
+ #${OVERLAY_ID}-title {
177
+ font-size: 20px;
178
+ font-weight: 600;
179
+ margin: 0 0 12px 0;
180
+ color: #1a1a1a;
181
+ }
182
+
183
+ #${OVERLAY_ID}-message {
184
+ font-size: 14px;
185
+ line-height: 1.5;
186
+ margin: 0 0 24px 0;
187
+ color: #666;
188
+ }
189
+
190
+ #${OVERLAY_ID}-button {
191
+ background: #007aff;
192
+ color: white;
193
+ border: none;
194
+ border-radius: 8px;
195
+ padding: 12px 24px;
196
+ font-size: 16px;
197
+ font-weight: 500;
198
+ cursor: pointer;
199
+ transition: background 0.2s;
200
+ }
201
+
202
+ #${OVERLAY_ID}-button:hover {
203
+ background: #0051d5;
204
+ }
205
+
206
+ #${OVERLAY_ID}-button:active {
207
+ transform: scale(0.98);
208
+ }
209
+ </style>
210
+
211
+ <div id="${OVERLAY_ID}-modal">
212
+ <h2 id="${OVERLAY_ID}-title">Enable Authentication</h2>
213
+ <p id="${OVERLAY_ID}-message">
214
+ This game needs to store authentication data.<br>
215
+ Click below to continue.
216
+ </p>
217
+ <button id="${OVERLAY_ID}-button">Continue</button>
218
+ </div>
219
+ `;
220
+ const button = overlay.querySelector(`#${OVERLAY_ID}-button`);
221
+ if (button) {
222
+ button.addEventListener("click", async () => {
223
+ if (hasStorageAccessAPI()) {
224
+ try {
225
+ const hasAccess = await document.hasStorageAccess();
226
+ if (hasAccess) {
227
+ removeOverlay();
228
+ window.location.reload();
229
+ return;
230
+ }
231
+ } catch {}
232
+ }
233
+ const granted = await requestStorageAccess();
234
+ if (granted) {
235
+ removeOverlay();
236
+ window.location.reload();
237
+ } else {
238
+ const message = overlay.querySelector(`#${OVERLAY_ID}-message`);
239
+ if (message) {
240
+ message.textContent = "Storage access was denied. Please enable cookies in your browser settings to continue.";
241
+ }
242
+ }
243
+ });
244
+ }
245
+ document.body.appendChild(overlay);
246
+ }
247
+ async function handleStorageAccess(options) {
248
+ if (!options.autoPrompt)
249
+ return;
250
+ if (!isPlatformMode() || !isSafari())
251
+ return;
252
+ const needs = await needsStorageAccess();
253
+ if (needs) {
254
+ injectStorageAccessOverlay();
255
+ }
256
+ }
257
+ function playcademy(options) {
258
+ const opts = {
259
+ safari: {
260
+ autoPrompt: options?.safari?.autoPrompt ?? true
261
+ }
262
+ };
263
+ if (typeof window !== "undefined" && opts.safari.autoPrompt) {
264
+ if (isPlatformMode() && isSafari()) {
265
+ setTimeout(() => {
266
+ handleStorageAccess(opts.safari);
267
+ }, 0);
268
+ }
269
+ }
270
+ return {
271
+ id: "playcademy-client",
272
+ $InferServerPlugin: {},
273
+ fetchPlugins: [playcademyExchangePlugin()]
274
+ };
275
+ }
276
+ export {
277
+ playcademy
278
+ };
@@ -0,0 +1,10 @@
1
+ import type { BetterFetchPlugin } from '@better-fetch/fetch';
2
+ /**
3
+ * Better Auth fetch plugin - exchanges platform token on first request
4
+ *
5
+ * Uses polling strategy (similar to bootIframe handshake) to wait for SDK initialization.
6
+ * This keeps Better Auth completely decoupled from our SDK.
7
+ */
8
+ export declare function playcademyExchangePlugin(_opts?: {
9
+ baseURL: string;
10
+ }): BetterFetchPlugin;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Playcademy plugin for Better Auth
3
+ *
4
+ * This plugin provides:
5
+ * - Platform user schema field (playcademyUserId)
6
+ * - Token exchange endpoint (POST /api/auth/playcademy)
7
+ * - Automatic account linking between platform and standalone modes
8
+ * - Cross-site cookie support with CHIPS
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * import { betterAuth } from 'better-auth'
13
+ * import { playcademy } from '@playcademy/better-auth/server'
14
+ *
15
+ * export const auth = betterAuth({
16
+ * database: drizzleAdapter(db, { provider: 'sqlite' }),
17
+ * plugins: [playcademy()],
18
+ * advanced: {
19
+ * defaultCookieAttributes: {
20
+ * sameSite: 'none',
21
+ * secure: true,
22
+ * path: '/',
23
+ * partitioned: true, // CHIPS for Chrome/Edge
24
+ * },
25
+ * },
26
+ * })
27
+ * ```
28
+ */
29
+ export declare const playcademy: () => {
30
+ id: "playcademy";
31
+ schema: {
32
+ user: {
33
+ fields: {
34
+ playcademyUserId: {
35
+ type: "string";
36
+ unique: true;
37
+ required: false;
38
+ };
39
+ };
40
+ };
41
+ };
42
+ endpoints: {
43
+ /**
44
+ * Platform Token Exchange Endpoint
45
+ *
46
+ * POST /api/auth/playcademy
47
+ *
48
+ * Exchanges a Playcademy platform JWT for a Better Auth session.
49
+ * Used when games are launched from the platform (iframe mode).
50
+ *
51
+ * Flow:
52
+ * 1. Verify platform JWT with platform API
53
+ * 2. Find or create user (with account linking)
54
+ * 3. Check if already authenticated
55
+ * 4. Create new session if needed
56
+ * 5. Set cross-site compatible cookie (CHIPS)
57
+ */
58
+ playcademyAuth: {
59
+ <AsResponse extends boolean = false, ReturnHeaders extends boolean = false>(inputCtx_0?: ({
60
+ body?: undefined;
61
+ } & {
62
+ method?: "POST" | undefined;
63
+ } & {
64
+ query?: Record<string, any> | undefined;
65
+ } & {
66
+ params?: Record<string, any>;
67
+ } & {
68
+ request?: Request;
69
+ } & {
70
+ headers?: HeadersInit;
71
+ } & {
72
+ asResponse?: boolean;
73
+ returnHeaders?: boolean;
74
+ use?: import("better-call").Middleware[];
75
+ path?: string;
76
+ } & {
77
+ asResponse?: AsResponse | undefined;
78
+ returnHeaders?: ReturnHeaders | undefined;
79
+ }) | undefined): Promise<[AsResponse] extends [true] ? Response : [ReturnHeaders] extends [true] ? {
80
+ headers: Headers;
81
+ response: Response;
82
+ } : Response>;
83
+ options: {
84
+ method: "POST";
85
+ requireHeaders: false;
86
+ } & {
87
+ use: any[];
88
+ };
89
+ path: "/playcademy";
90
+ };
91
+ };
92
+ };