@omg-dev/sdk 0.4.25 → 0.4.27

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.
@@ -1,5 +1,5 @@
1
1
  import { t as VibesFeedback } from "./VibesFeedback-BF2Vf6FK.mjs";
2
- import { useId, useState } from "react";
2
+ import { useEffect, useId, useState } from "react";
3
3
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/brand/OmgBadge.tsx
5
5
  const BRAND = "#FF5530";
@@ -15,6 +15,7 @@ function feedbackBundled() {
15
15
  }
16
16
  const DISMISS_KEY = "vibes-remix:dismissed";
17
17
  const REMIX_ORIGIN = typeof import.meta !== "undefined" && import.meta.env?.VITE_REMIX_ORIGIN || "https://omg.dev";
18
+ const CONTROLPLANE_URL = (typeof import.meta !== "undefined" && import.meta.env?.VITE_CONTROLPLANE_URL || "https://backend.omg.dev").replace(/\/$/, "");
18
19
  function appSlug() {
19
20
  if (typeof import.meta === "undefined") return null;
20
21
  const slug = import.meta.env?.VITE_APP_SLUG?.trim();
@@ -33,13 +34,38 @@ function setDismissedFlag() {
33
34
  localStorage.setItem(DISMISS_KEY, "1");
34
35
  } catch {}
35
36
  }
