@dalgoridim/headless-cms 0.2.0 → 0.3.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/ARCHITECTURE.md +1 -2
- package/README.md +5 -1
- package/dist/auth/google/client/index.cjs +196 -0
- package/dist/auth/google/client/index.cjs.map +1 -0
- package/dist/auth/google/client/index.d.cts +74 -0
- package/dist/auth/google/client/index.d.ts +74 -0
- package/dist/auth/google/client/index.js +178 -0
- package/dist/auth/google/client/index.js.map +1 -0
- package/dist/auth/google/index.cjs +121 -0
- package/dist/auth/google/index.cjs.map +1 -0
- package/dist/auth/google/index.d.cts +45 -0
- package/dist/auth/google/index.d.ts +45 -0
- package/dist/auth/google/index.js +95 -0
- package/dist/auth/google/index.js.map +1 -0
- package/package.json +16 -4
- package/REDESIGN.md +0 -250
package/ARCHITECTURE.md
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# Architecture & design notes
|
|
2
2
|
|
|
3
3
|
Core/internal documentation for `@dalgoridim/headless-cms`. For consumer usage,
|
|
4
|
-
see the [README](./README.md).
|
|
5
|
-
[REDESIGN.md](./REDESIGN.md).
|
|
4
|
+
see the [README](./README.md).
|
|
6
5
|
|
|
7
6
|
## Philosophy: harness, not framework
|
|
8
7
|
|
package/README.md
CHANGED
|
@@ -13,6 +13,11 @@ You build a thin skin over the primitives (see [Styling](#styling-bring-your-own
|
|
|
13
13
|
the package never pushes a look onto your site. The design rationale and internals
|
|
14
14
|
live in [ARCHITECTURE.md](./ARCHITECTURE.md).
|
|
15
15
|
|
|
16
|
+
**Live example:** [dalgoridim.com](https://dalgoridim.com) runs on this package.
|
|
17
|
+
Try it: anyone can toggle edit mode and change the content inline, right on the
|
|
18
|
+
page — but saves are gated, so only the authenticated owner's edits actually
|
|
19
|
+
persist. Your changes are yours alone to play with.
|
|
20
|
+
|
|
16
21
|
## Install
|
|
17
22
|
|
|
18
23
|
```bash
|
|
@@ -353,7 +358,6 @@ without ever changing how your site looks.
|
|
|
353
358
|
- **[README](./README.md)** — this guide: install and usage.
|
|
354
359
|
- **[ARCHITECTURE.md](./ARCHITECTURE.md)** — design rationale and internals
|
|
355
360
|
(bundle boundaries, backend parity, the hybrid Postgres model, headless UI).
|
|
356
|
-
- **[REDESIGN.md](./REDESIGN.md)** — the "harness, not framework" redesign spec.
|
|
357
361
|
|
|
358
362
|
## Build
|
|
359
363
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
"use client";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/auth/google/client/index.tsx
|
|
22
|
+
var client_exports = {};
|
|
23
|
+
__export(client_exports, {
|
|
24
|
+
GoogleAuthProvider: () => GoogleAuthProvider,
|
|
25
|
+
useGoogleAuth: () => useGoogleAuth
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(client_exports);
|
|
28
|
+
var import_react = require("react");
|
|
29
|
+
var import_client = require("@dalgoridim/headless-cms/client");
|
|
30
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
31
|
+
var GSI_SRC = "https://accounts.google.com/gsi/client";
|
|
32
|
+
var GoogleAuthContext = (0, import_react.createContext)(
|
|
33
|
+
void 0
|
|
34
|
+
);
|
|
35
|
+
function setCookie(name, value) {
|
|
36
|
+
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; samesite=lax`;
|
|
37
|
+
}
|
|
38
|
+
function deleteCookie(name) {
|
|
39
|
+
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
|
|
40
|
+
}
|
|
41
|
+
function readCookie(name) {
|
|
42
|
+
for (const part of document.cookie.split(";")) {
|
|
43
|
+
const [k, ...v] = part.trim().split("=");
|
|
44
|
+
if (k === name) return decodeURIComponent(v.join("="));
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
function decodeJwt(token) {
|
|
49
|
+
try {
|
|
50
|
+
const payload = token.split(".")[1];
|
|
51
|
+
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
|
|
52
|
+
const data = JSON.parse(json);
|
|
53
|
+
return data;
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function GoogleAuthProvider({
|
|
59
|
+
children,
|
|
60
|
+
clientId,
|
|
61
|
+
adminEmails,
|
|
62
|
+
cookieName = "adminToken",
|
|
63
|
+
onLogout
|
|
64
|
+
}) {
|
|
65
|
+
const [user, setUser] = (0, import_react.useState)(null);
|
|
66
|
+
const [isAdmin, setIsAdmin] = (0, import_react.useState)(false);
|
|
67
|
+
const [isEditing, setIsEditing] = (0, import_react.useState)(false);
|
|
68
|
+
const [ready, setReady] = (0, import_react.useState)(false);
|
|
69
|
+
const allowed = (0, import_react.useRef)(
|
|
70
|
+
(adminEmails != null ? adminEmails : []).map((e) => e.trim().toLowerCase())
|
|
71
|
+
);
|
|
72
|
+
const applyToken = (0, import_react.useCallback)(
|
|
73
|
+
(token) => {
|
|
74
|
+
var _a;
|
|
75
|
+
const claims = decodeJwt(token);
|
|
76
|
+
if (!claims) return;
|
|
77
|
+
if (claims.exp && claims.exp * 1e3 <= Date.now()) {
|
|
78
|
+
deleteCookie(cookieName);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const email = (_a = claims.email) == null ? void 0 : _a.toLowerCase();
|
|
82
|
+
const admin = allowed.current.length === 0 ? true : !!email && allowed.current.includes(email);
|
|
83
|
+
setCookie(cookieName, token);
|
|
84
|
+
setUser({
|
|
85
|
+
sub: claims.sub,
|
|
86
|
+
email: claims.email,
|
|
87
|
+
name: claims.name,
|
|
88
|
+
picture: claims.picture
|
|
89
|
+
});
|
|
90
|
+
setIsAdmin(admin);
|
|
91
|
+
},
|
|
92
|
+
[cookieName]
|
|
93
|
+
);
|
|
94
|
+
(0, import_react.useEffect)(() => {
|
|
95
|
+
const existing = readCookie(cookieName);
|
|
96
|
+
if (existing) applyToken(existing);
|
|
97
|
+
}, [cookieName, applyToken]);
|
|
98
|
+
(0, import_react.useEffect)(() => {
|
|
99
|
+
function init() {
|
|
100
|
+
if (!window.google) return;
|
|
101
|
+
window.google.accounts.id.initialize({
|
|
102
|
+
client_id: clientId,
|
|
103
|
+
callback: (res) => applyToken(res.credential),
|
|
104
|
+
auto_select: false,
|
|
105
|
+
cancel_on_tap_outside: true
|
|
106
|
+
});
|
|
107
|
+
setReady(true);
|
|
108
|
+
}
|
|
109
|
+
if (window.google) {
|
|
110
|
+
init();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const existing = document.querySelector(
|
|
114
|
+
`script[src="${GSI_SRC}"]`
|
|
115
|
+
);
|
|
116
|
+
if (existing) {
|
|
117
|
+
existing.addEventListener("load", init);
|
|
118
|
+
return () => existing.removeEventListener("load", init);
|
|
119
|
+
}
|
|
120
|
+
const script = document.createElement("script");
|
|
121
|
+
script.src = GSI_SRC;
|
|
122
|
+
script.async = true;
|
|
123
|
+
script.defer = true;
|
|
124
|
+
script.onload = init;
|
|
125
|
+
document.head.appendChild(script);
|
|
126
|
+
}, [clientId, applyToken]);
|
|
127
|
+
(0, import_react.useEffect)(() => {
|
|
128
|
+
const originalFetch = window.fetch;
|
|
129
|
+
window.fetch = async (...args) => {
|
|
130
|
+
const response = await originalFetch(...args);
|
|
131
|
+
if (response.status === 401) {
|
|
132
|
+
try {
|
|
133
|
+
const data = await response.clone().json();
|
|
134
|
+
if (data == null ? void 0 : data.logout) doLogout();
|
|
135
|
+
} catch (e) {
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return response;
|
|
139
|
+
};
|
|
140
|
+
return () => {
|
|
141
|
+
window.fetch = originalFetch;
|
|
142
|
+
};
|
|
143
|
+
}, []);
|
|
144
|
+
function doLogout() {
|
|
145
|
+
var _a;
|
|
146
|
+
(_a = window.google) == null ? void 0 : _a.accounts.id.disableAutoSelect();
|
|
147
|
+
deleteCookie(cookieName);
|
|
148
|
+
setUser(null);
|
|
149
|
+
setIsAdmin(false);
|
|
150
|
+
setIsEditing(false);
|
|
151
|
+
onLogout == null ? void 0 : onLogout();
|
|
152
|
+
}
|
|
153
|
+
const promptSignIn = (0, import_react.useCallback)(() => {
|
|
154
|
+
var _a;
|
|
155
|
+
if (!ready) return;
|
|
156
|
+
(_a = window.google) == null ? void 0 : _a.accounts.id.prompt();
|
|
157
|
+
}, [ready]);
|
|
158
|
+
const renderButton = (0, import_react.useCallback)(
|
|
159
|
+
(el, options) => {
|
|
160
|
+
var _a;
|
|
161
|
+
if (!ready) return;
|
|
162
|
+
(_a = window.google) == null ? void 0 : _a.accounts.id.renderButton(el, options);
|
|
163
|
+
},
|
|
164
|
+
[ready]
|
|
165
|
+
);
|
|
166
|
+
const toggleEdit = (0, import_react.useCallback)(() => setIsEditing((p) => !p), []);
|
|
167
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
168
|
+
GoogleAuthContext.Provider,
|
|
169
|
+
{
|
|
170
|
+
value: {
|
|
171
|
+
user,
|
|
172
|
+
isAdmin,
|
|
173
|
+
isEditing,
|
|
174
|
+
toggleEdit,
|
|
175
|
+
ready,
|
|
176
|
+
promptSignIn,
|
|
177
|
+
renderButton,
|
|
178
|
+
logout: doLogout
|
|
179
|
+
},
|
|
180
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_client.CmsAuthProvider, { value: { isAdmin, isEditing, toggleEdit }, children })
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
function useGoogleAuth() {
|
|
185
|
+
const ctx = (0, import_react.useContext)(GoogleAuthContext);
|
|
186
|
+
if (!ctx) {
|
|
187
|
+
throw new Error("useGoogleAuth must be used within a GoogleAuthProvider");
|
|
188
|
+
}
|
|
189
|
+
return ctx;
|
|
190
|
+
}
|
|
191
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
192
|
+
0 && (module.exports = {
|
|
193
|
+
GoogleAuthProvider,
|
|
194
|
+
useGoogleAuth
|
|
195
|
+
});
|
|
196
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../src/auth/google/client/index.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\n// Imported via the public specifier so we share the SAME context instance as\n// the consumer's edit primitives at runtime (see tsup `external` + tsconfig paths).\nimport { CmsAuthProvider } from \"@dalgoridim/headless-cms/client\";\n\n/**\n * Client provider for **Google Identity Services** sign-in — the Firebase-free\n * counterpart to `FirebaseAuthProvider`. It loads the GSI script, lets the user\n * sign in with Google, stashes the resulting ID token in a cookie for the\n * server `googleAuth` adapter to verify, and feeds the shared CMS auth context\n * so the edit primitives light up. Admin status is optimistic on the client\n * (via `adminEmails`); the server gate remains authoritative.\n */\n\nconst GSI_SRC = \"https://accounts.google.com/gsi/client\";\n\n/** Minimal slice of the Google Identity Services API we use. */\ninterface GsiCredentialResponse {\n credential: string;\n}\ninterface GsiButtonOptions {\n type?: \"standard\" | \"icon\";\n theme?: \"outline\" | \"filled_blue\" | \"filled_black\";\n size?: \"small\" | \"medium\" | \"large\";\n text?: \"signin_with\" | \"signup_with\" | \"continue_with\" | \"signin\";\n shape?: \"rectangular\" | \"pill\" | \"circle\" | \"square\";\n width?: number;\n}\ninterface GsiClient {\n accounts: {\n id: {\n initialize: (config: {\n client_id: string;\n callback: (res: GsiCredentialResponse) => void;\n auto_select?: boolean;\n cancel_on_tap_outside?: boolean;\n }) => void;\n prompt: () => void;\n renderButton: (el: HTMLElement, options?: GsiButtonOptions) => void;\n disableAutoSelect: () => void;\n };\n };\n}\n\ndeclare global {\n interface Window {\n google?: GsiClient;\n }\n}\n\nexport interface GoogleUser {\n sub: string;\n email?: string;\n name?: string;\n picture?: string;\n}\n\nexport interface GoogleAuthProviderProps {\n children: ReactNode;\n /** OAuth 2.0 Web client ID (from Google Cloud Console → Credentials). */\n clientId: string;\n /**\n * Optional admin allowlist for *optimistic* client-side `isAdmin`. The server\n * `googleAuth` adapter still enforces the real gate. Omit to treat any\n * successful sign-in as optimistically admin (server will correct via 401).\n */\n adminEmails?: string[];\n /** Cookie name for the ID token. Default `adminToken`. */\n cookieName?: string;\n /** Called when a 401 `{ logout: true }` response is intercepted. */\n onLogout?: () => void;\n}\n\nexport interface GoogleAuthContextValue {\n user: GoogleUser | null;\n isAdmin: boolean;\n isEditing: boolean;\n toggleEdit: () => void;\n /** True once the GSI script has loaded and the client is initialized. */\n ready: boolean;\n /** Trigger Google One Tap / sign-in prompt. */\n promptSignIn: () => void;\n /** Render the official Google button into `el` (most reliable trigger). */\n renderButton: (el: HTMLElement, options?: GsiButtonOptions) => void;\n logout: () => void;\n}\n\nconst GoogleAuthContext = createContext<GoogleAuthContextValue | undefined>(\n undefined,\n);\n\nfunction setCookie(name: string, value: string) {\n document.cookie = `${name}=${encodeURIComponent(value)}; path=/; samesite=lax`;\n}\nfunction deleteCookie(name: string) {\n document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;\n}\nfunction readCookie(name: string): string | null {\n for (const part of document.cookie.split(\";\")) {\n const [k, ...v] = part.trim().split(\"=\");\n if (k === name) return decodeURIComponent(v.join(\"=\"));\n }\n return null;\n}\n\n/** Decode a JWT payload client-side (no verification — the server does that). */\nfunction decodeJwt(token: string): (GoogleUser & { exp?: number }) | null {\n try {\n const payload = token.split(\".\")[1];\n const json = atob(payload.replace(/-/g, \"+\").replace(/_/g, \"/\"));\n const data = JSON.parse(json) as {\n sub: string;\n email?: string;\n name?: string;\n picture?: string;\n exp?: number;\n };\n return data;\n } catch {\n return null;\n }\n}\n\nexport function GoogleAuthProvider({\n children,\n clientId,\n adminEmails,\n cookieName = \"adminToken\",\n onLogout,\n}: GoogleAuthProviderProps) {\n const [user, setUser] = useState<GoogleUser | null>(null);\n const [isAdmin, setIsAdmin] = useState(false);\n const [isEditing, setIsEditing] = useState(false);\n const [ready, setReady] = useState(false);\n const allowed = useRef(\n (adminEmails ?? []).map((e) => e.trim().toLowerCase()),\n );\n\n const applyToken = useCallback(\n (token: string) => {\n const claims = decodeJwt(token);\n if (!claims) return;\n if (claims.exp && claims.exp * 1000 <= Date.now()) {\n deleteCookie(cookieName);\n return;\n }\n const email = claims.email?.toLowerCase();\n // Optimistic: if no allowlist supplied, trust the sign-in and let the\n // server correct a non-admin via the 401 interceptor below.\n const admin =\n allowed.current.length === 0\n ? true\n : !!email && allowed.current.includes(email);\n setCookie(cookieName, token);\n setUser({\n sub: claims.sub,\n email: claims.email,\n name: claims.name,\n picture: claims.picture,\n });\n setIsAdmin(admin);\n },\n [cookieName],\n );\n\n // Restore an existing session from the cookie on mount.\n useEffect(() => {\n const existing = readCookie(cookieName);\n if (existing) applyToken(existing);\n }, [cookieName, applyToken]);\n\n // Load the GSI script and initialize the One Tap / button client.\n useEffect(() => {\n function init() {\n if (!window.google) return;\n window.google.accounts.id.initialize({\n client_id: clientId,\n callback: (res) => applyToken(res.credential),\n auto_select: false,\n cancel_on_tap_outside: true,\n });\n setReady(true);\n }\n\n if (window.google) {\n init();\n return;\n }\n const existing = document.querySelector<HTMLScriptElement>(\n `script[src=\"${GSI_SRC}\"]`,\n );\n if (existing) {\n existing.addEventListener(\"load\", init);\n return () => existing.removeEventListener(\"load\", init);\n }\n const script = document.createElement(\"script\");\n script.src = GSI_SRC;\n script.async = true;\n script.defer = true;\n script.onload = init;\n document.head.appendChild(script);\n }, [clientId, applyToken]);\n\n // Intercept admin 401s so an expired/forbidden session forces sign-out.\n useEffect(() => {\n const originalFetch = window.fetch;\n window.fetch = async (...args: Parameters<typeof fetch>) => {\n const response = await originalFetch(...args);\n if (response.status === 401) {\n try {\n const data = await response.clone().json();\n if (data?.logout) doLogout();\n } catch {\n /* not a JSON body — ignore */\n }\n }\n return response;\n };\n return () => {\n window.fetch = originalFetch;\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n function doLogout() {\n window.google?.accounts.id.disableAutoSelect();\n deleteCookie(cookieName);\n setUser(null);\n setIsAdmin(false);\n setIsEditing(false);\n onLogout?.();\n }\n\n const promptSignIn = useCallback(() => {\n if (!ready) return;\n window.google?.accounts.id.prompt();\n }, [ready]);\n\n const renderButton = useCallback(\n (el: HTMLElement, options?: GsiButtonOptions) => {\n if (!ready) return;\n window.google?.accounts.id.renderButton(el, options);\n },\n [ready],\n );\n\n const toggleEdit = useCallback(() => setIsEditing((p) => !p), []);\n\n return (\n <GoogleAuthContext.Provider\n value={{\n user,\n isAdmin,\n isEditing,\n toggleEdit,\n ready,\n promptSignIn,\n renderButton,\n logout: doLogout,\n }}\n >\n <CmsAuthProvider value={{ isAdmin, isEditing, toggleEdit }}>\n {children}\n </CmsAuthProvider>\n </GoogleAuthContext.Provider>\n );\n}\n\n/** Google auth API (user/login/logout) for login pages and toolbars. */\nexport function useGoogleAuth(): GoogleAuthContextValue {\n const ctx = useContext(GoogleAuthContext);\n if (!ctx) {\n throw new Error(\"useGoogleAuth must be used within a GoogleAuthProvider\");\n }\n return ctx;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/** Minimal slice of the Google Identity Services API we use. */
|
|
5
|
+
interface GsiCredentialResponse {
|
|
6
|
+
credential: string;
|
|
7
|
+
}
|
|
8
|
+
interface GsiButtonOptions {
|
|
9
|
+
type?: "standard" | "icon";
|
|
10
|
+
theme?: "outline" | "filled_blue" | "filled_black";
|
|
11
|
+
size?: "small" | "medium" | "large";
|
|
12
|
+
text?: "signin_with" | "signup_with" | "continue_with" | "signin";
|
|
13
|
+
shape?: "rectangular" | "pill" | "circle" | "square";
|
|
14
|
+
width?: number;
|
|
15
|
+
}
|
|
16
|
+
interface GsiClient {
|
|
17
|
+
accounts: {
|
|
18
|
+
id: {
|
|
19
|
+
initialize: (config: {
|
|
20
|
+
client_id: string;
|
|
21
|
+
callback: (res: GsiCredentialResponse) => void;
|
|
22
|
+
auto_select?: boolean;
|
|
23
|
+
cancel_on_tap_outside?: boolean;
|
|
24
|
+
}) => void;
|
|
25
|
+
prompt: () => void;
|
|
26
|
+
renderButton: (el: HTMLElement, options?: GsiButtonOptions) => void;
|
|
27
|
+
disableAutoSelect: () => void;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
declare global {
|
|
32
|
+
interface Window {
|
|
33
|
+
google?: GsiClient;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
interface GoogleUser {
|
|
37
|
+
sub: string;
|
|
38
|
+
email?: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
picture?: string;
|
|
41
|
+
}
|
|
42
|
+
interface GoogleAuthProviderProps {
|
|
43
|
+
children: ReactNode;
|
|
44
|
+
/** OAuth 2.0 Web client ID (from Google Cloud Console → Credentials). */
|
|
45
|
+
clientId: string;
|
|
46
|
+
/**
|
|
47
|
+
* Optional admin allowlist for *optimistic* client-side `isAdmin`. The server
|
|
48
|
+
* `googleAuth` adapter still enforces the real gate. Omit to treat any
|
|
49
|
+
* successful sign-in as optimistically admin (server will correct via 401).
|
|
50
|
+
*/
|
|
51
|
+
adminEmails?: string[];
|
|
52
|
+
/** Cookie name for the ID token. Default `adminToken`. */
|
|
53
|
+
cookieName?: string;
|
|
54
|
+
/** Called when a 401 `{ logout: true }` response is intercepted. */
|
|
55
|
+
onLogout?: () => void;
|
|
56
|
+
}
|
|
57
|
+
interface GoogleAuthContextValue {
|
|
58
|
+
user: GoogleUser | null;
|
|
59
|
+
isAdmin: boolean;
|
|
60
|
+
isEditing: boolean;
|
|
61
|
+
toggleEdit: () => void;
|
|
62
|
+
/** True once the GSI script has loaded and the client is initialized. */
|
|
63
|
+
ready: boolean;
|
|
64
|
+
/** Trigger Google One Tap / sign-in prompt. */
|
|
65
|
+
promptSignIn: () => void;
|
|
66
|
+
/** Render the official Google button into `el` (most reliable trigger). */
|
|
67
|
+
renderButton: (el: HTMLElement, options?: GsiButtonOptions) => void;
|
|
68
|
+
logout: () => void;
|
|
69
|
+
}
|
|
70
|
+
declare function GoogleAuthProvider({ children, clientId, adminEmails, cookieName, onLogout, }: GoogleAuthProviderProps): React.JSX.Element;
|
|
71
|
+
/** Google auth API (user/login/logout) for login pages and toolbars. */
|
|
72
|
+
declare function useGoogleAuth(): GoogleAuthContextValue;
|
|
73
|
+
|
|
74
|
+
export { type GoogleAuthContextValue, GoogleAuthProvider, type GoogleAuthProviderProps, type GoogleUser, useGoogleAuth };
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/** Minimal slice of the Google Identity Services API we use. */
|
|
5
|
+
interface GsiCredentialResponse {
|
|
6
|
+
credential: string;
|
|
7
|
+
}
|
|
8
|
+
interface GsiButtonOptions {
|
|
9
|
+
type?: "standard" | "icon";
|
|
10
|
+
theme?: "outline" | "filled_blue" | "filled_black";
|
|
11
|
+
size?: "small" | "medium" | "large";
|
|
12
|
+
text?: "signin_with" | "signup_with" | "continue_with" | "signin";
|
|
13
|
+
shape?: "rectangular" | "pill" | "circle" | "square";
|
|
14
|
+
width?: number;
|
|
15
|
+
}
|
|
16
|
+
interface GsiClient {
|
|
17
|
+
accounts: {
|
|
18
|
+
id: {
|
|
19
|
+
initialize: (config: {
|
|
20
|
+
client_id: string;
|
|
21
|
+
callback: (res: GsiCredentialResponse) => void;
|
|
22
|
+
auto_select?: boolean;
|
|
23
|
+
cancel_on_tap_outside?: boolean;
|
|
24
|
+
}) => void;
|
|
25
|
+
prompt: () => void;
|
|
26
|
+
renderButton: (el: HTMLElement, options?: GsiButtonOptions) => void;
|
|
27
|
+
disableAutoSelect: () => void;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
declare global {
|
|
32
|
+
interface Window {
|
|
33
|
+
google?: GsiClient;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
interface GoogleUser {
|
|
37
|
+
sub: string;
|
|
38
|
+
email?: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
picture?: string;
|
|
41
|
+
}
|
|
42
|
+
interface GoogleAuthProviderProps {
|
|
43
|
+
children: ReactNode;
|
|
44
|
+
/** OAuth 2.0 Web client ID (from Google Cloud Console → Credentials). */
|
|
45
|
+
clientId: string;
|
|
46
|
+
/**
|
|
47
|
+
* Optional admin allowlist for *optimistic* client-side `isAdmin`. The server
|
|
48
|
+
* `googleAuth` adapter still enforces the real gate. Omit to treat any
|
|
49
|
+
* successful sign-in as optimistically admin (server will correct via 401).
|
|
50
|
+
*/
|
|
51
|
+
adminEmails?: string[];
|
|
52
|
+
/** Cookie name for the ID token. Default `adminToken`. */
|
|
53
|
+
cookieName?: string;
|
|
54
|
+
/** Called when a 401 `{ logout: true }` response is intercepted. */
|
|
55
|
+
onLogout?: () => void;
|
|
56
|
+
}
|
|
57
|
+
interface GoogleAuthContextValue {
|
|
58
|
+
user: GoogleUser | null;
|
|
59
|
+
isAdmin: boolean;
|
|
60
|
+
isEditing: boolean;
|
|
61
|
+
toggleEdit: () => void;
|
|
62
|
+
/** True once the GSI script has loaded and the client is initialized. */
|
|
63
|
+
ready: boolean;
|
|
64
|
+
/** Trigger Google One Tap / sign-in prompt. */
|
|
65
|
+
promptSignIn: () => void;
|
|
66
|
+
/** Render the official Google button into `el` (most reliable trigger). */
|
|
67
|
+
renderButton: (el: HTMLElement, options?: GsiButtonOptions) => void;
|
|
68
|
+
logout: () => void;
|
|
69
|
+
}
|
|
70
|
+
declare function GoogleAuthProvider({ children, clientId, adminEmails, cookieName, onLogout, }: GoogleAuthProviderProps): React.JSX.Element;
|
|
71
|
+
/** Google auth API (user/login/logout) for login pages and toolbars. */
|
|
72
|
+
declare function useGoogleAuth(): GoogleAuthContextValue;
|
|
73
|
+
|
|
74
|
+
export { type GoogleAuthContextValue, GoogleAuthProvider, type GoogleAuthProviderProps, type GoogleUser, useGoogleAuth };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/auth/google/client/index.tsx
|
|
4
|
+
import {
|
|
5
|
+
createContext,
|
|
6
|
+
useCallback,
|
|
7
|
+
useContext,
|
|
8
|
+
useEffect,
|
|
9
|
+
useRef,
|
|
10
|
+
useState
|
|
11
|
+
} from "react";
|
|
12
|
+
import { CmsAuthProvider } from "@dalgoridim/headless-cms/client";
|
|
13
|
+
import { jsx } from "react/jsx-runtime";
|
|
14
|
+
var GSI_SRC = "https://accounts.google.com/gsi/client";
|
|
15
|
+
var GoogleAuthContext = createContext(
|
|
16
|
+
void 0
|
|
17
|
+
);
|
|
18
|
+
function setCookie(name, value) {
|
|
19
|
+
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; samesite=lax`;
|
|
20
|
+
}
|
|
21
|
+
function deleteCookie(name) {
|
|
22
|
+
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
|
|
23
|
+
}
|
|
24
|
+
function readCookie(name) {
|
|
25
|
+
for (const part of document.cookie.split(";")) {
|
|
26
|
+
const [k, ...v] = part.trim().split("=");
|
|
27
|
+
if (k === name) return decodeURIComponent(v.join("="));
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function decodeJwt(token) {
|
|
32
|
+
try {
|
|
33
|
+
const payload = token.split(".")[1];
|
|
34
|
+
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
|
|
35
|
+
const data = JSON.parse(json);
|
|
36
|
+
return data;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function GoogleAuthProvider({
|
|
42
|
+
children,
|
|
43
|
+
clientId,
|
|
44
|
+
adminEmails,
|
|
45
|
+
cookieName = "adminToken",
|
|
46
|
+
onLogout
|
|
47
|
+
}) {
|
|
48
|
+
const [user, setUser] = useState(null);
|
|
49
|
+
const [isAdmin, setIsAdmin] = useState(false);
|
|
50
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
51
|
+
const [ready, setReady] = useState(false);
|
|
52
|
+
const allowed = useRef(
|
|
53
|
+
(adminEmails != null ? adminEmails : []).map((e) => e.trim().toLowerCase())
|
|
54
|
+
);
|
|
55
|
+
const applyToken = useCallback(
|
|
56
|
+
(token) => {
|
|
57
|
+
var _a;
|
|
58
|
+
const claims = decodeJwt(token);
|
|
59
|
+
if (!claims) return;
|
|
60
|
+
if (claims.exp && claims.exp * 1e3 <= Date.now()) {
|
|
61
|
+
deleteCookie(cookieName);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const email = (_a = claims.email) == null ? void 0 : _a.toLowerCase();
|
|
65
|
+
const admin = allowed.current.length === 0 ? true : !!email && allowed.current.includes(email);
|
|
66
|
+
setCookie(cookieName, token);
|
|
67
|
+
setUser({
|
|
68
|
+
sub: claims.sub,
|
|
69
|
+
email: claims.email,
|
|
70
|
+
name: claims.name,
|
|
71
|
+
picture: claims.picture
|
|
72
|
+
});
|
|
73
|
+
setIsAdmin(admin);
|
|
74
|
+
},
|
|
75
|
+
[cookieName]
|
|
76
|
+
);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const existing = readCookie(cookieName);
|
|
79
|
+
if (existing) applyToken(existing);
|
|
80
|
+
}, [cookieName, applyToken]);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
function init() {
|
|
83
|
+
if (!window.google) return;
|
|
84
|
+
window.google.accounts.id.initialize({
|
|
85
|
+
client_id: clientId,
|
|
86
|
+
callback: (res) => applyToken(res.credential),
|
|
87
|
+
auto_select: false,
|
|
88
|
+
cancel_on_tap_outside: true
|
|
89
|
+
});
|
|
90
|
+
setReady(true);
|
|
91
|
+
}
|
|
92
|
+
if (window.google) {
|
|
93
|
+
init();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const existing = document.querySelector(
|
|
97
|
+
`script[src="${GSI_SRC}"]`
|
|
98
|
+
);
|
|
99
|
+
if (existing) {
|
|
100
|
+
existing.addEventListener("load", init);
|
|
101
|
+
return () => existing.removeEventListener("load", init);
|
|
102
|
+
}
|
|
103
|
+
const script = document.createElement("script");
|
|
104
|
+
script.src = GSI_SRC;
|
|
105
|
+
script.async = true;
|
|
106
|
+
script.defer = true;
|
|
107
|
+
script.onload = init;
|
|
108
|
+
document.head.appendChild(script);
|
|
109
|
+
}, [clientId, applyToken]);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const originalFetch = window.fetch;
|
|
112
|
+
window.fetch = async (...args) => {
|
|
113
|
+
const response = await originalFetch(...args);
|
|
114
|
+
if (response.status === 401) {
|
|
115
|
+
try {
|
|
116
|
+
const data = await response.clone().json();
|
|
117
|
+
if (data == null ? void 0 : data.logout) doLogout();
|
|
118
|
+
} catch (e) {
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return response;
|
|
122
|
+
};
|
|
123
|
+
return () => {
|
|
124
|
+
window.fetch = originalFetch;
|
|
125
|
+
};
|
|
126
|
+
}, []);
|
|
127
|
+
function doLogout() {
|
|
128
|
+
var _a;
|
|
129
|
+
(_a = window.google) == null ? void 0 : _a.accounts.id.disableAutoSelect();
|
|
130
|
+
deleteCookie(cookieName);
|
|
131
|
+
setUser(null);
|
|
132
|
+
setIsAdmin(false);
|
|
133
|
+
setIsEditing(false);
|
|
134
|
+
onLogout == null ? void 0 : onLogout();
|
|
135
|
+
}
|
|
136
|
+
const promptSignIn = useCallback(() => {
|
|
137
|
+
var _a;
|
|
138
|
+
if (!ready) return;
|
|
139
|
+
(_a = window.google) == null ? void 0 : _a.accounts.id.prompt();
|
|
140
|
+
}, [ready]);
|
|
141
|
+
const renderButton = useCallback(
|
|
142
|
+
(el, options) => {
|
|
143
|
+
var _a;
|
|
144
|
+
if (!ready) return;
|
|
145
|
+
(_a = window.google) == null ? void 0 : _a.accounts.id.renderButton(el, options);
|
|
146
|
+
},
|
|
147
|
+
[ready]
|
|
148
|
+
);
|
|
149
|
+
const toggleEdit = useCallback(() => setIsEditing((p) => !p), []);
|
|
150
|
+
return /* @__PURE__ */ jsx(
|
|
151
|
+
GoogleAuthContext.Provider,
|
|
152
|
+
{
|
|
153
|
+
value: {
|
|
154
|
+
user,
|
|
155
|
+
isAdmin,
|
|
156
|
+
isEditing,
|
|
157
|
+
toggleEdit,
|
|
158
|
+
ready,
|
|
159
|
+
promptSignIn,
|
|
160
|
+
renderButton,
|
|
161
|
+
logout: doLogout
|
|
162
|
+
},
|
|
163
|
+
children: /* @__PURE__ */ jsx(CmsAuthProvider, { value: { isAdmin, isEditing, toggleEdit }, children })
|
|
164
|
+
}
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
function useGoogleAuth() {
|
|
168
|
+
const ctx = useContext(GoogleAuthContext);
|
|
169
|
+
if (!ctx) {
|
|
170
|
+
throw new Error("useGoogleAuth must be used within a GoogleAuthProvider");
|
|
171
|
+
}
|
|
172
|
+
return ctx;
|
|
173
|
+
}
|
|
174
|
+
export {
|
|
175
|
+
GoogleAuthProvider,
|
|
176
|
+
useGoogleAuth
|
|
177
|
+
};
|
|
178
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../src/auth/google/client/index.tsx"],"sourcesContent":["\"use client\";\n\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\n// Imported via the public specifier so we share the SAME context instance as\n// the consumer's edit primitives at runtime (see tsup `external` + tsconfig paths).\nimport { CmsAuthProvider } from \"@dalgoridim/headless-cms/client\";\n\n/**\n * Client provider for **Google Identity Services** sign-in — the Firebase-free\n * counterpart to `FirebaseAuthProvider`. It loads the GSI script, lets the user\n * sign in with Google, stashes the resulting ID token in a cookie for the\n * server `googleAuth` adapter to verify, and feeds the shared CMS auth context\n * so the edit primitives light up. Admin status is optimistic on the client\n * (via `adminEmails`); the server gate remains authoritative.\n */\n\nconst GSI_SRC = \"https://accounts.google.com/gsi/client\";\n\n/** Minimal slice of the Google Identity Services API we use. */\ninterface GsiCredentialResponse {\n credential: string;\n}\ninterface GsiButtonOptions {\n type?: \"standard\" | \"icon\";\n theme?: \"outline\" | \"filled_blue\" | \"filled_black\";\n size?: \"small\" | \"medium\" | \"large\";\n text?: \"signin_with\" | \"signup_with\" | \"continue_with\" | \"signin\";\n shape?: \"rectangular\" | \"pill\" | \"circle\" | \"square\";\n width?: number;\n}\ninterface GsiClient {\n accounts: {\n id: {\n initialize: (config: {\n client_id: string;\n callback: (res: GsiCredentialResponse) => void;\n auto_select?: boolean;\n cancel_on_tap_outside?: boolean;\n }) => void;\n prompt: () => void;\n renderButton: (el: HTMLElement, options?: GsiButtonOptions) => void;\n disableAutoSelect: () => void;\n };\n };\n}\n\ndeclare global {\n interface Window {\n google?: GsiClient;\n }\n}\n\nexport interface GoogleUser {\n sub: string;\n email?: string;\n name?: string;\n picture?: string;\n}\n\nexport interface GoogleAuthProviderProps {\n children: ReactNode;\n /** OAuth 2.0 Web client ID (from Google Cloud Console → Credentials). */\n clientId: string;\n /**\n * Optional admin allowlist for *optimistic* client-side `isAdmin`. The server\n * `googleAuth` adapter still enforces the real gate. Omit to treat any\n * successful sign-in as optimistically admin (server will correct via 401).\n */\n adminEmails?: string[];\n /** Cookie name for the ID token. Default `adminToken`. */\n cookieName?: string;\n /** Called when a 401 `{ logout: true }` response is intercepted. */\n onLogout?: () => void;\n}\n\nexport interface GoogleAuthContextValue {\n user: GoogleUser | null;\n isAdmin: boolean;\n isEditing: boolean;\n toggleEdit: () => void;\n /** True once the GSI script has loaded and the client is initialized. */\n ready: boolean;\n /** Trigger Google One Tap / sign-in prompt. */\n promptSignIn: () => void;\n /** Render the official Google button into `el` (most reliable trigger). */\n renderButton: (el: HTMLElement, options?: GsiButtonOptions) => void;\n logout: () => void;\n}\n\nconst GoogleAuthContext = createContext<GoogleAuthContextValue | undefined>(\n undefined,\n);\n\nfunction setCookie(name: string, value: string) {\n document.cookie = `${name}=${encodeURIComponent(value)}; path=/; samesite=lax`;\n}\nfunction deleteCookie(name: string) {\n document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;\n}\nfunction readCookie(name: string): string | null {\n for (const part of document.cookie.split(\";\")) {\n const [k, ...v] = part.trim().split(\"=\");\n if (k === name) return decodeURIComponent(v.join(\"=\"));\n }\n return null;\n}\n\n/** Decode a JWT payload client-side (no verification — the server does that). */\nfunction decodeJwt(token: string): (GoogleUser & { exp?: number }) | null {\n try {\n const payload = token.split(\".\")[1];\n const json = atob(payload.replace(/-/g, \"+\").replace(/_/g, \"/\"));\n const data = JSON.parse(json) as {\n sub: string;\n email?: string;\n name?: string;\n picture?: string;\n exp?: number;\n };\n return data;\n } catch {\n return null;\n }\n}\n\nexport function GoogleAuthProvider({\n children,\n clientId,\n adminEmails,\n cookieName = \"adminToken\",\n onLogout,\n}: GoogleAuthProviderProps) {\n const [user, setUser] = useState<GoogleUser | null>(null);\n const [isAdmin, setIsAdmin] = useState(false);\n const [isEditing, setIsEditing] = useState(false);\n const [ready, setReady] = useState(false);\n const allowed = useRef(\n (adminEmails ?? []).map((e) => e.trim().toLowerCase()),\n );\n\n const applyToken = useCallback(\n (token: string) => {\n const claims = decodeJwt(token);\n if (!claims) return;\n if (claims.exp && claims.exp * 1000 <= Date.now()) {\n deleteCookie(cookieName);\n return;\n }\n const email = claims.email?.toLowerCase();\n // Optimistic: if no allowlist supplied, trust the sign-in and let the\n // server correct a non-admin via the 401 interceptor below.\n const admin =\n allowed.current.length === 0\n ? true\n : !!email && allowed.current.includes(email);\n setCookie(cookieName, token);\n setUser({\n sub: claims.sub,\n email: claims.email,\n name: claims.name,\n picture: claims.picture,\n });\n setIsAdmin(admin);\n },\n [cookieName],\n );\n\n // Restore an existing session from the cookie on mount.\n useEffect(() => {\n const existing = readCookie(cookieName);\n if (existing) applyToken(existing);\n }, [cookieName, applyToken]);\n\n // Load the GSI script and initialize the One Tap / button client.\n useEffect(() => {\n function init() {\n if (!window.google) return;\n window.google.accounts.id.initialize({\n client_id: clientId,\n callback: (res) => applyToken(res.credential),\n auto_select: false,\n cancel_on_tap_outside: true,\n });\n setReady(true);\n }\n\n if (window.google) {\n init();\n return;\n }\n const existing = document.querySelector<HTMLScriptElement>(\n `script[src=\"${GSI_SRC}\"]`,\n );\n if (existing) {\n existing.addEventListener(\"load\", init);\n return () => existing.removeEventListener(\"load\", init);\n }\n const script = document.createElement(\"script\");\n script.src = GSI_SRC;\n script.async = true;\n script.defer = true;\n script.onload = init;\n document.head.appendChild(script);\n }, [clientId, applyToken]);\n\n // Intercept admin 401s so an expired/forbidden session forces sign-out.\n useEffect(() => {\n const originalFetch = window.fetch;\n window.fetch = async (...args: Parameters<typeof fetch>) => {\n const response = await originalFetch(...args);\n if (response.status === 401) {\n try {\n const data = await response.clone().json();\n if (data?.logout) doLogout();\n } catch {\n /* not a JSON body — ignore */\n }\n }\n return response;\n };\n return () => {\n window.fetch = originalFetch;\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n function doLogout() {\n window.google?.accounts.id.disableAutoSelect();\n deleteCookie(cookieName);\n setUser(null);\n setIsAdmin(false);\n setIsEditing(false);\n onLogout?.();\n }\n\n const promptSignIn = useCallback(() => {\n if (!ready) return;\n window.google?.accounts.id.prompt();\n }, [ready]);\n\n const renderButton = useCallback(\n (el: HTMLElement, options?: GsiButtonOptions) => {\n if (!ready) return;\n window.google?.accounts.id.renderButton(el, options);\n },\n [ready],\n );\n\n const toggleEdit = useCallback(() => setIsEditing((p) => !p), []);\n\n return (\n <GoogleAuthContext.Provider\n value={{\n user,\n isAdmin,\n isEditing,\n toggleEdit,\n ready,\n promptSignIn,\n renderButton,\n logout: doLogout,\n }}\n >\n <CmsAuthProvider value={{ isAdmin, isEditing, toggleEdit }}>\n {children}\n </CmsAuthProvider>\n </GoogleAuthContext.Provider>\n );\n}\n\n/** Google auth API (user/login/logout) for login pages and toolbars. */\nexport function useGoogleAuth(): GoogleAuthContextValue {\n const ctx = useContext(GoogleAuthContext);\n if (!ctx) {\n throw new Error(\"useGoogleAuth must be used within a GoogleAuthProvider\");\n }\n return ctx;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/auth/google/index.ts
|
|
21
|
+
var google_exports = {};
|
|
22
|
+
__export(google_exports, {
|
|
23
|
+
googleAuth: () => googleAuth,
|
|
24
|
+
verifyGoogleIdToken: () => verifyGoogleIdToken
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(google_exports);
|
|
27
|
+
var import_node_crypto = require("crypto");
|
|
28
|
+
var GOOGLE_CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs";
|
|
29
|
+
var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
|
|
30
|
+
var keyCache = null;
|
|
31
|
+
async function getSigningKey(kid) {
|
|
32
|
+
var _a, _b;
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
if (!keyCache || now >= keyCache.expiresAt) {
|
|
35
|
+
const res = await fetch(GOOGLE_CERTS_URL);
|
|
36
|
+
if (!res.ok) throw new Error(`Google JWKS fetch failed: ${res.status}`);
|
|
37
|
+
const body = await res.json();
|
|
38
|
+
const keys = new Map(body.keys.map((k) => [k.kid, k]));
|
|
39
|
+
const maxAge = /max-age=(\d+)/.exec((_a = res.headers.get("cache-control")) != null ? _a : "");
|
|
40
|
+
keyCache = {
|
|
41
|
+
keys,
|
|
42
|
+
expiresAt: now + (maxAge ? Number(maxAge[1]) * 1e3 : 36e5)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return (_b = keyCache.keys.get(kid)) != null ? _b : null;
|
|
46
|
+
}
|
|
47
|
+
function b64urlToBuffer(input) {
|
|
48
|
+
return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
49
|
+
}
|
|
50
|
+
function b64urlToJson(input) {
|
|
51
|
+
return JSON.parse(b64urlToBuffer(input).toString("utf8"));
|
|
52
|
+
}
|
|
53
|
+
function readCookie(req, name) {
|
|
54
|
+
const header = req.headers.get("cookie");
|
|
55
|
+
if (!header) return null;
|
|
56
|
+
for (const part of header.split(";")) {
|
|
57
|
+
const [k, ...v] = part.trim().split("=");
|
|
58
|
+
if (k === name) return decodeURIComponent(v.join("="));
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
async function verifyGoogleIdToken(token, opts) {
|
|
63
|
+
var _a, _b, _c;
|
|
64
|
+
const parts = token.split(".");
|
|
65
|
+
if (parts.length !== 3) throw new Error("Malformed ID token");
|
|
66
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
67
|
+
const header = b64urlToJson(headerB64);
|
|
68
|
+
if (header.alg !== "RS256") throw new Error(`Unexpected alg: ${header.alg}`);
|
|
69
|
+
const jwk = await getSigningKey(header.kid);
|
|
70
|
+
if (!jwk) throw new Error("No matching Google signing key for token");
|
|
71
|
+
const publicKey = (0, import_node_crypto.createPublicKey)({
|
|
72
|
+
key: jwk,
|
|
73
|
+
format: "jwk"
|
|
74
|
+
});
|
|
75
|
+
const verifier = (0, import_node_crypto.createVerify)("RSA-SHA256");
|
|
76
|
+
verifier.update(`${headerB64}.${payloadB64}`);
|
|
77
|
+
verifier.end();
|
|
78
|
+
if (!verifier.verify(publicKey, b64urlToBuffer(signatureB64))) {
|
|
79
|
+
throw new Error("Invalid ID token signature");
|
|
80
|
+
}
|
|
81
|
+
const payload = b64urlToJson(payloadB64);
|
|
82
|
+
const nowSec = Math.floor(((_b = (_a = opts.now) == null ? void 0 : _a.call(opts)) != null ? _b : Date.now()) / 1e3);
|
|
83
|
+
if (payload.exp <= nowSec) throw new Error("ID token expired");
|
|
84
|
+
const issuers = (_c = opts.issuers) != null ? _c : GOOGLE_ISSUERS;
|
|
85
|
+
if (!issuers.includes(payload.iss)) {
|
|
86
|
+
throw new Error(`Untrusted issuer: ${payload.iss}`);
|
|
87
|
+
}
|
|
88
|
+
const audiences = Array.isArray(opts.clientId) ? opts.clientId : [opts.clientId];
|
|
89
|
+
if (!audiences.includes(payload.aud)) throw new Error("Audience mismatch");
|
|
90
|
+
return payload;
|
|
91
|
+
}
|
|
92
|
+
function googleAuth(config) {
|
|
93
|
+
var _a;
|
|
94
|
+
const cookieName = (_a = config.cookieName) != null ? _a : "adminToken";
|
|
95
|
+
const allowed = config.adminEmails.map((e) => e.trim().toLowerCase());
|
|
96
|
+
return {
|
|
97
|
+
async verifyRequest(req) {
|
|
98
|
+
var _a2;
|
|
99
|
+
const token = readCookie(req, cookieName);
|
|
100
|
+
if (!token) return null;
|
|
101
|
+
let payload;
|
|
102
|
+
try {
|
|
103
|
+
payload = await verifyGoogleIdToken(token, {
|
|
104
|
+
clientId: config.clientId,
|
|
105
|
+
issuers: config.issuers
|
|
106
|
+
});
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const email = (_a2 = payload.email) == null ? void 0 : _a2.toLowerCase();
|
|
111
|
+
const isAdmin = !!email && payload.email_verified === true && allowed.includes(email);
|
|
112
|
+
return { userId: payload.sub, email: payload.email, isAdmin };
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
117
|
+
0 && (module.exports = {
|
|
118
|
+
googleAuth,
|
|
119
|
+
verifyGoogleIdToken
|
|
120
|
+
});
|
|
121
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/auth/google/index.ts"],"sourcesContent":["import { createPublicKey, createVerify, type JsonWebKey } from \"node:crypto\";\nimport type { AuthAdapter, AuthIdentity } from \"../../types\";\n\n/**\n * Server auth adapter backed by **Google Identity Services** ID tokens — the\n * lightweight alternative to the Firebase adapter when all you want is \"Sign in\n * with Google\". No Firebase project, no service account: the browser obtains a\n * signed Google ID token (a JWT), drops it in a cookie, and this adapter\n * verifies it locally against Google's public keys, then gates on an email\n * allowlist.\n *\n * Verification is dependency-free (Node's built-in `crypto` + `fetch`), so the\n * package keeps its zero-runtime-deps contract.\n */\n\nconst GOOGLE_CERTS_URL = \"https://www.googleapis.com/oauth2/v3/certs\";\nconst GOOGLE_ISSUERS = [\"https://accounts.google.com\", \"accounts.google.com\"];\n\n/** A Google JWKS entry (RSA public key in JWK form). */\ninterface GoogleJwk {\n kid: string;\n kty: string;\n n: string;\n e: string;\n alg?: string;\n use?: string;\n}\n\nlet keyCache: { keys: Map<string, GoogleJwk>; expiresAt: number } | null = null;\n\n/** Fetch (and cache, honoring `cache-control: max-age`) Google's signing keys. */\nasync function getSigningKey(kid: string): Promise<GoogleJwk | null> {\n const now = Date.now();\n if (!keyCache || now >= keyCache.expiresAt) {\n const res = await fetch(GOOGLE_CERTS_URL);\n if (!res.ok) throw new Error(`Google JWKS fetch failed: ${res.status}`);\n const body = (await res.json()) as { keys: GoogleJwk[] };\n const keys = new Map(body.keys.map((k) => [k.kid, k]));\n const maxAge = /max-age=(\\d+)/.exec(res.headers.get(\"cache-control\") ?? \"\");\n keyCache = {\n keys,\n expiresAt: now + (maxAge ? Number(maxAge[1]) * 1000 : 3_600_000),\n };\n }\n return keyCache.keys.get(kid) ?? null;\n}\n\nfunction b64urlToBuffer(input: string): Buffer {\n return Buffer.from(input.replace(/-/g, \"+\").replace(/_/g, \"/\"), \"base64\");\n}\n\nfunction b64urlToJson<T>(input: string): T {\n return JSON.parse(b64urlToBuffer(input).toString(\"utf8\")) as T;\n}\n\nfunction readCookie(req: Request, name: string): string | null {\n const header = req.headers.get(\"cookie\");\n if (!header) return null;\n for (const part of header.split(\";\")) {\n const [k, ...v] = part.trim().split(\"=\");\n if (k === name) return decodeURIComponent(v.join(\"=\"));\n }\n return null;\n}\n\n/** Claims of interest from a verified Google ID token. */\nexport interface GoogleIdTokenPayload {\n iss: string;\n aud: string;\n sub: string;\n exp: number;\n iat: number;\n email?: string;\n email_verified?: boolean;\n name?: string;\n picture?: string;\n}\n\nexport interface VerifyGoogleIdTokenOptions {\n /** Expected audience — your OAuth 2.0 Web client ID(s). */\n clientId: string | string[];\n /** Accepted issuers. Defaults to Google's two canonical issuers. */\n issuers?: string[];\n /** Clock injection for tests. Defaults to `Date.now`. */\n now?: () => number;\n}\n\n/**\n * Verify a Google ID token end-to-end: RS256 signature against Google's JWKS,\n * plus `exp` / `iss` / `aud` checks. Throws on any failure; resolves with the\n * decoded payload on success. Exported standalone for custom flows/tests.\n */\nexport async function verifyGoogleIdToken(\n token: string,\n opts: VerifyGoogleIdTokenOptions,\n): Promise<GoogleIdTokenPayload> {\n const parts = token.split(\".\");\n if (parts.length !== 3) throw new Error(\"Malformed ID token\");\n const [headerB64, payloadB64, signatureB64] = parts;\n\n const header = b64urlToJson<{ alg: string; kid: string }>(headerB64);\n if (header.alg !== \"RS256\") throw new Error(`Unexpected alg: ${header.alg}`);\n\n const jwk = await getSigningKey(header.kid);\n if (!jwk) throw new Error(\"No matching Google signing key for token\");\n\n const publicKey = createPublicKey({\n key: jwk as unknown as JsonWebKey,\n format: \"jwk\",\n });\n const verifier = createVerify(\"RSA-SHA256\");\n verifier.update(`${headerB64}.${payloadB64}`);\n verifier.end();\n if (!verifier.verify(publicKey, b64urlToBuffer(signatureB64))) {\n throw new Error(\"Invalid ID token signature\");\n }\n\n const payload = b64urlToJson<GoogleIdTokenPayload>(payloadB64);\n const nowSec = Math.floor((opts.now?.() ?? Date.now()) / 1000);\n if (payload.exp <= nowSec) throw new Error(\"ID token expired\");\n\n const issuers = opts.issuers ?? GOOGLE_ISSUERS;\n if (!issuers.includes(payload.iss)) {\n throw new Error(`Untrusted issuer: ${payload.iss}`);\n }\n const audiences = Array.isArray(opts.clientId)\n ? opts.clientId\n : [opts.clientId];\n if (!audiences.includes(payload.aud)) throw new Error(\"Audience mismatch\");\n\n return payload;\n}\n\nexport interface GoogleAuthConfig {\n /** OAuth 2.0 Web client ID(s); the token's `aud` must match one of these. */\n clientId: string | string[];\n /** Allowlist of admin emails (compared case-insensitively). */\n adminEmails: string[];\n /** Cookie carrying the Google ID token. Default `adminToken`. */\n cookieName?: string;\n /** Accepted issuers (override for testing). */\n issuers?: string[];\n}\n\n/**\n * Build an {@link AuthAdapter} that verifies the Google ID token from the\n * request cookie and grants admin only to a verified email in `adminEmails`.\n */\nexport function googleAuth(config: GoogleAuthConfig): AuthAdapter {\n const cookieName = config.cookieName ?? \"adminToken\";\n const allowed = config.adminEmails.map((e) => e.trim().toLowerCase());\n\n return {\n async verifyRequest(req: Request): Promise<AuthIdentity | null> {\n const token = readCookie(req, cookieName);\n if (!token) return null;\n\n let payload: GoogleIdTokenPayload;\n try {\n payload = await verifyGoogleIdToken(token, {\n clientId: config.clientId,\n issuers: config.issuers,\n });\n } catch {\n // Expired/invalid/tampered → unauthorized, so the gate emits a\n // 401 { logout: true } and the client can force sign-out.\n return null;\n }\n\n const email = payload.email?.toLowerCase();\n const isAdmin =\n !!email && payload.email_verified === true && allowed.includes(email);\n\n return { userId: payload.sub, email: payload.email, isAdmin };\n },\n };\n}\n"],"mappings":"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","names":["_a"]}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { AuthAdapter } from '../../index.cjs';
|
|
2
|
+
|
|
3
|
+
/** Claims of interest from a verified Google ID token. */
|
|
4
|
+
interface GoogleIdTokenPayload {
|
|
5
|
+
iss: string;
|
|
6
|
+
aud: string;
|
|
7
|
+
sub: string;
|
|
8
|
+
exp: number;
|
|
9
|
+
iat: number;
|
|
10
|
+
email?: string;
|
|
11
|
+
email_verified?: boolean;
|
|
12
|
+
name?: string;
|
|
13
|
+
picture?: string;
|
|
14
|
+
}
|
|
15
|
+
interface VerifyGoogleIdTokenOptions {
|
|
16
|
+
/** Expected audience — your OAuth 2.0 Web client ID(s). */
|
|
17
|
+
clientId: string | string[];
|
|
18
|
+
/** Accepted issuers. Defaults to Google's two canonical issuers. */
|
|
19
|
+
issuers?: string[];
|
|
20
|
+
/** Clock injection for tests. Defaults to `Date.now`. */
|
|
21
|
+
now?: () => number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Verify a Google ID token end-to-end: RS256 signature against Google's JWKS,
|
|
25
|
+
* plus `exp` / `iss` / `aud` checks. Throws on any failure; resolves with the
|
|
26
|
+
* decoded payload on success. Exported standalone for custom flows/tests.
|
|
27
|
+
*/
|
|
28
|
+
declare function verifyGoogleIdToken(token: string, opts: VerifyGoogleIdTokenOptions): Promise<GoogleIdTokenPayload>;
|
|
29
|
+
interface GoogleAuthConfig {
|
|
30
|
+
/** OAuth 2.0 Web client ID(s); the token's `aud` must match one of these. */
|
|
31
|
+
clientId: string | string[];
|
|
32
|
+
/** Allowlist of admin emails (compared case-insensitively). */
|
|
33
|
+
adminEmails: string[];
|
|
34
|
+
/** Cookie carrying the Google ID token. Default `adminToken`. */
|
|
35
|
+
cookieName?: string;
|
|
36
|
+
/** Accepted issuers (override for testing). */
|
|
37
|
+
issuers?: string[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build an {@link AuthAdapter} that verifies the Google ID token from the
|
|
41
|
+
* request cookie and grants admin only to a verified email in `adminEmails`.
|
|
42
|
+
*/
|
|
43
|
+
declare function googleAuth(config: GoogleAuthConfig): AuthAdapter;
|
|
44
|
+
|
|
45
|
+
export { type GoogleAuthConfig, type GoogleIdTokenPayload, type VerifyGoogleIdTokenOptions, googleAuth, verifyGoogleIdToken };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { AuthAdapter } from '../../index.js';
|
|
2
|
+
|
|
3
|
+
/** Claims of interest from a verified Google ID token. */
|
|
4
|
+
interface GoogleIdTokenPayload {
|
|
5
|
+
iss: string;
|
|
6
|
+
aud: string;
|
|
7
|
+
sub: string;
|
|
8
|
+
exp: number;
|
|
9
|
+
iat: number;
|
|
10
|
+
email?: string;
|
|
11
|
+
email_verified?: boolean;
|
|
12
|
+
name?: string;
|
|
13
|
+
picture?: string;
|
|
14
|
+
}
|
|
15
|
+
interface VerifyGoogleIdTokenOptions {
|
|
16
|
+
/** Expected audience — your OAuth 2.0 Web client ID(s). */
|
|
17
|
+
clientId: string | string[];
|
|
18
|
+
/** Accepted issuers. Defaults to Google's two canonical issuers. */
|
|
19
|
+
issuers?: string[];
|
|
20
|
+
/** Clock injection for tests. Defaults to `Date.now`. */
|
|
21
|
+
now?: () => number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Verify a Google ID token end-to-end: RS256 signature against Google's JWKS,
|
|
25
|
+
* plus `exp` / `iss` / `aud` checks. Throws on any failure; resolves with the
|
|
26
|
+
* decoded payload on success. Exported standalone for custom flows/tests.
|
|
27
|
+
*/
|
|
28
|
+
declare function verifyGoogleIdToken(token: string, opts: VerifyGoogleIdTokenOptions): Promise<GoogleIdTokenPayload>;
|
|
29
|
+
interface GoogleAuthConfig {
|
|
30
|
+
/** OAuth 2.0 Web client ID(s); the token's `aud` must match one of these. */
|
|
31
|
+
clientId: string | string[];
|
|
32
|
+
/** Allowlist of admin emails (compared case-insensitively). */
|
|
33
|
+
adminEmails: string[];
|
|
34
|
+
/** Cookie carrying the Google ID token. Default `adminToken`. */
|
|
35
|
+
cookieName?: string;
|
|
36
|
+
/** Accepted issuers (override for testing). */
|
|
37
|
+
issuers?: string[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Build an {@link AuthAdapter} that verifies the Google ID token from the
|
|
41
|
+
* request cookie and grants admin only to a verified email in `adminEmails`.
|
|
42
|
+
*/
|
|
43
|
+
declare function googleAuth(config: GoogleAuthConfig): AuthAdapter;
|
|
44
|
+
|
|
45
|
+
export { type GoogleAuthConfig, type GoogleIdTokenPayload, type VerifyGoogleIdTokenOptions, googleAuth, verifyGoogleIdToken };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// src/auth/google/index.ts
|
|
2
|
+
import { createPublicKey, createVerify } from "crypto";
|
|
3
|
+
var GOOGLE_CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs";
|
|
4
|
+
var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
|
|
5
|
+
var keyCache = null;
|
|
6
|
+
async function getSigningKey(kid) {
|
|
7
|
+
var _a, _b;
|
|
8
|
+
const now = Date.now();
|
|
9
|
+
if (!keyCache || now >= keyCache.expiresAt) {
|
|
10
|
+
const res = await fetch(GOOGLE_CERTS_URL);
|
|
11
|
+
if (!res.ok) throw new Error(`Google JWKS fetch failed: ${res.status}`);
|
|
12
|
+
const body = await res.json();
|
|
13
|
+
const keys = new Map(body.keys.map((k) => [k.kid, k]));
|
|
14
|
+
const maxAge = /max-age=(\d+)/.exec((_a = res.headers.get("cache-control")) != null ? _a : "");
|
|
15
|
+
keyCache = {
|
|
16
|
+
keys,
|
|
17
|
+
expiresAt: now + (maxAge ? Number(maxAge[1]) * 1e3 : 36e5)
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return (_b = keyCache.keys.get(kid)) != null ? _b : null;
|
|
21
|
+
}
|
|
22
|
+
function b64urlToBuffer(input) {
|
|
23
|
+
return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
24
|
+
}
|
|
25
|
+
function b64urlToJson(input) {
|
|
26
|
+
return JSON.parse(b64urlToBuffer(input).toString("utf8"));
|
|
27
|
+
}
|
|
28
|
+
function readCookie(req, name) {
|
|
29
|
+
const header = req.headers.get("cookie");
|
|
30
|
+
if (!header) return null;
|
|
31
|
+
for (const part of header.split(";")) {
|
|
32
|
+
const [k, ...v] = part.trim().split("=");
|
|
33
|
+
if (k === name) return decodeURIComponent(v.join("="));
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
async function verifyGoogleIdToken(token, opts) {
|
|
38
|
+
var _a, _b, _c;
|
|
39
|
+
const parts = token.split(".");
|
|
40
|
+
if (parts.length !== 3) throw new Error("Malformed ID token");
|
|
41
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
42
|
+
const header = b64urlToJson(headerB64);
|
|
43
|
+
if (header.alg !== "RS256") throw new Error(`Unexpected alg: ${header.alg}`);
|
|
44
|
+
const jwk = await getSigningKey(header.kid);
|
|
45
|
+
if (!jwk) throw new Error("No matching Google signing key for token");
|
|
46
|
+
const publicKey = createPublicKey({
|
|
47
|
+
key: jwk,
|
|
48
|
+
format: "jwk"
|
|
49
|
+
});
|
|
50
|
+
const verifier = createVerify("RSA-SHA256");
|
|
51
|
+
verifier.update(`${headerB64}.${payloadB64}`);
|
|
52
|
+
verifier.end();
|
|
53
|
+
if (!verifier.verify(publicKey, b64urlToBuffer(signatureB64))) {
|
|
54
|
+
throw new Error("Invalid ID token signature");
|
|
55
|
+
}
|
|
56
|
+
const payload = b64urlToJson(payloadB64);
|
|
57
|
+
const nowSec = Math.floor(((_b = (_a = opts.now) == null ? void 0 : _a.call(opts)) != null ? _b : Date.now()) / 1e3);
|
|
58
|
+
if (payload.exp <= nowSec) throw new Error("ID token expired");
|
|
59
|
+
const issuers = (_c = opts.issuers) != null ? _c : GOOGLE_ISSUERS;
|
|
60
|
+
if (!issuers.includes(payload.iss)) {
|
|
61
|
+
throw new Error(`Untrusted issuer: ${payload.iss}`);
|
|
62
|
+
}
|
|
63
|
+
const audiences = Array.isArray(opts.clientId) ? opts.clientId : [opts.clientId];
|
|
64
|
+
if (!audiences.includes(payload.aud)) throw new Error("Audience mismatch");
|
|
65
|
+
return payload;
|
|
66
|
+
}
|
|
67
|
+
function googleAuth(config) {
|
|
68
|
+
var _a;
|
|
69
|
+
const cookieName = (_a = config.cookieName) != null ? _a : "adminToken";
|
|
70
|
+
const allowed = config.adminEmails.map((e) => e.trim().toLowerCase());
|
|
71
|
+
return {
|
|
72
|
+
async verifyRequest(req) {
|
|
73
|
+
var _a2;
|
|
74
|
+
const token = readCookie(req, cookieName);
|
|
75
|
+
if (!token) return null;
|
|
76
|
+
let payload;
|
|
77
|
+
try {
|
|
78
|
+
payload = await verifyGoogleIdToken(token, {
|
|
79
|
+
clientId: config.clientId,
|
|
80
|
+
issuers: config.issuers
|
|
81
|
+
});
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const email = (_a2 = payload.email) == null ? void 0 : _a2.toLowerCase();
|
|
86
|
+
const isAdmin = !!email && payload.email_verified === true && allowed.includes(email);
|
|
87
|
+
return { userId: payload.sub, email: payload.email, isAdmin };
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export {
|
|
92
|
+
googleAuth,
|
|
93
|
+
verifyGoogleIdToken
|
|
94
|
+
};
|
|
95
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/auth/google/index.ts"],"sourcesContent":["import { createPublicKey, createVerify, type JsonWebKey } from \"node:crypto\";\nimport type { AuthAdapter, AuthIdentity } from \"../../types\";\n\n/**\n * Server auth adapter backed by **Google Identity Services** ID tokens — the\n * lightweight alternative to the Firebase adapter when all you want is \"Sign in\n * with Google\". No Firebase project, no service account: the browser obtains a\n * signed Google ID token (a JWT), drops it in a cookie, and this adapter\n * verifies it locally against Google's public keys, then gates on an email\n * allowlist.\n *\n * Verification is dependency-free (Node's built-in `crypto` + `fetch`), so the\n * package keeps its zero-runtime-deps contract.\n */\n\nconst GOOGLE_CERTS_URL = \"https://www.googleapis.com/oauth2/v3/certs\";\nconst GOOGLE_ISSUERS = [\"https://accounts.google.com\", \"accounts.google.com\"];\n\n/** A Google JWKS entry (RSA public key in JWK form). */\ninterface GoogleJwk {\n kid: string;\n kty: string;\n n: string;\n e: string;\n alg?: string;\n use?: string;\n}\n\nlet keyCache: { keys: Map<string, GoogleJwk>; expiresAt: number } | null = null;\n\n/** Fetch (and cache, honoring `cache-control: max-age`) Google's signing keys. */\nasync function getSigningKey(kid: string): Promise<GoogleJwk | null> {\n const now = Date.now();\n if (!keyCache || now >= keyCache.expiresAt) {\n const res = await fetch(GOOGLE_CERTS_URL);\n if (!res.ok) throw new Error(`Google JWKS fetch failed: ${res.status}`);\n const body = (await res.json()) as { keys: GoogleJwk[] };\n const keys = new Map(body.keys.map((k) => [k.kid, k]));\n const maxAge = /max-age=(\\d+)/.exec(res.headers.get(\"cache-control\") ?? \"\");\n keyCache = {\n keys,\n expiresAt: now + (maxAge ? Number(maxAge[1]) * 1000 : 3_600_000),\n };\n }\n return keyCache.keys.get(kid) ?? null;\n}\n\nfunction b64urlToBuffer(input: string): Buffer {\n return Buffer.from(input.replace(/-/g, \"+\").replace(/_/g, \"/\"), \"base64\");\n}\n\nfunction b64urlToJson<T>(input: string): T {\n return JSON.parse(b64urlToBuffer(input).toString(\"utf8\")) as T;\n}\n\nfunction readCookie(req: Request, name: string): string | null {\n const header = req.headers.get(\"cookie\");\n if (!header) return null;\n for (const part of header.split(\";\")) {\n const [k, ...v] = part.trim().split(\"=\");\n if (k === name) return decodeURIComponent(v.join(\"=\"));\n }\n return null;\n}\n\n/** Claims of interest from a verified Google ID token. */\nexport interface GoogleIdTokenPayload {\n iss: string;\n aud: string;\n sub: string;\n exp: number;\n iat: number;\n email?: string;\n email_verified?: boolean;\n name?: string;\n picture?: string;\n}\n\nexport interface VerifyGoogleIdTokenOptions {\n /** Expected audience — your OAuth 2.0 Web client ID(s). */\n clientId: string | string[];\n /** Accepted issuers. Defaults to Google's two canonical issuers. */\n issuers?: string[];\n /** Clock injection for tests. Defaults to `Date.now`. */\n now?: () => number;\n}\n\n/**\n * Verify a Google ID token end-to-end: RS256 signature against Google's JWKS,\n * plus `exp` / `iss` / `aud` checks. Throws on any failure; resolves with the\n * decoded payload on success. Exported standalone for custom flows/tests.\n */\nexport async function verifyGoogleIdToken(\n token: string,\n opts: VerifyGoogleIdTokenOptions,\n): Promise<GoogleIdTokenPayload> {\n const parts = token.split(\".\");\n if (parts.length !== 3) throw new Error(\"Malformed ID token\");\n const [headerB64, payloadB64, signatureB64] = parts;\n\n const header = b64urlToJson<{ alg: string; kid: string }>(headerB64);\n if (header.alg !== \"RS256\") throw new Error(`Unexpected alg: ${header.alg}`);\n\n const jwk = await getSigningKey(header.kid);\n if (!jwk) throw new Error(\"No matching Google signing key for token\");\n\n const publicKey = createPublicKey({\n key: jwk as unknown as JsonWebKey,\n format: \"jwk\",\n });\n const verifier = createVerify(\"RSA-SHA256\");\n verifier.update(`${headerB64}.${payloadB64}`);\n verifier.end();\n if (!verifier.verify(publicKey, b64urlToBuffer(signatureB64))) {\n throw new Error(\"Invalid ID token signature\");\n }\n\n const payload = b64urlToJson<GoogleIdTokenPayload>(payloadB64);\n const nowSec = Math.floor((opts.now?.() ?? Date.now()) / 1000);\n if (payload.exp <= nowSec) throw new Error(\"ID token expired\");\n\n const issuers = opts.issuers ?? GOOGLE_ISSUERS;\n if (!issuers.includes(payload.iss)) {\n throw new Error(`Untrusted issuer: ${payload.iss}`);\n }\n const audiences = Array.isArray(opts.clientId)\n ? opts.clientId\n : [opts.clientId];\n if (!audiences.includes(payload.aud)) throw new Error(\"Audience mismatch\");\n\n return payload;\n}\n\nexport interface GoogleAuthConfig {\n /** OAuth 2.0 Web client ID(s); the token's `aud` must match one of these. */\n clientId: string | string[];\n /** Allowlist of admin emails (compared case-insensitively). */\n adminEmails: string[];\n /** Cookie carrying the Google ID token. Default `adminToken`. */\n cookieName?: string;\n /** Accepted issuers (override for testing). */\n issuers?: string[];\n}\n\n/**\n * Build an {@link AuthAdapter} that verifies the Google ID token from the\n * request cookie and grants admin only to a verified email in `adminEmails`.\n */\nexport function googleAuth(config: GoogleAuthConfig): AuthAdapter {\n const cookieName = config.cookieName ?? \"adminToken\";\n const allowed = config.adminEmails.map((e) => e.trim().toLowerCase());\n\n return {\n async verifyRequest(req: Request): Promise<AuthIdentity | null> {\n const token = readCookie(req, cookieName);\n if (!token) return null;\n\n let payload: GoogleIdTokenPayload;\n try {\n payload = await verifyGoogleIdToken(token, {\n clientId: config.clientId,\n issuers: config.issuers,\n });\n } catch {\n // Expired/invalid/tampered → unauthorized, so the gate emits a\n // 401 { logout: true } and the client can force sign-out.\n return null;\n }\n\n const email = payload.email?.toLowerCase();\n const isAdmin =\n !!email && payload.email_verified === true && allowed.includes(email);\n\n return { userId: payload.sub, email: payload.email, isAdmin };\n },\n };\n}\n"],"mappings":"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","names":["_a"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dalgoridim/headless-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Database-agnostic, inline-edit headless CMS engine for React / Next.js apps",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "dalgoridim",
|
|
@@ -92,17 +92,28 @@
|
|
|
92
92
|
"types": "./dist/auth/nextauth/index.d.ts",
|
|
93
93
|
"import": "./dist/auth/nextauth/index.js",
|
|
94
94
|
"require": "./dist/auth/nextauth/index.cjs"
|
|
95
|
+
},
|
|
96
|
+
"./auth/google": {
|
|
97
|
+
"types": "./dist/auth/google/index.d.ts",
|
|
98
|
+
"import": "./dist/auth/google/index.js",
|
|
99
|
+
"require": "./dist/auth/google/index.cjs"
|
|
100
|
+
},
|
|
101
|
+
"./auth/google/client": {
|
|
102
|
+
"types": "./dist/auth/google/client/index.d.ts",
|
|
103
|
+
"import": "./dist/auth/google/client/index.js",
|
|
104
|
+
"require": "./dist/auth/google/client/index.cjs"
|
|
95
105
|
}
|
|
96
106
|
},
|
|
97
107
|
"files": [
|
|
98
108
|
"dist",
|
|
99
|
-
"ARCHITECTURE.md"
|
|
100
|
-
"REDESIGN.md"
|
|
109
|
+
"ARCHITECTURE.md"
|
|
101
110
|
],
|
|
102
111
|
"scripts": {
|
|
103
112
|
"build": "tsup",
|
|
104
113
|
"dev": "tsup --watch",
|
|
105
114
|
"typecheck": "tsc --noEmit",
|
|
115
|
+
"test": "vitest run",
|
|
116
|
+
"test:watch": "vitest",
|
|
106
117
|
"prepare": "tsup"
|
|
107
118
|
},
|
|
108
119
|
"peerDependencies": {
|
|
@@ -155,6 +166,7 @@
|
|
|
155
166
|
"react": "^19.0.0",
|
|
156
167
|
"react-dom": "^19.0.0",
|
|
157
168
|
"tsup": "^8.5.1",
|
|
158
|
-
"typescript": "^5.7.0"
|
|
169
|
+
"typescript": "^5.7.0",
|
|
170
|
+
"vitest": "^3.0.0"
|
|
159
171
|
}
|
|
160
172
|
}
|
package/REDESIGN.md
DELETED
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
# Headless CMS — "Harness, Not Framework" Redesign
|
|
2
|
-
|
|
3
|
-
> Full spec for the "harness, not framework" redesign. Living document — kept in
|
|
4
|
-
> sync as the two tracks land.
|
|
5
|
-
|
|
6
|
-
## Context
|
|
7
|
-
|
|
8
|
-
`@dalgoridim/headless-cms` was extracted from `portfolio-2025`. An audit of what
|
|
9
|
-
the package *forces* on its consumers found two layers with opposite philosophies:
|
|
10
|
-
|
|
11
|
-
- **Data / server layer — already free.** `Section` is schemaless
|
|
12
|
-
(`{ id, collection, [key]: any }`), the Postgres adapter never drops unmapped
|
|
13
|
-
fields (JSONB `documents` fallback + `extra` column), and `DataAdapter` is
|
|
14
|
-
generic. Good.
|
|
15
|
-
- **UI / client layer — a framework, not a harness.** The components ship a
|
|
16
|
-
finished, portfolio-specific look and hard dependencies. A consumer adopting
|
|
17
|
-
the package today **inherits portfolio-2025's visual design** and is forced
|
|
18
|
-
into Tailwind, a dark `neutral` palette, a `primary` design token,
|
|
19
|
-
`lucide-react`, `sonner`, and a bespoke text-mark syntax.
|
|
20
|
-
|
|
21
|
-
The goal: make the **whole** package a harness — wire behavior, impose nothing.
|
|
22
|
-
Two tracks, shipped together:
|
|
23
|
-
|
|
24
|
-
- **Track A** — de-framework-ize the UI (the philosophy fix; highest impact).
|
|
25
|
-
- **Track B** — close the four data-layer capability gaps (query power,
|
|
26
|
-
relations, loosened `id`/`collection`, auth flexibility).
|
|
27
|
-
|
|
28
|
-
### Decisions locked in with the user
|
|
29
|
-
- Package goes **pure headless**; the current dark/Tailwind look is **moved into
|
|
30
|
-
portfolio-2025's own shim components**, not kept in the package.
|
|
31
|
-
- API optimized for **DX**: `className`/`style` passthrough + `data-*` state
|
|
32
|
-
attributes + injectable deps (icons / notifier / markup parser); render-props
|
|
33
|
-
/ `asChild` only where it genuinely improves control.
|
|
34
|
-
- **Both tracks in one pass.**
|
|
35
|
-
|
|
36
|
-
### Guiding principles (apply to every change)
|
|
37
|
-
1. Ship **unstyled** by default — no Tailwind, no palette, no `primary` token.
|
|
38
|
-
2. Expose **styling hooks** — `className`, `style`, `data-*` state attributes,
|
|
39
|
-
and render-props/`asChild` for complex chrome.
|
|
40
|
-
3. Make deps **injectable, not baked** — icons, toasts, markup parser.
|
|
41
|
-
4. **No portfolio-specific code** in the package.
|
|
42
|
-
5. Never silently break portfolio — package + portfolio shims change in lockstep.
|
|
43
|
-
|
|
44
|
-
---
|
|
45
|
-
|
|
46
|
-
## Current forcing inventory (what we are removing)
|
|
47
|
-
|
|
48
|
-
| Forced thing | Location | Resolution |
|
|
49
|
-
|---|---|---|
|
|
50
|
-
| Tailwind mandatory (`cn`, utility strings) | all client components, `client/utils.ts` | Strip utility strings; `cn`/`tailwind-merge` leaves core. |
|
|
51
|
-
| Dark `neutral` palette + ring offsets | `ContentEditSpan`, `EditableImage`, `MarkdownEditor` | Move to portfolio shims. |
|
|
52
|
-
| `primary` token (`var(--color-primary)`, `bg-primary`) | `ContentEditSpan.tsx:103`, `EditableImage`, `MarkdownEditor` | Removed from package; portfolio keeps it in its shims. |
|
|
53
|
-
| `lucide-react` + `sonner` baked in | `EditableImage`, `MarkdownEditor`, `PageProvider` default notifier | Drop from hard deps; inject; portfolio supplies them. |
|
|
54
|
-
| Bespoke text-mark syntax (`^^primary^^`, `~~br~~`, `__underline__`) | `ContentEditSpan.tsx:30` `PATTERNS` | Make parser/renderer injectable with a default. |
|
|
55
|
-
| Portfolio leftovers: `collection = "portfolio"` default, `ProjectContentEditor` | `ContentEditSpan.tsx:151`, `MarkdownEditor.tsx:293` | Delete from package; move `ProjectContentEditor` to portfolio. |
|
|
56
|
-
| Hardcoded English copy | `EditableImage`, `MarkdownEditor` | Replace with props/children defaults. |
|
|
57
|
-
| `id` + `collection` forced on content type | `types.ts:12` | Split internal addressing from user's `T` (Track B3). |
|
|
58
|
-
| Tiny query language | `types.ts:48` | Extend neutrally (Track B1). |
|
|
59
|
-
| `AuthIdentity` forces `userId`+`isAdmin` | `types.ts:92` | Minimal contract + open payload + `authorize` predicate (Track B4). |
|
|
60
|
-
|
|
61
|
-
---
|
|
62
|
-
|
|
63
|
-
## Track A — Headless UI
|
|
64
|
-
|
|
65
|
-
### A1. `PageProvider` (`src/client/PageProvider.tsx`)
|
|
66
|
-
- `Notifier` is already injectable — keep. **Change the default** from a `sonner`
|
|
67
|
-
toast sink to a dependency-free console/no-op default so `sonner` is no longer
|
|
68
|
-
required. Portfolio passes a sonner-backed notifier from its shim.
|
|
69
|
-
- No other behavior change; this file is mostly backend-agnostic already.
|
|
70
|
-
|
|
71
|
-
### A2. `ContentEditSpan` (`src/client/ContentEditSpan.tsx`)
|
|
72
|
-
- Remove `collection = "portfolio"` default → `collection` becomes required (or
|
|
73
|
-
resolved from an optional provider-level default-collection context).
|
|
74
|
-
- Extract the `PATTERNS` parser + `RenderStatic` into an **injectable markup
|
|
75
|
-
contract**: `parse(raw) => Node[]` + `render(nodes)`, defaulted to the current
|
|
76
|
-
implementation but overridable via prop or a `CmsMarkupContext`. Consumers who
|
|
77
|
-
want plain text or their own DSL drop in their own.
|
|
78
|
-
- Strip all Tailwind/`primary`/`neutral` classes. Keep `className` passthrough.
|
|
79
|
-
Emit **state via data attributes**: `data-cms-editable`, `data-editing`,
|
|
80
|
-
`data-focused` so consumers style with their own CSS.
|
|
81
|
-
- Keep the `as` prop; add `asChild` (lightweight Slot) so the consumer can supply
|
|
82
|
-
their own element/markup while the primitive wires `contentEditable`.
|
|
83
|
-
|
|
84
|
-
### A3. `EditableImage` (`src/client/EditableImage.tsx`)
|
|
85
|
-
- Keep the upload/url/pending-image behavior (it's correct and engine-wired).
|
|
86
|
-
- Remove `lucide` icons and all dark modal/overlay styling. Expose a
|
|
87
|
-
**render-prop API**: the component manages state and hands the consumer
|
|
88
|
-
`{ src, isEditing, saving, openFilePicker, setUrl, error }`; the consumer
|
|
89
|
-
renders the overlay/modal. Provide a minimal **unstyled default** render so the
|
|
90
|
-
simple case still works without writing chrome.
|
|
91
|
-
- Placeholder ("No image available") and modal copy become props/children.
|
|
92
|
-
|
|
93
|
-
### A4. `MarkdownEditor` (`src/client/MarkdownEditor.tsx`)
|
|
94
|
-
- This is the most styled file. Split into:
|
|
95
|
-
- **Headless core**: editor state + commands (`insertMarkdown`, preview
|
|
96
|
-
toggle, save/cancel, char count) exposed via hook/render-prop.
|
|
97
|
-
- The modal chrome, toolbar, icons (`lucide`), markdown guide, and
|
|
98
|
-
`react-markdown`/`remark-gfm` preview move to **portfolio**.
|
|
99
|
-
- `react-markdown` + `remark-gfm` leave the package's hard deps (preview is a
|
|
100
|
-
consumer concern; the core only manages text).
|
|
101
|
-
- **Delete `ProjectContentEditor`** from the package (portfolio-specific) → move
|
|
102
|
-
to portfolio.
|
|
103
|
-
|
|
104
|
-
### A5. `client/utils.ts` + deps
|
|
105
|
-
- `cn` relies on `tailwind-merge` (Tailwind-specific). Drop `tailwind-merge` from
|
|
106
|
-
the package; either remove `cn` from the public API or reduce it to a plain
|
|
107
|
-
`clsx` join. Tailwind-specific merging belongs in portfolio.
|
|
108
|
-
|
|
109
|
-
### A6. `package.json` dependency surgery
|
|
110
|
-
Move out of hard `dependencies` (→ removed from package; portfolio installs):
|
|
111
|
-
`lucide-react`, `react-markdown`, `remark-gfm`, `sonner`, `tailwind-merge`.
|
|
112
|
-
Keep only genuinely shared, non-opinion runtime deps (`clsx` at most). Update
|
|
113
|
-
`client/index.ts` exports (remove `ProjectContentEditor`; keep `cn` only if
|
|
114
|
-
retained).
|
|
115
|
-
|
|
116
|
-
---
|
|
117
|
-
|
|
118
|
-
## Track B — Data-layer freedom
|
|
119
|
-
|
|
120
|
-
Touch points: `src/types.ts`, `src/adapters/postgres/index.ts`,
|
|
121
|
-
`src/adapters/firestore/index.ts` (read during impl), `src/server/*`.
|
|
122
|
-
|
|
123
|
-
### B1. Query power (`types.ts:48`, both adapters)
|
|
124
|
-
Extend the neutral `Query` without leaking any backend type:
|
|
125
|
-
- Ops: add `ne`, `nin`, `contains` (case-insensitive substring → SQL `ILIKE
|
|
126
|
-
%v%`), keep existing.
|
|
127
|
-
- **OR groups**: allow `filters` to nest — an `or: Filter[]` group alongside the
|
|
128
|
-
implicit top-level `AND`.
|
|
129
|
-
- **Pagination**: add `offset` next to `limit`.
|
|
130
|
-
- Implement fully in Postgres (`colExpr`/`OP_MAP` already structured for it).
|
|
131
|
-
Implement best-effort in Firestore and **throw a clear error** on ops the
|
|
132
|
-
backend can't honor (e.g. cross-field `OR`, `contains`) rather than returning
|
|
133
|
-
wrong results. Document the parity matrix.
|
|
134
|
-
|
|
135
|
-
### B2. Relations (schemaless-friendly)
|
|
136
|
-
- A reference is just a field holding an id (or `{ collection, id }`) or an array
|
|
137
|
-
of them — no schema change required to store one.
|
|
138
|
-
- Add an optional `populate?: string[]` to `Query` and a neutral resolver in the
|
|
139
|
-
engine that, after the primary fetch, **batch-loads referenced docs** via an
|
|
140
|
-
`in` query and inlines them. Backend-agnostic (works on JSONB + Firestore).
|
|
141
|
-
- Optional `RelationConfig` (field → target collection) so refs that are bare ids
|
|
142
|
-
know where to resolve.
|
|
143
|
-
|
|
144
|
-
### B3. Loosen `id` / `collection` (`types.ts:12`)
|
|
145
|
-
- Keep `id`/`collection` as the engine's **internal addressing**, but stop
|
|
146
|
-
forcing them onto the user's content type. Introduce `Editable<T> = T & {
|
|
147
|
-
id: string; collection: string }` used internally; public APIs accept the
|
|
148
|
-
user's clean `T`.
|
|
149
|
-
- Make the field names **configurable** at the adapter level (`idField`,
|
|
150
|
-
`collectionField`) for consumers whose records use `_id` / `slug`.
|
|
151
|
-
|
|
152
|
-
### B4. Auth flexibility (`types.ts:92`, `server/createAdminGate.ts`)
|
|
153
|
-
- `AuthIdentity` → minimal `{ isAdmin: boolean }` plus an open payload
|
|
154
|
-
(`[key: string]: unknown`; `userId`/`email` optional).
|
|
155
|
-
- `verifyRequest` may return any identity shape; the gate authorizes via an
|
|
156
|
-
injectable `authorize(identity, req) => boolean` predicate, **defaulting** to
|
|
157
|
-
`identity.isAdmin === true`. Lets consumers gate on roles/scopes without the
|
|
158
|
-
fixed contract.
|
|
159
|
-
- Client `CmsAuthState` stays minimal but becomes extensible.
|
|
160
|
-
|
|
161
|
-
---
|
|
162
|
-
|
|
163
|
-
## Portfolio-2025 migration (lockstep)
|
|
164
|
-
|
|
165
|
-
Portfolio consumes the package only through **thin shims**, so the look moves
|
|
166
|
-
there cleanly:
|
|
167
|
-
|
|
168
|
-
- `lib/context/PageContent.tsx` — pass a **sonner-backed `notify`** to
|
|
169
|
-
`PageProvider` (restores toasts).
|
|
170
|
-
- `components/customs/ContentEditSpan.tsx` — wrap the headless primitive,
|
|
171
|
-
re-apply the dark ring/`primary` Tailwind classes and the default `collection`.
|
|
172
|
-
- `components/customs/EditableImage.tsx` — supply the styled overlay/modal +
|
|
173
|
-
`lucide` icons via the render-prop.
|
|
174
|
-
- `components/customs/MarkdownEditor.tsx` — supply the modal chrome, toolbar,
|
|
175
|
-
`lucide` icons, `react-markdown` preview, guide; **define `ProjectContentEditor`
|
|
176
|
-
here**.
|
|
177
|
-
- Install in portfolio what left the package: `lucide-react`, `sonner`,
|
|
178
|
-
`react-markdown`, `remark-gfm`, `tailwind-merge` (most already present).
|
|
179
|
-
- `lib/cms/server.ts` / auth shim — adjust only if `AuthIdentity` field changes
|
|
180
|
-
ripple (expected: none, contract is widened not narrowed).
|
|
181
|
-
|
|
182
|
-
Portfolio's `app/globals.css` already defines `--primary` / `--color-primary`,
|
|
183
|
-
so its shims keep the exact current look.
|
|
184
|
-
|
|
185
|
-
---
|
|
186
|
-
|
|
187
|
-
## Sequencing (so nothing breaks mid-flight)
|
|
188
|
-
|
|
189
|
-
1. **Track B** first (additive, low-risk): `types.ts` Query/Auth widening, then
|
|
190
|
-
Postgres + Firestore adapter impls. Widening contracts won't break portfolio.
|
|
191
|
-
2. **Track A** package changes: provider default notifier, headless
|
|
192
|
-
ContentEditSpan / EditableImage / MarkdownEditor, dep surgery, exports.
|
|
193
|
-
3. **Portfolio shims** updated in the same change set to restore look + behavior.
|
|
194
|
-
4. Drop this doc into the package root as `REDESIGN.md`.
|
|
195
|
-
|
|
196
|
-
---
|
|
197
|
-
|
|
198
|
-
## Verification
|
|
199
|
-
|
|
200
|
-
- **Package**: `npm run build` (tsup) and `npm run typecheck` in
|
|
201
|
-
`headless-cms` — both clean.
|
|
202
|
-
- **Portfolio typecheck/build**: `npm run build` in `portfolio-2025` resolves all
|
|
203
|
-
shims against the new headless API.
|
|
204
|
-
- **Postgres proof** (also a memory milestone): run `scripts/seed-postgres.ts`,
|
|
205
|
-
then exercise new query ops (`contains`, `or`, `offset`) and a `populate`
|
|
206
|
-
round-trip against the local `documents` table.
|
|
207
|
-
- **Manual UI** (`/verify` or `/run`): in portfolio admin/edit mode, confirm
|
|
208
|
-
inline text edit, image upload + external-URL, and the markdown modal all still
|
|
209
|
-
look and behave as before — proving the look fully survived the move to shims.
|
|
210
|
-
- **Headless smoke**: render `ContentEditSpan` / `EditableImage` with **no**
|
|
211
|
-
Tailwind/styles to confirm they work unstyled (harness proof).
|
|
212
|
-
|
|
213
|
-
## Open risks
|
|
214
|
-
- **Firestore query parity** — it cannot honor cross-field `OR` / `contains`
|
|
215
|
-
generally. Plan: throw explicit "unsupported by this adapter" errors and ship a
|
|
216
|
-
documented parity matrix rather than silent wrong results.
|
|
217
|
-
- **`asChild`/render-prop surface** — adds API; mitigated by keeping unstyled
|
|
218
|
-
defaults so trivial cases stay one-liners.
|
|
219
|
-
|
|
220
|
-
---
|
|
221
|
-
|
|
222
|
-
## Status (landed)
|
|
223
|
-
|
|
224
|
-
- **Track B1 (query power)** — done. `QueryFilterOp` gains `ne`/`nin`/`contains`;
|
|
225
|
-
`Query` gains OR groups (`{ or: [...] }`) and `offset`. Postgres implements all;
|
|
226
|
-
Firestore throws clear errors on `contains` / OR groups.
|
|
227
|
-
- **Track B2 (relations)** — done. `resolveRelations(adapter, docs, opts)` in
|
|
228
|
-
`src/server/relations.ts` (exported from `/server`); `Query.populate`,
|
|
229
|
-
`Ref`, `RelationConfig` added.
|
|
230
|
-
- **Track B3 (id/collection)** — **type-level done** (`Editable<T>` +
|
|
231
|
-
`EntityAddress`; `T` unconstrained). **Deferred:** per-adapter configurable
|
|
232
|
-
physical `idField`/`collectionField` renaming — bigger SQL change, left as a
|
|
233
|
-
follow-up to avoid destabilizing the working schemaless path.
|
|
234
|
-
- **Track B4 (auth)** — done. `AuthIdentity` widened to `{ isAdmin } + open
|
|
235
|
-
payload`; injectable `AuthorizeFn` on `createAdminGate` / `createCmsHandlers`.
|
|
236
|
-
- **Track A (headless UI)** — done. `ContentEditSpan` (injectable `renderValue`,
|
|
237
|
-
`data-cms-*` attrs, no styles), `EditableImage` (render-prop), `MarkdownEditor`
|
|
238
|
-
→ `useMarkdownEditor` hook. Removed `ProjectContentEditor`, `cn`/`utils.ts`,
|
|
239
|
-
and all of `lucide-react`/`sonner`/`react-markdown`/`remark-gfm`/`tailwind-merge`
|
|
240
|
-
from package deps (now **zero** runtime deps).
|
|
241
|
-
- **Portfolio shims** — restyled to restore the exact look: `ContentEditSpan`,
|
|
242
|
-
`EditableImage`, `MarkdownEditor`+`ProjectContentEditor`, sonner `notify`.
|
|
243
|
-
|
|
244
|
-
Verified: package `typecheck` + `build` clean; portfolio `tsc --noEmit` clean
|
|
245
|
-
against the new API; portfolio **production build passes** (firebase mode, all
|
|
246
|
-
routes); **Postgres round-trip 12/12** — `contains`, OR groups, `ne`/`nin`,
|
|
247
|
-
`offset`+order (text + typed numeric), and `populate` via `RelationConfig`.
|
|
248
|
-
Caveat surfaced: numeric fields in the schemaless JSONB `documents` table order
|
|
249
|
-
as text (`data->>` is text); register a typed collection for true numeric
|
|
250
|
-
ordering/comparison. Not yet run: manual edit-mode UI pass (needs admin login).
|