@kazzle/app 0.1.926 → 0.1.929
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/dist/auth-react.d.ts +20 -0
- package/dist/auth-react.js +52 -0
- package/dist/auth-schema.d.ts +12 -0
- package/dist/auth-schema.js +66 -0
- package/dist/auth.d.ts +50 -0
- package/dist/auth.js +87 -0
- package/dist/connections.d.ts +11 -0
- package/dist/connections.js +56 -0
- package/dist/index.d.ts +7 -0
- package/dist/sdk.d.ts +1 -0
- package/dist/sdk.js +10 -0
- package/package.json +43 -1
- package/templates/login-app/KAZZLE.md +18 -0
- package/templates/login-app/components/server/index.ts +93 -0
- package/templates/login-app/components/server/package.json +25 -0
- package/templates/login-app/components/server/tsconfig.json +11 -0
- package/templates/login-app/components/ui/index.html +12 -0
- package/templates/login-app/components/ui/package.json +29 -0
- package/templates/login-app/components/ui/public/favicon.svg +4 -0
- package/templates/login-app/components/ui/src/App.tsx +107 -0
- package/templates/login-app/components/ui/src/api.ts +37 -0
- package/templates/login-app/components/ui/src/main.tsx +13 -0
- package/templates/login-app/components/ui/src/styles/theme.css +181 -0
- package/templates/login-app/components/ui/src/vite-env.d.ts +1 -0
- package/templates/login-app/components/ui/tsconfig.json +21 -0
- package/templates/login-app/components/ui/vite.config.ts +19 -0
- package/templates/login-app/kazzle.config.ts +13 -0
- package/templates/login-app/package.json +17 -0
- package/templates/manifest.json +1 -0
- package/templates/ui-db-app/components/ui/tsconfig.json +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AppUser } from './auth-schema';
|
|
2
|
+
export type { AppUser } from './auth-schema';
|
|
3
|
+
/** Login methods the Kazzle broker supports. Email arrives with the email primitive. */
|
|
4
|
+
export type KazzleLoginMethod = 'google';
|
|
5
|
+
/**
|
|
6
|
+
* Start sign-in. Redirects the browser through the Kazzle broker to the named
|
|
7
|
+
* method's login, then back to `callbackURL` (default: the page root).
|
|
8
|
+
*/
|
|
9
|
+
export declare function signInWithKazzle(_method?: KazzleLoginMethod, opts?: {
|
|
10
|
+
callbackURL?: string;
|
|
11
|
+
}): Promise<unknown>;
|
|
12
|
+
/** Sign the current user out of this app. */
|
|
13
|
+
export declare function signOutOfApp(): Promise<unknown>;
|
|
14
|
+
/**
|
|
15
|
+
* The signed-in end user, or null. Reactive: re-renders on sign-in/out.
|
|
16
|
+
* While the session is still loading, returns null (render logged-out UI).
|
|
17
|
+
*/
|
|
18
|
+
export declare function useAppUser(): AppUser | null;
|
|
19
|
+
/** Session loading state, for UIs that want a spinner instead of a flash of logged-out. */
|
|
20
|
+
export declare function useAppUserPending(): boolean;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @kazzle/app/auth/react — app login, browser half.
|
|
2
|
+
//
|
|
3
|
+
// All the agent touches: `useAppUser()` for who's signed in, and
|
|
4
|
+
// `signInWithKazzle()` / `signOutOfApp()` on buttons. The client talks to the
|
|
5
|
+
// app's own /api/auth/* routes (same origin — the UI dev server proxies them
|
|
6
|
+
// to the process; in prod both share one host), so there is no base URL to
|
|
7
|
+
// configure and no auth code to write.
|
|
8
|
+
import { createAuthClient } from 'better-auth/react';
|
|
9
|
+
import { genericOAuthClient } from 'better-auth/client/plugins';
|
|
10
|
+
// Module-private: Better Auth's inferred client type is not portable across
|
|
11
|
+
// d.ts emit. The SDK contract is the functions below, not the raw client.
|
|
12
|
+
const authClient = createAuthClient({
|
|
13
|
+
plugins: [genericOAuthClient()],
|
|
14
|
+
});
|
|
15
|
+
/**
|
|
16
|
+
* Start sign-in. Redirects the browser through the Kazzle broker to the named
|
|
17
|
+
* method's login, then back to `callbackURL` (default: the page root).
|
|
18
|
+
*/
|
|
19
|
+
export function signInWithKazzle(_method = 'google', opts) {
|
|
20
|
+
// The method is fixed server-side today (authorizationUrlParams). The
|
|
21
|
+
// parameter exists so login buttons already state their intent — when the
|
|
22
|
+
// broker gains methods, this maps per-call without app code changing shape.
|
|
23
|
+
return authClient.signIn.oauth2({
|
|
24
|
+
providerId: 'kazzle',
|
|
25
|
+
callbackURL: opts?.callbackURL ?? '/',
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Sign the current user out of this app. */
|
|
29
|
+
export function signOutOfApp() {
|
|
30
|
+
return authClient.signOut();
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The signed-in end user, or null. Reactive: re-renders on sign-in/out.
|
|
34
|
+
* While the session is still loading, returns null (render logged-out UI).
|
|
35
|
+
*/
|
|
36
|
+
export function useAppUser() {
|
|
37
|
+
const { data } = authClient.useSession();
|
|
38
|
+
const user = data?.user;
|
|
39
|
+
if (!user)
|
|
40
|
+
return null;
|
|
41
|
+
return {
|
|
42
|
+
id: user.id,
|
|
43
|
+
email: user.email,
|
|
44
|
+
emailVerified: user.emailVerified,
|
|
45
|
+
name: user.name ?? null,
|
|
46
|
+
image: user.image ?? null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Session loading state, for UIs that want a spinner instead of a flash of logged-out. */
|
|
50
|
+
export function useAppUserPending() {
|
|
51
|
+
return authClient.useSession().isPending;
|
|
52
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Identity claims the app sees for a signed-in end user. */
|
|
2
|
+
export interface AppUser {
|
|
3
|
+
id: string;
|
|
4
|
+
email: string;
|
|
5
|
+
emailVerified: boolean;
|
|
6
|
+
name: string | null;
|
|
7
|
+
image: string | null;
|
|
8
|
+
}
|
|
9
|
+
/** Postgres schema the auth tables live in. */
|
|
10
|
+
export declare const AUTH_PG_SCHEMA = "auth";
|
|
11
|
+
/** Idempotent DDL for the auth tables. Run against the app's own database. */
|
|
12
|
+
export declare const AUTH_SCHEMA_SQL = "\nCREATE SCHEMA IF NOT EXISTS auth;\n\nCREATE TABLE IF NOT EXISTS auth.\"user\" (\n id text PRIMARY KEY,\n name text,\n email text NOT NULL UNIQUE,\n \"emailVerified\" boolean NOT NULL DEFAULT false,\n image text,\n \"createdAt\" timestamptz NOT NULL DEFAULT now(),\n \"updatedAt\" timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE TABLE IF NOT EXISTS auth.\"session\" (\n id text PRIMARY KEY,\n \"expiresAt\" timestamptz NOT NULL,\n token text NOT NULL UNIQUE,\n \"ipAddress\" text,\n \"userAgent\" text,\n \"userId\" text NOT NULL REFERENCES auth.\"user\"(id) ON DELETE CASCADE,\n \"createdAt\" timestamptz NOT NULL DEFAULT now(),\n \"updatedAt\" timestamptz NOT NULL DEFAULT now()\n);\nCREATE INDEX IF NOT EXISTS session_user_id_idx ON auth.\"session\"(\"userId\");\n\nCREATE TABLE IF NOT EXISTS auth.\"account\" (\n id text PRIMARY KEY,\n \"accountId\" text NOT NULL,\n \"providerId\" text NOT NULL,\n \"userId\" text NOT NULL REFERENCES auth.\"user\"(id) ON DELETE CASCADE,\n \"accessToken\" text,\n \"refreshToken\" text,\n \"idToken\" text,\n \"accessTokenExpiresAt\" timestamptz,\n \"refreshTokenExpiresAt\" timestamptz,\n scope text,\n password text,\n \"createdAt\" timestamptz NOT NULL DEFAULT now(),\n \"updatedAt\" timestamptz NOT NULL DEFAULT now()\n);\nCREATE INDEX IF NOT EXISTS account_user_id_idx ON auth.\"account\"(\"userId\");\n\nCREATE TABLE IF NOT EXISTS auth.\"verification\" (\n id text PRIMARY KEY,\n identifier text NOT NULL,\n value text NOT NULL,\n \"expiresAt\" timestamptz NOT NULL,\n \"createdAt\" timestamptz NOT NULL DEFAULT now(),\n \"updatedAt\" timestamptz NOT NULL DEFAULT now()\n);\n";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @kazzle/app/auth/schema — the app-login auth tables, as SQL.
|
|
2
|
+
//
|
|
3
|
+
// Dependency-free on purpose: the server's login template hook runs this SQL
|
|
4
|
+
// at checkout (and the agent runs it when adding `appLogin` to an existing
|
|
5
|
+
// app) without pulling better-auth/pg into the importer.
|
|
6
|
+
//
|
|
7
|
+
// The tables live in a dedicated `auth` Postgres schema inside the app's own
|
|
8
|
+
// database — Supabase convention: app code never modifies these tables, it
|
|
9
|
+
// extends by referencing `auth."user"(id)` from its own tables in the public
|
|
10
|
+
// schema. Shapes match Better Auth core (user/session/account/verification,
|
|
11
|
+
// camelCase quoted columns); `@kazzle/app/auth` connects with
|
|
12
|
+
// `search_path=auth` so Better Auth's unqualified table names resolve here.
|
|
13
|
+
/** Postgres schema the auth tables live in. */
|
|
14
|
+
export const AUTH_PG_SCHEMA = 'auth';
|
|
15
|
+
/** Idempotent DDL for the auth tables. Run against the app's own database. */
|
|
16
|
+
export const AUTH_SCHEMA_SQL = `
|
|
17
|
+
CREATE SCHEMA IF NOT EXISTS auth;
|
|
18
|
+
|
|
19
|
+
CREATE TABLE IF NOT EXISTS auth."user" (
|
|
20
|
+
id text PRIMARY KEY,
|
|
21
|
+
name text,
|
|
22
|
+
email text NOT NULL UNIQUE,
|
|
23
|
+
"emailVerified" boolean NOT NULL DEFAULT false,
|
|
24
|
+
image text,
|
|
25
|
+
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
|
26
|
+
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS auth."session" (
|
|
30
|
+
id text PRIMARY KEY,
|
|
31
|
+
"expiresAt" timestamptz NOT NULL,
|
|
32
|
+
token text NOT NULL UNIQUE,
|
|
33
|
+
"ipAddress" text,
|
|
34
|
+
"userAgent" text,
|
|
35
|
+
"userId" text NOT NULL REFERENCES auth."user"(id) ON DELETE CASCADE,
|
|
36
|
+
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
|
37
|
+
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
|
38
|
+
);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS session_user_id_idx ON auth."session"("userId");
|
|
40
|
+
|
|
41
|
+
CREATE TABLE IF NOT EXISTS auth."account" (
|
|
42
|
+
id text PRIMARY KEY,
|
|
43
|
+
"accountId" text NOT NULL,
|
|
44
|
+
"providerId" text NOT NULL,
|
|
45
|
+
"userId" text NOT NULL REFERENCES auth."user"(id) ON DELETE CASCADE,
|
|
46
|
+
"accessToken" text,
|
|
47
|
+
"refreshToken" text,
|
|
48
|
+
"idToken" text,
|
|
49
|
+
"accessTokenExpiresAt" timestamptz,
|
|
50
|
+
"refreshTokenExpiresAt" timestamptz,
|
|
51
|
+
scope text,
|
|
52
|
+
password text,
|
|
53
|
+
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
|
54
|
+
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
|
55
|
+
);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS account_user_id_idx ON auth."account"("userId");
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS auth."verification" (
|
|
59
|
+
id text PRIMARY KEY,
|
|
60
|
+
identifier text NOT NULL,
|
|
61
|
+
value text NOT NULL,
|
|
62
|
+
"expiresAt" timestamptz NOT NULL,
|
|
63
|
+
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
|
64
|
+
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
|
65
|
+
);
|
|
66
|
+
`;
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type AppUser } from './auth-schema';
|
|
2
|
+
export type { AppUser } from './auth-schema';
|
|
3
|
+
export { AUTH_SCHEMA_SQL, AUTH_PG_SCHEMA } from './auth-schema';
|
|
4
|
+
/**
|
|
5
|
+
* Minimal structural view of the Better Auth instance. Kept narrow on purpose:
|
|
6
|
+
* Better Auth's inferred generics are not portable across d.ts emit, and the
|
|
7
|
+
* SDK contract is the helpers, not the raw instance.
|
|
8
|
+
*/
|
|
9
|
+
interface AuthInstance {
|
|
10
|
+
handler(request: Request): Promise<Response>;
|
|
11
|
+
api: {
|
|
12
|
+
getSession(opts: {
|
|
13
|
+
headers: Headers;
|
|
14
|
+
}): Promise<{
|
|
15
|
+
user: {
|
|
16
|
+
id: string;
|
|
17
|
+
email: string;
|
|
18
|
+
emailVerified: boolean;
|
|
19
|
+
name?: string | null;
|
|
20
|
+
image?: string | null;
|
|
21
|
+
};
|
|
22
|
+
} | null>;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export interface KazzleAuth {
|
|
26
|
+
/** The underlying Better Auth instance (escape hatch; prefer the helpers). */
|
|
27
|
+
auth: AuthInstance;
|
|
28
|
+
/** Request handler for /api/auth/* — see mountKazzleAuth. */
|
|
29
|
+
handler(request: Request): Promise<Response>;
|
|
30
|
+
/** Resolve the signed-in end user from a request's cookies, or null. */
|
|
31
|
+
getAppUser(request: Request): Promise<AppUser | null>;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Create the app's login runtime. Reads platform-injected env; no options in
|
|
35
|
+
* the common case. `databaseUrl` override exists for tests only.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createKazzleAuth(options?: {
|
|
38
|
+
databaseUrl?: string;
|
|
39
|
+
}): KazzleAuth;
|
|
40
|
+
/**
|
|
41
|
+
* Mount /api/auth/* on a Hono app. Structural typing on purpose so this
|
|
42
|
+
* module doesn't depend on hono.
|
|
43
|
+
*/
|
|
44
|
+
export declare function mountKazzleAuth(app: {
|
|
45
|
+
on(methods: string[], path: string, handler: (c: {
|
|
46
|
+
req: {
|
|
47
|
+
raw: Request;
|
|
48
|
+
};
|
|
49
|
+
}) => Promise<Response>): unknown;
|
|
50
|
+
}, kazzleAuth: KazzleAuth): void;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @kazzle/app/auth — app login, server half.
|
|
2
|
+
//
|
|
3
|
+
// One provider, `kazzle`: the app's Better Auth does standard OIDC against
|
|
4
|
+
// Kazzle's app-login broker, which runs Google (and later other methods)
|
|
5
|
+
// behind it. App code never touches Google — no secrets, no callbacks, no
|
|
6
|
+
// consent config. The platform injects everything this module reads:
|
|
7
|
+
//
|
|
8
|
+
// DATABASE_URL the app's own Postgres (users live HERE, not at Kazzle)
|
|
9
|
+
// KAZZLE_LOGIN_ID the app's broker client id (= its app id)
|
|
10
|
+
// KAZZLE_LOGIN_SECRET the app's broker client secret (server-only)
|
|
11
|
+
// KAZZLE_APP_PUBLIC_URL the app's browser-facing origin (preview tunnel or prod host)
|
|
12
|
+
// KAZZLE_API_URL Kazzle platform API base (the broker lives under /app-login)
|
|
13
|
+
//
|
|
14
|
+
// Auth rides the app's UI origin: the browser only ever calls relative
|
|
15
|
+
// /api/auth/* paths (the UI dev server proxies them to this process; in prod
|
|
16
|
+
// both surfaces share one host), so cookies are plain same-origin.
|
|
17
|
+
//
|
|
18
|
+
// The auth tables live in the `auth` Postgres schema (see ./auth-schema.ts);
|
|
19
|
+
// this module connects with search_path=auth so Better Auth's unqualified
|
|
20
|
+
// table names resolve there. App tables in the public schema reference
|
|
21
|
+
// auth."user"(id) — never add columns to the auth tables themselves.
|
|
22
|
+
import { betterAuth } from 'better-auth';
|
|
23
|
+
import { genericOAuth } from 'better-auth/plugins';
|
|
24
|
+
import { Pool } from 'pg';
|
|
25
|
+
import { AUTH_PG_SCHEMA } from './auth-schema';
|
|
26
|
+
export { AUTH_SCHEMA_SQL, AUTH_PG_SCHEMA } from './auth-schema';
|
|
27
|
+
function requiredEnv(name) {
|
|
28
|
+
const value = process.env[name];
|
|
29
|
+
if (!value) {
|
|
30
|
+
throw new Error(`${name} is not set — app login requires the platform-injected env (is appLogin enabled for this app?)`);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Create the app's login runtime. Reads platform-injected env; no options in
|
|
36
|
+
* the common case. `databaseUrl` override exists for tests only.
|
|
37
|
+
*/
|
|
38
|
+
export function createKazzleAuth(options) {
|
|
39
|
+
const databaseUrl = options?.databaseUrl ?? requiredEnv('DATABASE_URL');
|
|
40
|
+
const publicUrl = requiredEnv('KAZZLE_APP_PUBLIC_URL');
|
|
41
|
+
const apiUrl = requiredEnv('KAZZLE_API_URL');
|
|
42
|
+
const clientId = requiredEnv('KAZZLE_LOGIN_ID');
|
|
43
|
+
const clientSecret = requiredEnv('KAZZLE_LOGIN_SECRET');
|
|
44
|
+
const pool = new Pool({
|
|
45
|
+
connectionString: databaseUrl,
|
|
46
|
+
options: `-c search_path=${AUTH_PG_SCHEMA}`,
|
|
47
|
+
});
|
|
48
|
+
const auth = betterAuth({
|
|
49
|
+
database: pool,
|
|
50
|
+
secret: clientSecret,
|
|
51
|
+
baseURL: publicUrl,
|
|
52
|
+
basePath: '/api/auth',
|
|
53
|
+
trustedOrigins: [publicUrl],
|
|
54
|
+
plugins: [
|
|
55
|
+
genericOAuth({
|
|
56
|
+
config: [{
|
|
57
|
+
providerId: 'kazzle',
|
|
58
|
+
clientId,
|
|
59
|
+
clientSecret,
|
|
60
|
+
discoveryUrl: `${apiUrl}/app-login/.well-known/openid-configuration`,
|
|
61
|
+
scopes: ['openid', 'email', 'profile'],
|
|
62
|
+
pkce: true,
|
|
63
|
+
// The app states the method; the broker executes it. Today: google.
|
|
64
|
+
authorizationUrlParams: { method: 'google' },
|
|
65
|
+
}],
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
return {
|
|
70
|
+
auth,
|
|
71
|
+
handler: (request) => auth.handler(request),
|
|
72
|
+
getAppUser: async (request) => {
|
|
73
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
74
|
+
if (!session?.user)
|
|
75
|
+
return null;
|
|
76
|
+
const { id, email, emailVerified, name, image } = session.user;
|
|
77
|
+
return { id, email, emailVerified, name: name ?? null, image: image ?? null };
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Mount /api/auth/* on a Hono app. Structural typing on purpose so this
|
|
83
|
+
* module doesn't depend on hono.
|
|
84
|
+
*/
|
|
85
|
+
export function mountKazzleAuth(app, kazzleAuth) {
|
|
86
|
+
app.on(['GET', 'POST'], '/api/auth/*', (c) => kazzleAuth.handler(c.req.raw));
|
|
87
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare class ConnectionNotConnectedError extends Error {
|
|
2
|
+
readonly connectionId: string;
|
|
3
|
+
constructor(connectionId: string, message?: string);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* A fresh vendor access token for a connection, e.g.
|
|
7
|
+
* `new Stripe(await getConnectionToken('8f2a…'))`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getConnectionToken(connectionId: string): Promise<string>;
|
|
10
|
+
/** Forget cached tokens (tests, or after a reconnect). */
|
|
11
|
+
export declare function clearConnectionTokenCache(): void;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @kazzle/app/connections — use the accounts a person connected in Kazzle
|
|
2
|
+
// (Stripe, Gmail, Slack, …) from app code.
|
|
3
|
+
//
|
|
4
|
+
// Kazzle keeps the OAuth tokens; this asks Kazzle for a fresh one and caches it
|
|
5
|
+
// until it expires. Identify the connection by its vault secret id (the AI that
|
|
6
|
+
// built the app has it from the vault). Requires KAZZLE_API_KEY and
|
|
7
|
+
// KAZZLE_API_URL, both injected into every component at deploy and `kazzle run`.
|
|
8
|
+
//
|
|
9
|
+
// Dependency-free on purpose (uses global fetch). Do not add zod/vite here.
|
|
10
|
+
import { kazzleApiUrl } from './client';
|
|
11
|
+
export class ConnectionNotConnectedError extends Error {
|
|
12
|
+
connectionId;
|
|
13
|
+
constructor(connectionId, message) {
|
|
14
|
+
super(message ?? `Connection ${connectionId} is not connected. Connect it on the Integrations page in Kazzle.`);
|
|
15
|
+
this.connectionId = connectionId;
|
|
16
|
+
this.name = 'ConnectionNotConnectedError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const DEFAULT_TTL_MS = 60 * 60_000;
|
|
20
|
+
const REFRESH_MARGIN_MS = 60_000;
|
|
21
|
+
const cache = new Map();
|
|
22
|
+
function apiKey() {
|
|
23
|
+
const key = process.env['KAZZLE_API_KEY'];
|
|
24
|
+
if (!key) {
|
|
25
|
+
throw new Error('KAZZLE_API_KEY is not set — connection tokens are only available to deployed Kazzle app components.');
|
|
26
|
+
}
|
|
27
|
+
return key;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A fresh vendor access token for a connection, e.g.
|
|
31
|
+
* `new Stripe(await getConnectionToken('8f2a…'))`.
|
|
32
|
+
*/
|
|
33
|
+
export async function getConnectionToken(connectionId) {
|
|
34
|
+
const hit = cache.get(connectionId);
|
|
35
|
+
if (hit && hit.expiresAt - Date.now() > REFRESH_MARGIN_MS)
|
|
36
|
+
return hit.token;
|
|
37
|
+
const res = await fetch(`${kazzleApiUrl()}/connections/${encodeURIComponent(connectionId)}/token`, {
|
|
38
|
+
headers: { authorization: `Bearer ${apiKey()}` },
|
|
39
|
+
});
|
|
40
|
+
if (res.status === 404 || res.status === 409) {
|
|
41
|
+
const body = await res.json().catch(() => ({}));
|
|
42
|
+
cache.delete(connectionId);
|
|
43
|
+
throw new ConnectionNotConnectedError(connectionId, body.message);
|
|
44
|
+
}
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
throw new Error(`Kazzle connection token request failed: HTTP ${res.status}`);
|
|
47
|
+
}
|
|
48
|
+
const body = await res.json();
|
|
49
|
+
const expiresAt = body.expiresAt ? Date.parse(body.expiresAt) : Date.now() + DEFAULT_TTL_MS;
|
|
50
|
+
cache.set(connectionId, { token: body.token, expiresAt: Number.isFinite(expiresAt) ? expiresAt : Date.now() + DEFAULT_TTL_MS });
|
|
51
|
+
return body.token;
|
|
52
|
+
}
|
|
53
|
+
/** Forget cached tokens (tests, or after a reconnect). */
|
|
54
|
+
export function clearConnectionTokenCache() {
|
|
55
|
+
cache.clear();
|
|
56
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -89,6 +89,13 @@ export interface KazzleConfig {
|
|
|
89
89
|
target?: 'local' | 'remote';
|
|
90
90
|
/** Path to the app icon file (png, jpg, svg, webp, ico) */
|
|
91
91
|
icon?: string;
|
|
92
|
+
/**
|
|
93
|
+
* End-user login for this app (via @kazzle/app/auth — Google today).
|
|
94
|
+
* Distinct from `kazzleAuth`, which gates the launch surface by Kazzle
|
|
95
|
+
* account; appLogin is the app's OWN users, stored in its own database.
|
|
96
|
+
* Requires a process + database.
|
|
97
|
+
*/
|
|
98
|
+
appLogin?: boolean;
|
|
92
99
|
/** Executable components — UI frontends or background processes */
|
|
93
100
|
components?: KazzleComponent[];
|
|
94
101
|
/** AI skill definitions — markdown files the AI reads for domain knowledge */
|
package/dist/sdk.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from 'kazzle';
|
package/dist/sdk.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// @kazzle/app/sdk — the Kazzle REST API client, re-exported for app code.
|
|
2
|
+
//
|
|
3
|
+
// Deployed app components get KAZZLE_API_KEY and KAZZLE_API_URL injected
|
|
4
|
+
// (server/apps/apps.runtime-env.ts), so `new Kazzle()` needs no arguments
|
|
5
|
+
// there: the key scopes every call to the app's space.
|
|
6
|
+
//
|
|
7
|
+
// The implementation lives in the `kazzle` package (packages/kazzle-sdk) — an
|
|
8
|
+
// optional peer dependency, so apps that do not drive computers or browsers do
|
|
9
|
+
// not install it.
|
|
10
|
+
export * from 'kazzle';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kazzle/app",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.929",
|
|
4
4
|
"description": "Contracts, tool helpers, Vite helper, and templates for building Kazzle apps.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -24,6 +24,22 @@
|
|
|
24
24
|
"types": "./dist/client.d.ts",
|
|
25
25
|
"import": "./dist/client.js"
|
|
26
26
|
},
|
|
27
|
+
"./connections": {
|
|
28
|
+
"types": "./dist/connections.d.ts",
|
|
29
|
+
"import": "./dist/connections.js"
|
|
30
|
+
},
|
|
31
|
+
"./auth": {
|
|
32
|
+
"types": "./dist/auth.d.ts",
|
|
33
|
+
"import": "./dist/auth.js"
|
|
34
|
+
},
|
|
35
|
+
"./auth/schema": {
|
|
36
|
+
"types": "./dist/auth-schema.d.ts",
|
|
37
|
+
"import": "./dist/auth-schema.js"
|
|
38
|
+
},
|
|
39
|
+
"./auth/react": {
|
|
40
|
+
"types": "./dist/auth-react.d.ts",
|
|
41
|
+
"import": "./dist/auth-react.js"
|
|
42
|
+
},
|
|
27
43
|
"./vite": {
|
|
28
44
|
"types": "./dist/vite.d.ts",
|
|
29
45
|
"import": "./dist/vite.js"
|
|
@@ -32,6 +48,10 @@
|
|
|
32
48
|
"types": "./dist/pwa.d.ts",
|
|
33
49
|
"import": "./dist/pwa.js"
|
|
34
50
|
},
|
|
51
|
+
"./sdk": {
|
|
52
|
+
"types": "./dist/sdk.d.ts",
|
|
53
|
+
"import": "./dist/sdk.js"
|
|
54
|
+
},
|
|
35
55
|
"./templates": {
|
|
36
56
|
"types": "./dist/templates.d.ts",
|
|
37
57
|
"import": "./dist/templates.js"
|
|
@@ -61,10 +81,26 @@
|
|
|
61
81
|
"workbox-window": "^7.4.1"
|
|
62
82
|
},
|
|
63
83
|
"peerDependencies": {
|
|
84
|
+
"kazzle": "^0.1.0",
|
|
85
|
+
"better-auth": ">=1.6.0 <1.7.0",
|
|
86
|
+
"pg": "^8.11.0",
|
|
87
|
+
"react": ">=18",
|
|
64
88
|
"vite": ">=5",
|
|
65
89
|
"zod": "^3 || ^4"
|
|
66
90
|
},
|
|
67
91
|
"peerDependenciesMeta": {
|
|
92
|
+
"kazzle": {
|
|
93
|
+
"optional": true
|
|
94
|
+
},
|
|
95
|
+
"better-auth": {
|
|
96
|
+
"optional": true
|
|
97
|
+
},
|
|
98
|
+
"pg": {
|
|
99
|
+
"optional": true
|
|
100
|
+
},
|
|
101
|
+
"react": {
|
|
102
|
+
"optional": true
|
|
103
|
+
},
|
|
68
104
|
"vite": {
|
|
69
105
|
"optional": true
|
|
70
106
|
},
|
|
@@ -73,7 +109,13 @@
|
|
|
73
109
|
}
|
|
74
110
|
},
|
|
75
111
|
"devDependencies": {
|
|
112
|
+
"kazzle": "workspace:*",
|
|
76
113
|
"@types/node": "^25.1.0",
|
|
114
|
+
"@types/pg": "^8.11.0",
|
|
115
|
+
"@types/react": "^19.0.0",
|
|
116
|
+
"better-auth": ">=1.6.0 <1.7.0",
|
|
117
|
+
"pg": "^8.11.0",
|
|
118
|
+
"react": "^19.0.0",
|
|
77
119
|
"typescript": "7.0.2",
|
|
78
120
|
"vite": "^6.3.5",
|
|
79
121
|
"zod": "^4.3.6"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Kazzle template context
|
|
2
|
+
|
|
3
|
+
This app was created from the `login` template. It already includes end-user
|
|
4
|
+
login (Google via Kazzle), a React UI, a Hono process API, and Postgres wiring.
|
|
5
|
+
|
|
6
|
+
Template-owned setup:
|
|
7
|
+
- `kazzle.config.ts` declares the UI and server components and `appLogin: true`. Database env vars are injected by `app.create`.
|
|
8
|
+
- `components/server/index.ts` mounts the login flow (`createKazzleAuth` + `mountKazzleAuth` from `@kazzle/app/auth`) and owns the per-user notes API.
|
|
9
|
+
- `components/ui/src/App.tsx` renders login state via `useAppUser()` / `signInWithKazzle()` / `signOutOfApp()` from `@kazzle/app/auth/react`.
|
|
10
|
+
- The database schema (auth tables + notes) is bootstrapped by template setup before the app is handed back.
|
|
11
|
+
|
|
12
|
+
Login rules — these keep sign-in working:
|
|
13
|
+
- Auth is SDK code. Never write session/cookie/callback logic, never call the Kazzle broker or Google directly, never edit `/api/auth` handling beyond `mountKazzleAuth`.
|
|
14
|
+
- The `auth` Postgres schema (`auth."user"`, `auth."session"`, `auth."account"`, `auth."verification"`) is owned by the SDK. Never ALTER those tables. Per-user data goes in your own public-schema tables with `user_id TEXT REFERENCES auth."user"(id) ON DELETE CASCADE` — see `notes`.
|
|
15
|
+
- Server routes that touch user data start with `await kazzleAuth.getAppUser(c.req.raw)`; null → return 401.
|
|
16
|
+
- New backend routes the browser calls must be added to `proxyPaths` in `components/ui/vite.config.ts`. `/api/auth` must stay in that list.
|
|
17
|
+
|
|
18
|
+
Do not rerun `db.connect`, rewrite manifest env refs, recreate server boilerplate, or reinstall dependencies as setup repair after `app.create` reports template setup complete. Change these files only when the user asks for behavior beyond this template.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {{APP_NAME}} — server component for the login template.
|
|
3
|
+
*
|
|
4
|
+
* End-user login via Kazzle (Google today) plus a per-user notes API that
|
|
5
|
+
* shows the pattern for user-owned data:
|
|
6
|
+
*
|
|
7
|
+
* - Auth is SDK code: `createKazzleAuth()` + `mountKazzleAuth()` wire the
|
|
8
|
+
* whole login flow. Do NOT write auth logic (sessions, cookies, callbacks)
|
|
9
|
+
* by hand — extend around it.
|
|
10
|
+
* - Users live in this app's own database, in the `auth` Postgres schema
|
|
11
|
+
* (auth."user", auth."session", …). NEVER modify those tables. Per-user
|
|
12
|
+
* app data goes in your own public-schema tables with a
|
|
13
|
+
* `user_id TEXT REFERENCES auth."user"(id)` column — like `notes` below.
|
|
14
|
+
* - Every route that touches user data starts with `getAppUser(request)`;
|
|
15
|
+
* null means logged out → 401. The browser sends the session cookie
|
|
16
|
+
* automatically (same-origin /api/auth + API paths via the UI proxy).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { serve } from '@hono/node-server';
|
|
20
|
+
import { Hono } from 'hono';
|
|
21
|
+
import { cors } from 'hono/cors';
|
|
22
|
+
import postgres from 'postgres';
|
|
23
|
+
import { createKazzleAuth, mountKazzleAuth } from '@kazzle/app/auth';
|
|
24
|
+
|
|
25
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
26
|
+
if (!databaseUrl) throw new Error('DATABASE_URL is required');
|
|
27
|
+
|
|
28
|
+
const sql = postgres(databaseUrl);
|
|
29
|
+
const kazzleAuth = createKazzleAuth();
|
|
30
|
+
const app = new Hono();
|
|
31
|
+
|
|
32
|
+
app.use('*', cors());
|
|
33
|
+
app.get('/health', (c) => c.json({ ok: true }));
|
|
34
|
+
|
|
35
|
+
// All of /api/auth/* — sign-in, callback, session, sign-out. SDK-owned.
|
|
36
|
+
mountKazzleAuth(app, kazzleAuth);
|
|
37
|
+
|
|
38
|
+
// ── Who am I ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
app.get('/me', async (c) => {
|
|
41
|
+
const user = await kazzleAuth.getAppUser(c.req.raw);
|
|
42
|
+
if (!user) return c.json({ error: 'Not signed in' }, 401);
|
|
43
|
+
return c.json(user);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ── Notes (per-user data pattern) ────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
app.get('/notes', async (c) => {
|
|
49
|
+
const user = await kazzleAuth.getAppUser(c.req.raw);
|
|
50
|
+
if (!user) return c.json({ error: 'Not signed in' }, 401);
|
|
51
|
+
const rows = await sql`
|
|
52
|
+
SELECT id, body, created_at
|
|
53
|
+
FROM notes
|
|
54
|
+
WHERE user_id = ${user.id}
|
|
55
|
+
ORDER BY created_at DESC
|
|
56
|
+
`;
|
|
57
|
+
return c.json(rows);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
app.post('/notes', async (c) => {
|
|
61
|
+
const user = await kazzleAuth.getAppUser(c.req.raw);
|
|
62
|
+
if (!user) return c.json({ error: 'Not signed in' }, 401);
|
|
63
|
+
const { body } = await c.req.json<{ body?: string }>();
|
|
64
|
+
const text = body?.trim();
|
|
65
|
+
if (!text) return c.json({ error: 'Note text is required' }, 400);
|
|
66
|
+
const [note] = await sql`
|
|
67
|
+
INSERT INTO notes (user_id, body)
|
|
68
|
+
VALUES (${user.id}, ${text})
|
|
69
|
+
RETURNING id, body, created_at
|
|
70
|
+
`;
|
|
71
|
+
return c.json(note, 201);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
app.delete('/notes/:id', async (c) => {
|
|
75
|
+
const user = await kazzleAuth.getAppUser(c.req.raw);
|
|
76
|
+
if (!user) return c.json({ error: 'Not signed in' }, 401);
|
|
77
|
+
const id = Number(c.req.param('id'));
|
|
78
|
+
if (!Number.isFinite(id)) return c.json({ error: 'Invalid note id' }, 400);
|
|
79
|
+
// WHERE user_id keeps one user from deleting another's note by guessing ids.
|
|
80
|
+
await sql`DELETE FROM notes WHERE id = ${id} AND user_id = ${user.id}`;
|
|
81
|
+
return c.json({ ok: true });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
85
|
+
// DO NOT CHANGE THE NEXT 4 LINES. DO NOT ADD FALLBACKS. DO NOT HARDCODE.
|
|
86
|
+
// PORT and HOST are injected by `kazzle run`. Missing values must throw.
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
88
|
+
if (!process.env.PORT || !process.env.HOST) {
|
|
89
|
+
throw new Error('PORT and HOST must be set by "kazzle run" — never hardcode them.');
|
|
90
|
+
}
|
|
91
|
+
const port = Number(process.env.PORT);
|
|
92
|
+
const hostname = process.env.HOST;
|
|
93
|
+
serve({ fetch: app.fetch, port, hostname });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{APP_SLUG}}-server",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "bun run index.ts",
|
|
8
|
+
"dev": "kazzle run -- bun --watch index.ts",
|
|
9
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions",
|
|
10
|
+
"check": "bun run typecheck"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@hono/node-server": "^1.13.0",
|
|
14
|
+
"@kazzle/app": "{{KAZZLE_APP_VERSION}}",
|
|
15
|
+
"better-auth": ">=1.6.0 <1.7.0",
|
|
16
|
+
"hono": "^4.12.16",
|
|
17
|
+
"pg": "^8.11.0",
|
|
18
|
+
"postgres": "^3.4.5"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/bun": "^1.3.6",
|
|
22
|
+
"@types/pg": "^8.11.0",
|
|
23
|
+
"typescript": "7.0.2"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>{{APP_NAME}}</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{APP_SLUG}}",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "kazzle run -- vite",
|
|
8
|
+
"typecheck": "tsc --noEmit",
|
|
9
|
+
"build": "vite build",
|
|
10
|
+
"preview": "kazzle run -- vite preview",
|
|
11
|
+
"check": "bun run typecheck && bun run build"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@kazzle/app": "{{KAZZLE_APP_VERSION}}",
|
|
15
|
+
"@vitejs/plugin-react": "^4.5.2",
|
|
16
|
+
"better-auth": ">=1.6.0 <1.7.0",
|
|
17
|
+
"react": "^19.1.0",
|
|
18
|
+
"react-dom": "^19.1.0",
|
|
19
|
+
"vite": "^6.3.5"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@tailwindcss/vite": "^4.1.0",
|
|
23
|
+
"@types/node": "^25.1.0",
|
|
24
|
+
"@types/react": "^19.1.0",
|
|
25
|
+
"@types/react-dom": "^19.1.0",
|
|
26
|
+
"tailwindcss": "^4.1.0",
|
|
27
|
+
"typescript": "7.0.2"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<rect width="64" height="64" rx="18" fill="#6366F1"/>
|
|
3
|
+
<path d="M19 33.5L28 42L46 22" stroke="white" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
|
4
|
+
</svg>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {{APP_NAME}} — login template UI.
|
|
3
|
+
*
|
|
4
|
+
* Two states, driven entirely by `useAppUser()` from @kazzle/app/auth/react:
|
|
5
|
+
* logged out → a login card with one "Continue with Google" button
|
|
6
|
+
* logged in → the app (per-user notes), user chip, sign out
|
|
7
|
+
*
|
|
8
|
+
* All auth is SDK code: `signInWithKazzle()` starts the flow,
|
|
9
|
+
* `signOutOfApp()` ends the session, `useAppUser()` is reactive. There is no
|
|
10
|
+
* auth logic to write in this file — build the app around the user object.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useEffect, useState, type FormEvent } from 'react';
|
|
14
|
+
import { signInWithKazzle, signOutOfApp, useAppUser, useAppUserPending } from '@kazzle/app/auth/react';
|
|
15
|
+
import { createNote, deleteNote, getNotes, type Note } from './api';
|
|
16
|
+
|
|
17
|
+
export function App() {
|
|
18
|
+
const user = useAppUser();
|
|
19
|
+
const pending = useAppUserPending();
|
|
20
|
+
|
|
21
|
+
if (pending) return <div className="page" />;
|
|
22
|
+
if (!user) return <LoginScreen />;
|
|
23
|
+
return <NotesScreen name={user.name ?? user.email} image={user.image} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function LoginScreen() {
|
|
27
|
+
return (
|
|
28
|
+
<div className="page items-center justify-center">
|
|
29
|
+
<div className="card stack max-w-sm w-full items-center text-center">
|
|
30
|
+
<h1 className="text-2xl font-semibold">{'{{APP_NAME}}'}</h1>
|
|
31
|
+
<p className="text-muted">Sign in to keep your notes in one place.</p>
|
|
32
|
+
<button className="btn w-full" onClick={() => void signInWithKazzle('google')}>
|
|
33
|
+
Continue with Google
|
|
34
|
+
</button>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function NotesScreen({ name, image }: { name: string; image: string | null }) {
|
|
41
|
+
const [notes, setNotes] = useState<Note[]>([]);
|
|
42
|
+
const [draft, setDraft] = useState('');
|
|
43
|
+
const [error, setError] = useState<string | null>(null);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
getNotes().then(setNotes).catch((err: Error) => setError(err.message));
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
async function addNote(event: FormEvent) {
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
const body = draft.trim();
|
|
52
|
+
if (!body) return;
|
|
53
|
+
try {
|
|
54
|
+
const note = await createNote(body);
|
|
55
|
+
setNotes((current) => [note, ...current]);
|
|
56
|
+
setDraft('');
|
|
57
|
+
} catch (err) {
|
|
58
|
+
setError((err as Error).message);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function removeNote(id: number) {
|
|
63
|
+
try {
|
|
64
|
+
await deleteNote(id);
|
|
65
|
+
setNotes((current) => current.filter((note) => note.id !== id));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
setError((err as Error).message);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="page">
|
|
73
|
+
<header className="row justify-between">
|
|
74
|
+
<h1 className="text-xl font-semibold">{'{{APP_NAME}}'}</h1>
|
|
75
|
+
<div className="row">
|
|
76
|
+
{image && <img src={image} alt="" className="size-8 rounded-full" referrerPolicy="no-referrer" />}
|
|
77
|
+
<span className="text-muted">{name}</span>
|
|
78
|
+
<button className="btn" onClick={() => void signOutOfApp().then(() => window.location.reload())}>
|
|
79
|
+
Sign out
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
</header>
|
|
83
|
+
|
|
84
|
+
<form className="row" onSubmit={addNote}>
|
|
85
|
+
<input
|
|
86
|
+
className="input flex-1"
|
|
87
|
+
placeholder="Write a note…"
|
|
88
|
+
value={draft}
|
|
89
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
90
|
+
/>
|
|
91
|
+
<button className="btn" type="submit">Add</button>
|
|
92
|
+
</form>
|
|
93
|
+
|
|
94
|
+
{error && <p className="text-red-400">{error}</p>}
|
|
95
|
+
|
|
96
|
+
<div className="stack">
|
|
97
|
+
{notes.map((note) => (
|
|
98
|
+
<div key={note.id} className="card row justify-between">
|
|
99
|
+
<span>{note.body}</span>
|
|
100
|
+
<button className="btn" onClick={() => void removeNote(note.id)}>Delete</button>
|
|
101
|
+
</div>
|
|
102
|
+
))}
|
|
103
|
+
{notes.length === 0 && !error && <p className="text-muted">No notes yet — your notes are only visible to you.</p>}
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrappers over the server's REST API. The browser sends the session
|
|
3
|
+
* cookie automatically — no auth headers to add. A 401 means logged out; the
|
|
4
|
+
* App component treats that as "show the login screen".
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface Note {
|
|
8
|
+
id: number;
|
|
9
|
+
body: string;
|
|
10
|
+
created_at: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
14
|
+
const res = await fetch(path, {
|
|
15
|
+
...init,
|
|
16
|
+
headers: {
|
|
17
|
+
'Content-Type': 'application/json',
|
|
18
|
+
...init?.headers,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
throw new Error(`Request failed: ${res.status} ${await res.text()}`);
|
|
23
|
+
}
|
|
24
|
+
return res.json() as Promise<T>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getNotes(): Promise<Note[]> {
|
|
28
|
+
return request<Note[]>('/notes');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createNote(body: string): Promise<Note> {
|
|
32
|
+
return request<Note>('/notes', { method: 'POST', body: JSON.stringify({ body }) });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function deleteNote(id: number): Promise<{ ok: boolean }> {
|
|
36
|
+
return request<{ ok: boolean }>(`/notes/${id}`, { method: 'DELETE' });
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { registerAppSW } from '@kazzle/app/pwa';
|
|
4
|
+
import { App } from './App';
|
|
5
|
+
import './styles/theme.css';
|
|
6
|
+
|
|
7
|
+
registerAppSW();
|
|
8
|
+
|
|
9
|
+
createRoot(document.getElementById('root')!).render(
|
|
10
|
+
<React.StrictMode>
|
|
11
|
+
<App />
|
|
12
|
+
</React.StrictMode>,
|
|
13
|
+
);
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
2
|
+
* Kazzle app theme — the ONE styling entry point for this app.
|
|
3
|
+
*
|
|
4
|
+
* This file is the design system. It is imported once (in `main.tsx`) and
|
|
5
|
+
* gives you three things, in order:
|
|
6
|
+
*
|
|
7
|
+
* 1. Tailwind v4 — every utility class (`flex`, `gap-4`,
|
|
8
|
+
* `rounded-xl`, `text-zinc-500`, …) works in
|
|
9
|
+
* your JSX. No config file, no PostCSS setup.
|
|
10
|
+
* 2. Design tokens (@theme) — colors, radius, fonts as CSS variables. Edit
|
|
11
|
+
* these to re-skin the whole app at once.
|
|
12
|
+
* 3. Primitives (@layer) — a few ready-made classes (`.btn`, `.card`,
|
|
13
|
+
* `.input`, `.field`, `.page`, `.stack`,
|
|
14
|
+
* `.row`) so common UI looks good with zero
|
|
15
|
+
* effort.
|
|
16
|
+
*
|
|
17
|
+
* HOW TO STYLE — pick whichever is easier, mix freely:
|
|
18
|
+
* • Tailwind utilities in `className` for one-off layout/spacing.
|
|
19
|
+
* • A primitive class (`.btn`, `.card`) for the common building blocks.
|
|
20
|
+
*
|
|
21
|
+
* WHY IT'S BUILT THIS WAY: styles live in the markup (utilities) or in named
|
|
22
|
+
* primitives here — never in a separate per-screen stylesheet keyed to class
|
|
23
|
+
* names you have to keep in sync. Rewriting a component can't silently strip
|
|
24
|
+
* its styling, because the styling travels with the elements.
|
|
25
|
+
*
|
|
26
|
+
* TO EXTEND: add new design tokens under `@theme`, and add new reusable
|
|
27
|
+
* building blocks under `@layer components`. Keep app-specific one-offs as
|
|
28
|
+
* inline Tailwind utilities in the component instead of adding a class here.
|
|
29
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
30
|
+
|
|
31
|
+
@import "tailwindcss";
|
|
32
|
+
|
|
33
|
+
/* ── Design tokens ──────────────────────────────────────────────────────────
|
|
34
|
+
* Exposed to Tailwind as `bg-surface`, `text-fg`, `text-muted`,
|
|
35
|
+
* `bg-accent`, `rounded-app`, etc. Change a value here and every utility and
|
|
36
|
+
* primitive that references it updates at once. */
|
|
37
|
+
@theme {
|
|
38
|
+
--color-bg: #0b0f1a;
|
|
39
|
+
--color-surface: #131a2a;
|
|
40
|
+
--color-surface-raised: #1b2438;
|
|
41
|
+
--color-fg: #e8edf7;
|
|
42
|
+
--color-muted: #94a3b8;
|
|
43
|
+
--color-border: rgba(148, 163, 184, 0.18);
|
|
44
|
+
--color-accent: #6366f1;
|
|
45
|
+
--color-accent-hover: #4f46e5;
|
|
46
|
+
--color-accent-fg: #ffffff;
|
|
47
|
+
--color-danger: #f87171;
|
|
48
|
+
|
|
49
|
+
--radius-app: 0.875rem;
|
|
50
|
+
|
|
51
|
+
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system,
|
|
52
|
+
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ── Base ────────────────────────────────────────────────────────────────────
|
|
56
|
+
* Sensible document defaults so even unstyled markup reads well. */
|
|
57
|
+
@layer base {
|
|
58
|
+
html,
|
|
59
|
+
body,
|
|
60
|
+
#root {
|
|
61
|
+
min-height: 100%;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
body {
|
|
65
|
+
margin: 0;
|
|
66
|
+
background: var(--color-bg);
|
|
67
|
+
color: var(--color-fg);
|
|
68
|
+
font-family: var(--font-sans);
|
|
69
|
+
-webkit-font-smoothing: antialiased;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
h1 {
|
|
73
|
+
font-size: 1.875rem;
|
|
74
|
+
font-weight: 700;
|
|
75
|
+
letter-spacing: -0.02em;
|
|
76
|
+
margin: 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
h2 {
|
|
80
|
+
font-size: 1.25rem;
|
|
81
|
+
font-weight: 600;
|
|
82
|
+
margin: 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* ── Primitives ──────────────────────────────────────────────────────────────
|
|
87
|
+
* Reusable building blocks. Use them directly (`<button class="btn btn-primary">`)
|
|
88
|
+
* or layer Tailwind utilities on top. Add new ones here when a pattern repeats. */
|
|
89
|
+
@layer components {
|
|
90
|
+
/* Page wrapper — centers content with comfortable gutters. */
|
|
91
|
+
.page {
|
|
92
|
+
width: min(100% - 2rem, 48rem);
|
|
93
|
+
margin-inline: auto;
|
|
94
|
+
padding-block: 3rem;
|
|
95
|
+
display: flex;
|
|
96
|
+
flex-direction: column;
|
|
97
|
+
gap: 1.5rem;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Vertical / horizontal stacks. */
|
|
101
|
+
.stack {
|
|
102
|
+
display: flex;
|
|
103
|
+
flex-direction: column;
|
|
104
|
+
gap: 0.75rem;
|
|
105
|
+
}
|
|
106
|
+
.row {
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
gap: 0.75rem;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* Card surface. */
|
|
113
|
+
.card {
|
|
114
|
+
background: var(--color-surface);
|
|
115
|
+
border: 1px solid var(--color-border);
|
|
116
|
+
border-radius: var(--radius-app);
|
|
117
|
+
padding: 1.25rem;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* Buttons. `.btn` is the neutral base; add `.btn-primary` for the accent. */
|
|
121
|
+
.btn {
|
|
122
|
+
display: inline-flex;
|
|
123
|
+
align-items: center;
|
|
124
|
+
justify-content: center;
|
|
125
|
+
gap: 0.5rem;
|
|
126
|
+
padding: 0.625rem 1rem;
|
|
127
|
+
border-radius: var(--radius-app);
|
|
128
|
+
border: 1px solid var(--color-border);
|
|
129
|
+
background: var(--color-surface-raised);
|
|
130
|
+
color: var(--color-fg);
|
|
131
|
+
font: inherit;
|
|
132
|
+
font-weight: 500;
|
|
133
|
+
cursor: pointer;
|
|
134
|
+
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
|
135
|
+
}
|
|
136
|
+
.btn:hover {
|
|
137
|
+
background: color-mix(in srgb, var(--color-surface-raised) 80%, white);
|
|
138
|
+
}
|
|
139
|
+
.btn:disabled {
|
|
140
|
+
opacity: 0.5;
|
|
141
|
+
cursor: not-allowed;
|
|
142
|
+
}
|
|
143
|
+
.btn-primary {
|
|
144
|
+
background: var(--color-accent);
|
|
145
|
+
border-color: transparent;
|
|
146
|
+
color: var(--color-accent-fg);
|
|
147
|
+
}
|
|
148
|
+
.btn-primary:hover {
|
|
149
|
+
background: var(--color-accent-hover);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* Text input / select. */
|
|
153
|
+
.input {
|
|
154
|
+
width: 100%;
|
|
155
|
+
min-width: 0;
|
|
156
|
+
padding: 0.625rem 0.875rem;
|
|
157
|
+
border-radius: var(--radius-app);
|
|
158
|
+
border: 1px solid var(--color-border);
|
|
159
|
+
background: var(--color-surface-raised);
|
|
160
|
+
color: var(--color-fg);
|
|
161
|
+
font: inherit;
|
|
162
|
+
outline: none;
|
|
163
|
+
}
|
|
164
|
+
.input:focus {
|
|
165
|
+
border-color: var(--color-accent);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* Labelled field: <label class="field"><span>Label</span><input class="input"/></label> */
|
|
169
|
+
.field {
|
|
170
|
+
display: flex;
|
|
171
|
+
flex-direction: column;
|
|
172
|
+
gap: 0.375rem;
|
|
173
|
+
font-size: 0.875rem;
|
|
174
|
+
color: var(--color-muted);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* Muted helper / secondary text. */
|
|
178
|
+
.muted {
|
|
179
|
+
color: var(--color-muted);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"allowJs": false,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"allowSyntheticDefaultImports": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"module": "ESNext",
|
|
13
|
+
"moduleResolution": "Bundler",
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"isolatedModules": true,
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
"jsx": "react-jsx"
|
|
18
|
+
},
|
|
19
|
+
"include": ["src"],
|
|
20
|
+
"references": []
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite config for the UI component.
|
|
3
|
+
*
|
|
4
|
+
* Kazzle owns HOST/PORT (`kazzleAppVite`) and production PWA (`kazzleAppPwa`)
|
|
5
|
+
* in `@kazzle/app/vite`. This template declares a backend `server` component;
|
|
6
|
+
* we pass its name plus the relative routes the browser calls. `/api/auth` is
|
|
7
|
+
* the login flow — it MUST stay proxied so auth cookies are same-origin. Add
|
|
8
|
+
* new backend routes to `proxyPaths`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { defineConfig } from 'vite';
|
|
12
|
+
import react from '@vitejs/plugin-react';
|
|
13
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
14
|
+
import { kazzleAppVite, kazzleAppPwa } from '@kazzle/app/vite';
|
|
15
|
+
|
|
16
|
+
export default defineConfig(({ command }) => ({
|
|
17
|
+
plugins: [react(), tailwindcss(), ...kazzleAppPwa()],
|
|
18
|
+
...kazzleAppVite(command, { apiComponent: 'server', proxyPaths: ['/api/auth', '/me', '/notes'] }),
|
|
19
|
+
}));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineConfig } from '@kazzle/app';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
/** One-line catalog tagline. Required to publish. */
|
|
5
|
+
subtitle: '{{APP_NAME}}',
|
|
6
|
+
icon: 'components/ui/public/favicon.svg',
|
|
7
|
+
/** End-user login (Google via Kazzle). Users live in this app's own database. */
|
|
8
|
+
appLogin: true,
|
|
9
|
+
components: [
|
|
10
|
+
{ name: 'ui', type: 'ui', path: './components/ui' },
|
|
11
|
+
{ name: 'server', type: 'process', path: './components/server/index.ts', runtime: { dev: { command: 'bun run dev' }, prod: { command: 'bun run start' } } },
|
|
12
|
+
],
|
|
13
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{APP_SLUG}}",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"workspaces": [
|
|
7
|
+
"components/*"
|
|
8
|
+
],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "bun run --cwd components/ui dev",
|
|
11
|
+
"start": "bun run --cwd components/server start",
|
|
12
|
+
"check": "bun run --filter='./components/*' check"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@kazzle/app": "{{KAZZLE_APP_VERSION}}"
|
|
16
|
+
}
|
|
17
|
+
}
|
package/templates/manifest.json
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
{ "id": "ui", "label": "UI", "description": "React + Vite UI component with the Kazzle preview Vite helper.", "tags": ["ui", "frontend"] },
|
|
5
5
|
{ "id": "process", "label": "Process", "description": "Background/triggered process component plus AI tool skill.", "tags": ["process", "tools"] },
|
|
6
6
|
{ "id": "ui-db", "label": "UI + Postgres", "description": "React UI plus Hono API backed by a plain Postgres database.", "tags": ["ui", "database"] },
|
|
7
|
+
{ "id": "login", "label": "Login", "description": "React UI with end-user Google login (via Kazzle) and per-user data in Postgres.", "tags": ["ui", "database", "login"] },
|
|
7
8
|
{ "id": "realtime", "label": "Realtime", "description": "Offline-first UI with PowerSync sync over a Postgres backend.", "tags": ["ui", "database", "sync"] },
|
|
8
9
|
{ "id": "ai", "label": "AI", "description": "React UI plus Hono server exposing Kazzle AI (chat, image, speech, extract).", "tags": ["ui", "ai"] }
|
|
9
10
|
]
|