37
+ /** Live check: only Explore-listed public apps may show "Make it mine". Fail closed. */
38
+ async function fetchRemixable(slug) {
39
+ try {
40
+ const res = await fetch(`${CONTROLPLANE_URL}/api/projects/isRemixable`, {
41
+ method: "POST",
42
+ headers: { "content-type": "application/json" },
43
+ body: JSON.stringify({ slug }),
44
+ cache: "no-store"
45
+ });
46
+ if (!res.ok) return false;
47
+ return (await res.json())?.remixable === true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
36
52
  function OmgBadge({ href = "https://omg.dev", label = "What is omg?" }) {
37
53
  const [open, setOpen] = useState(false);
38
54
  const slug = appSlug();
39
- const [remix, setRemix] = useState(() => slug !== null && !isDismissed());
55
+ const [remix, setRemix] = useState(false);
40
56
  const titleId = useId();
41
57
  const descriptionId = useId();
42
58
  const markId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
59
+ useEffect(() => {
60
+ if (!slug || isDismissed()) return;
61
+ let cancelled = false;
62
+ fetchRemixable(slug).then((ok) => {
63
+ if (!cancelled && ok && !isDismissed()) setRemix(true);
64
+ });
65
+ return () => {
66
+ cancelled = true;
67
+ };
68
+ }, [slug]);
43
69
  function dismissRemix() {
44
70
  setDismissedFlag();
45
71
  setRemix(false);
@@ -1,4 +1,4 @@
1
- import { t as OmgBadge } from "../OmgBadge-LAcimQp0.mjs";
1
+ import { t as OmgBadge } from "../OmgBadge-CDnTIlnn.mjs";
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  import { createRoot } from "react-dom/client";
4
4
  //#region src/brand/auto.tsx
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as installTrace, c as requestMotionPermission, d as getAuthContext, f as notifyAuthRequired, h as subscribeAuthRequired, i as getTrace, l as useFeedbackGesture, m as subscribeAuthChange, n as captureScreenshot, o as attachGestureListeners, p as setAuthContext, r as clearTrace, s as motionPermissionState, t as VibesFeedback, u as useUpload } from "./VibesFeedback-BF2Vf6FK.mjs";
2
- import { t as OmgBadge } from "./OmgBadge-LAcimQp0.mjs";
2
+ import { t as OmgBadge } from "./OmgBadge-CDnTIlnn.mjs";
3
3
  import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
4
4
  import { fetchEventSource } from "@microsoft/fetch-event-source";
5
5
  import { createAuthClient } from "better-auth/react";
@@ -7,6 +7,37 @@ import { passkeyClient } from "@better-auth/passkey/client";
7
7
  import { emailOTPClient, magicLinkClient } from "better-auth/client/plugins";
8
8
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
9
9
  //#region src/auth/client.ts
10
+ /**
11
+ * Recover the user carried by a slug-scoped JWT.
12
+ *
13
+ * Gated `*.omgs.app` deploys receive their identity through the edge's
14
+ * `omg_access` token exchange, not through an app-local Better Auth session.
15
+ * The exchanged JWT is signed and verified by the server before it can be
16
+ * used for data access; decoding it here only lets the React provider expose
17
+ * that already-authenticated identity to `useUser()` and auth guards.
18
+ */
19
+ function userFromToken(token) {
20
+ if (!token) return null;
21
+ try {
22
+ const payload = token.split(".")[1];
23
+ if (!payload) return null;
24
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
25
+ const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
26
+ const claims = JSON.parse(atob(padded));
27
+ if (typeof claims.sub !== "string" || typeof claims.email !== "string") return null;
28
+ return {
29
+ id: claims.sub,
30
+ email: claims.email,
31
+ ...typeof claims.name === "string" && claims.name ? { name: claims.name } : {}
32
+ };
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+ /** Prefer an explicit app session, then fall back to the edge-owned identity. */
38
+ function resolveAuthUser(sessionUser, tokenUser) {
39
+ return sessionUser ?? tokenUser;
40
+ }
10
41
  const DEFAULT_AUTH_URL = "https://auth.omg.dev";
11
42
  const AUTH_BRIDGE_PREFIX = "/__vibes/auth";
12
43
  /**
@@ -454,19 +485,21 @@ function VibesAuthProvider({ client: providedClient, appId, authUrl, autoPrompt
454
485
  ]);
455
486
  const { data: session, isPending } = client.authClient.useSession();
456
487
  const [token, setToken] = useState(null);
488
+ const [tokenUser, setTokenUser] = useState(null);
457
489
  const [authReady, setAuthReady] = useState(false);
490
+ const user = resolveAuthUser(session?.user ? {
491
+ id: session.user.id,
492
+ email: session.user.email,
493
+ name: session.user.name ?? void 0
494
+ } : null, tokenUser);
458
495
  useEffect(() => {
459
496
  if (isPending) return;
460
- if (!session?.user) {
461
- setToken(null);
462
- client.clearToken();
463
- setAuthReady(true);
464
- return;
465
- }
497
+ setAuthReady(false);
466
498
  let cancelled = false;
467
499
  client.getToken().then((t) => {
468
500
  if (cancelled) return;
469
501
  setToken(t);
502
+ setTokenUser(userFromToken(t));
470
503
  setAuthReady(true);
471
504
  });
472
505
  return () => {
@@ -479,18 +512,14 @@ function VibesAuthProvider({ client: providedClient, appId, authUrl, autoPrompt
479
512
  ]);
480
513
  useEffect(() => {
481
514
  setAuthContext({
482
- user: session?.user ? {
483
- id: session.user.id,
484
- email: session.user.email,
485
- name: session.user.name ?? void 0
486
- } : null,
515
+ user,
487
516
  token,
488
517
  authReady
489
518
  });
490
519
  }, [
491
- session?.user?.id,
492
- session?.user?.email,
493
- session?.user?.name,
520
+ user?.id,
521
+ user?.email,
522
+ user?.name,
494
523
  token,
495
524
  authReady
496
525
  ]);
@@ -498,27 +527,27 @@ function VibesAuthProvider({ client: providedClient, appId, authUrl, autoPrompt
498
527
  await client.authClient.signOut();
499
528
  client.clearToken();
500
529
  setToken(null);
530
+ setTokenUser(null);
501
531
  }, [client]);
502
532
  const refreshToken = useCallback(async () => {
503
533
  const t = await client.getToken();
504
534
  setToken(t);
535
+ setTokenUser(userFromToken(t));
505
536
  return t;
506
537
  }, [client]);
507
- const user = session?.user ? {
508
- id: session.user.id,
509
- email: session.user.email,
510
- name: session.user.name ?? void 0
511
- } : null;
538
+ const loading = isPending || !authReady;
512
539
  const value = useMemo(() => ({
513
540
  user,
514
- loading: isPending,
541
+ loading,
515
542
  token,
516
543
  signOut,
517
544
  refreshToken,
518
545
  client
519
546
  }), [
520
547
  user?.id,
521
- isPending,
548
+ user?.email,
549
+ user?.name,
550
+ loading,
522
551
  token,
523
552
  signOut,
524
553
  refreshToken,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omg-dev/sdk",
3
- "version": "0.4.25",
3
+ "version": "0.4.27",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -10,6 +10,48 @@ export interface VibesUser {
10
10
  name?: string
11
11
  }
12
12
 
13
+ /**
14
+ * Recover the user carried by a slug-scoped JWT.
15
+ *
16
+ * Gated `*.omgs.app` deploys receive their identity through the edge's
17
+ * `omg_access` token exchange, not through an app-local Better Auth session.
18
+ * The exchanged JWT is signed and verified by the server before it can be
19
+ * used for data access; decoding it here only lets the React provider expose
20
+ * that already-authenticated identity to `useUser()` and auth guards.
21
+ */
22
+ export function userFromToken(token: string | null): VibesUser | null {
23
+ if (!token) return null
24
+ try {
25
+ const payload = token.split(".")[1]
26
+ if (!payload) return null
27
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/")
28
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4)
29
+ const claims = JSON.parse(atob(padded)) as {
30
+ sub?: unknown
31
+ email?: unknown
32
+ name?: unknown
33
+ }
34
+ if (typeof claims.sub !== "string" || typeof claims.email !== "string") {
35
+ return null
36
+ }
37
+ return {
38
+ id: claims.sub,
39
+ email: claims.email,
40
+ ...(typeof claims.name === "string" && claims.name ? { name: claims.name } : {}),
41
+ }
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ /** Prefer an explicit app session, then fall back to the edge-owned identity. */
48
+ export function resolveAuthUser(
49
+ sessionUser: VibesUser | null,
50
+ tokenUser: VibesUser | null,
51
+ ): VibesUser | null {
52
+ return sessionUser ?? tokenUser
53
+ }
54
+
13
55
  export interface VibesAuthConfig {
14
56
  /** App ID registered with the auth service */
15
57
  appId: string
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { createContext, useContext, useEffect, useState, useCallback, useMemo, type ReactNode } from "react"
4
4
  import type { VibesAuthClient, VibesUser } from "./client"
5
- import { createVibesAuth, deriveAppId } from "./client"
5
+ import { createVibesAuth, deriveAppId, resolveAuthUser, userFromToken } from "./client"
6
6
  import { setAuthContext } from "./bridge"
7
7
  import { VibesAuthAutoPrompt } from "./auto-prompt"
8
8
 
@@ -53,27 +53,34 @@ export function VibesAuthProvider({
53
53
 
54
54
  const { data: session, isPending } = client.authClient.useSession()
55
55
  const [token, setToken] = useState<string | null>(null)
56
+ const [tokenUser, setTokenUser] = useState<VibesUser | null>(null)
56
57
  // True once the session check + first getToken() attempt have settled. Data
57
58
  // hooks read this (via the bridge) to tell a transient startup
58
59
  // `auth_required` apart from a genuinely signed-out user.
59
60
  const [authReady, setAuthReady] = useState(false)
60
-
61
- // Fetch JWT when session becomes available. Gate on isPending so we don't
62
- // mark auth "ready" (or clear the token) while the session is still
63
- // resolving — that window is exactly when the token race surfaced a scary
64
- // error before.
61
+ const sessionUser: VibesUser | null = session?.user
62
+ ? {
63
+ id: session.user.id,
64
+ email: session.user.email,
65
+ name: session.user.name ?? undefined,
66
+ }
67
+ : null
68
+ // One identity owner: a local Better Auth session when the app explicitly
69
+ // signed the visitor in, otherwise the edge-exchanged OMG identity.
70
+ const user = resolveAuthUser(sessionUser, tokenUser)
71
+
72
+ // Resolve the slug JWT after the local session check settles. This request
73
+ // is intentionally unconditional: gated omgs.app deploys authenticate at
74
+ // the edge and exchange their first-party `omg_access` cookie for this JWT,
75
+ // so they do not (and should not) need a second app-local auth session.
65
76
  useEffect(() => {
66
77
  if (isPending) return
67
- if (!session?.user) {
68
- setToken(null)
69
- client.clearToken()
70
- setAuthReady(true)
71
- return
72
- }
78
+ setAuthReady(false)
73
79
  let cancelled = false
74
80
  client.getToken().then((t) => {
75
81
  if (cancelled) return
76
82
  setToken(t)
83
+ setTokenUser(userFromToken(t))
77
84
  setAuthReady(true)
78
85
  })
79
86
  return () => {
@@ -85,41 +92,31 @@ export function VibesAuthProvider({
85
92
  // doesn't take a client prop) can attach Authorization on every fetch.
86
93
  useEffect(() => {
87
94
  setAuthContext({
88
- user: session?.user
89
- ? {
90
- id: session.user.id,
91
- email: session.user.email,
92
- name: session.user.name ?? undefined,
93
- }
94
- : null,
95
+ user,
95
96
  token,
96
97
  authReady,
97
98
  })
98
- }, [session?.user?.id, session?.user?.email, session?.user?.name, token, authReady])
99
+ }, [user?.id, user?.email, user?.name, token, authReady])
99
100
 
100
101
  const signOut = useCallback(async () => {
101
102
  await client.authClient.signOut()
102
103
  client.clearToken()
103
104
  setToken(null)
105
+ setTokenUser(null)
104
106
  }, [client])
105
107
 
106
108
  const refreshToken = useCallback(async () => {
107
109
  const t = await client.getToken()
108
110
  setToken(t)
111
+ setTokenUser(userFromToken(t))
109
112
  return t
110
113
  }, [client])
111
114
 
112
- const user: VibesUser | null = session?.user
113
- ? {
114
- id: session.user.id,
115
- email: session.user.email,
116
- name: session.user.name ?? undefined,
117
- }
118
- : null
115
+ const loading = isPending || !authReady
119
116
 
120
117
  const value = useMemo<VibesAuthContextValue>(
121
- () => ({ user, loading: isPending, token, signOut, refreshToken, client }),
122
- [user?.id, isPending, token, signOut, refreshToken, client]
118
+ () => ({ user, loading, token, signOut, refreshToken, client }),
119
+ [user?.id, user?.email, user?.name, loading, token, signOut, refreshToken, client]
123
120
  )
124
121
 
125
122
  return (
@@ -1,4 +1,4 @@
1
- import { useId, useState, type CSSProperties } from "react"
1
+ import { useEffect, useId, useState, type CSSProperties } from "react"
2
2
  import { VibesFeedback } from "../feedback/VibesFeedback"
3
3
 
4
4
  export interface OmgBadgeProps {
@@ -32,15 +32,23 @@ function feedbackBundled(): boolean {
32
32
  }
33
33
 
34
34
  // ── Remix ("Make it mine") gating ────────────────────────────────────────────
35
- // The badge's first job on a PUBLISHED app is the recipient→creator hinge: a
36
- // visitor who only has the shared link can fork the app back on omg.dev. Once
37
- // they dismiss that, the SAME badge collapses to plain branding the omg mark
38
- // never leaves, only the call-to-action does.
35
+ // The badge's first job on a PUBLIC Explore-listed app is the recipient→creator
36
+ // hinge: a visitor who only has the shared link can fork the app back on
37
+ // omg.dev. Private (not listed) apps keep the plain branding badge only
38
+ // remixBySlug rejects them, so the CTA must never appear. Once dismissed, the
39
+ // SAME badge collapses to branding — the omg mark never leaves, only the CTA.
39
40
  const DISMISS_KEY = "vibes-remix:dismissed"
40
41
  const REMIX_ORIGIN =
41
42
  (typeof import.meta !== "undefined" &&
42
43
  (import.meta as { env?: Record<string, string | undefined> }).env?.VITE_REMIX_ORIGIN) ||
43
44
  "https://omg.dev"
45
+ // Control-plane host for the unauthenticated isRemixable probe. Overridable for
46
+ // self-host; defaults to production. CORS is open for this path only (Caddy).
47
+ const CONTROLPLANE_URL = (
48
+ (typeof import.meta !== "undefined" &&
49
+ (import.meta as { env?: Record<string, string | undefined> }).env?.VITE_CONTROLPLANE_URL) ||
50
+ "https://backend.omg.dev"
51
+ ).replace(/\/$/, "")
44
52
 
45
53
  function appSlug(): string | null {
46
54
  if (typeof import.meta === "undefined") return null
@@ -66,20 +74,49 @@ function setDismissedFlag() {
66
74
  }
67
75
  }
68
76
 
77
+ /** Live check: only Explore-listed public apps may show "Make it mine". Fail closed. */
78
+ async function fetchRemixable(slug: string): Promise<boolean> {
79
+ try {
80
+ const res = await fetch(`${CONTROLPLANE_URL}/api/projects/isRemixable`, {
81
+ method: "POST",
82
+ headers: { "content-type": "application/json" },
83
+ body: JSON.stringify({ slug }),
84
+ // Public boolean; no credentials. Avoid caching a stale private→public flip.
85
+ cache: "no-store",
86
+ })
87
+ if (!res.ok) return false
88
+ const data = (await res.json()) as { remixable?: unknown }
89
+ return data?.remixable === true
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+
69
95
  export function OmgBadge({
70
96
  href = "https://omg.dev",
71
97
  label = "What is omg?",
72
98
  }: OmgBadgeProps) {
73
99
  const [open, setOpen] = useState(false)
74
- // "Make it mine" shows only on a published app (slug baked in) that the
75
- // visitor hasn't dismissed yet. Dismissing keeps the badge it just drops to
76
- // the branding state so the omg mark stays put.
100
+ // Fail closed: start as branding. Promote to remix only after the public
101
+ // catalog confirms this slug is listed never flash "Make it mine" on a
102
+ // private published app while the probe is in flight.
77
103
  const slug = appSlug()
78
- const [remix, setRemix] = useState(() => slug !== null && !isDismissed())
104
+ const [remix, setRemix] = useState(false)
79
105
  const titleId = useId()
80
106
  const descriptionId = useId()
81
107
  const markId = useId().replace(/[^a-zA-Z0-9_-]/g, "")
82
108
 
109
+ useEffect(() => {
110
+ if (!slug || isDismissed()) return
111
+ let cancelled = false
112
+ void fetchRemixable(slug).then((ok) => {
113
+ if (!cancelled && ok && !isDismissed()) setRemix(true)
114
+ })
115
+ return () => {
116
+ cancelled = true
117
+ }
118
+ }, [slug])
119
+
83
120
  function dismissRemix() {
84
121
  setDismissedFlag()
85
122
  setRemix(false)
@@ -5,10 +5,11 @@
5
5
  // generated app to carry attribution code in its source.
6
6
  //
7
7
  // One control, not three: this badge is the "Make it mine" remix CTA on a fresh
8
- // visit (same omg mark), collapses to the plain branding badge on dismiss, and
9
- // bundles the (button-less) feedback sheet, summoned from its dialog. The
10
- // vite-plugin therefore skips the standalone feedback/auto + remix-cta imports
11
- // whenever this badge is enabled (the default).
8
+ // visit to a PUBLIC (Explore-listed) app (same omg mark), collapses to the plain
9
+ // branding badge on dismiss or when the app is private, and bundles the
10
+ // (button-less) feedback sheet, summoned from its dialog. The vite-plugin
11
+ // therefore skips the standalone feedback/auto + remix-cta imports whenever
12
+ // this badge is enabled (the default).
12
13
 
13
14
  import { createRoot } from "react-dom/client"
14
15
  import { OmgBadge } from "./OmgBadge"