create-seiro 0.1.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.
@@ -0,0 +1,160 @@
1
+ import type { Sql } from "postgres";
2
+ import type { Server } from "seiro";
3
+ import type {
4
+ User,
5
+ AuthResult,
6
+ AuthCommands,
7
+ AuthQueries,
8
+ AuthEvents,
9
+ } from "./types";
10
+
11
+ const JWT_SECRET =
12
+ process.env.JWT_SECRET ?? "seiro-dev-secret-change-in-production";
13
+ const JWT_EXPIRY = process.env.JWT_EXPIRY ?? "7d";
14
+
15
+ // DB row shape (snake_case from postgres)
16
+ type UserRow = { id: number; email: string; created_at: string };
17
+
18
+ function toUser(row: UserRow): User {
19
+ return { id: row.id, email: row.email, createdAt: row.created_at };
20
+ }
21
+
22
+ // Simple JWT implementation using Bun's native crypto
23
+ async function signToken(userId: number): Promise<string> {
24
+ const header = { alg: "HS256", typ: "JWT" };
25
+ const now = Math.floor(Date.now() / 1000);
26
+ const expiry = parseExpiry(JWT_EXPIRY);
27
+ const payload = { sub: userId, iat: now, exp: now + expiry };
28
+
29
+ const encoder = new TextEncoder();
30
+ const headerB64 = btoa(JSON.stringify(header)).replace(/=/g, "");
31
+ const payloadB64 = btoa(JSON.stringify(payload)).replace(/=/g, "");
32
+ const data = `${headerB64}.${payloadB64}`;
33
+
34
+ const key = await crypto.subtle.importKey(
35
+ "raw",
36
+ encoder.encode(JWT_SECRET),
37
+ { name: "HMAC", hash: "SHA-256" },
38
+ false,
39
+ ["sign"],
40
+ );
41
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data));
42
+ const signatureB64 = btoa(
43
+ String.fromCharCode(...new Uint8Array(signature)),
44
+ ).replace(/=/g, "");
45
+
46
+ return `${data}.${signatureB64}`;
47
+ }
48
+
49
+ export async function verifyToken(token: string): Promise<number | null> {
50
+ try {
51
+ const [headerB64, payloadB64, signatureB64] = token.split(".");
52
+ if (!headerB64 || !payloadB64 || !signatureB64) return null;
53
+
54
+ const encoder = new TextEncoder();
55
+ const data = `${headerB64}.${payloadB64}`;
56
+
57
+ const key = await crypto.subtle.importKey(
58
+ "raw",
59
+ encoder.encode(JWT_SECRET),
60
+ { name: "HMAC", hash: "SHA-256" },
61
+ false,
62
+ ["verify"],
63
+ );
64
+
65
+ const signature = Uint8Array.from(atob(signatureB64), (c) =>
66
+ c.charCodeAt(0),
67
+ );
68
+ const valid = await crypto.subtle.verify(
69
+ "HMAC",
70
+ key,
71
+ signature,
72
+ encoder.encode(data),
73
+ );
74
+ if (!valid) return null;
75
+
76
+ const payload = JSON.parse(atob(payloadB64));
77
+ if (payload.exp < Math.floor(Date.now() / 1000)) return null;
78
+
79
+ return payload.sub;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function parseExpiry(expiry: string): number {
86
+ const match = expiry.match(/^(\d+)([smhd])$/);
87
+ if (!match || !match[1] || !match[2]) return 7 * 24 * 60 * 60; // default 7 days
88
+ const value = parseInt(match[1], 10);
89
+ const unit = match[2];
90
+ switch (unit) {
91
+ case "s":
92
+ return value;
93
+ case "m":
94
+ return value * 60;
95
+ case "h":
96
+ return value * 60 * 60;
97
+ case "d":
98
+ return value * 24 * 60 * 60;
99
+ default:
100
+ return 7 * 24 * 60 * 60;
101
+ }
102
+ }
103
+
104
+ export function register<
105
+ C extends AuthCommands,
106
+ Q extends AuthQueries,
107
+ E extends AuthEvents,
108
+ >(server: Server<C, Q, E>, sql: Sql) {
109
+ // Send profile on connect
110
+ server.onOpen(async (ctx) => {
111
+ if (!ctx.userId) {
112
+ ctx.send({ profile: null });
113
+ return;
114
+ }
115
+ const [row] = await sql<[{ query_auth_profile: UserRow }?]>`
116
+ SELECT query_auth_profile(${ctx.userId}, ${sql.json({})})
117
+ `;
118
+ if (row) {
119
+ ctx.send({ profile: toUser(row.query_auth_profile) });
120
+ } else {
121
+ ctx.send({ profile: null });
122
+ }
123
+ });
124
+
125
+ server.command("auth.register", async (data, ctx) => {
126
+ const [row] = await sql<[{ result: UserRow }]>`
127
+ SELECT cmd_auth_register(${sql.json(data)}) as result
128
+ `;
129
+ if (!row?.result) throw new Error("Registration failed");
130
+
131
+ const user = toUser(row.result);
132
+ const token = await signToken(user.id);
133
+ ctx.setUserId(user.id);
134
+ return { token, user } as AuthResult;
135
+ });
136
+
137
+ server.command("auth.login", async (data, ctx) => {
138
+ const [row] = await sql<[{ result: UserRow }]>`
139
+ SELECT cmd_auth_login(${sql.json(data)}) as result
140
+ `;
141
+ if (!row?.result) throw new Error("Invalid email or password");
142
+
143
+ const user = toUser(row.result);
144
+ const token = await signToken(user.id);
145
+ ctx.setUserId(user.id);
146
+ return { token, user } as AuthResult;
147
+ });
148
+
149
+ server.query("auth.profile", async function* (_params, ctx) {
150
+ if (!ctx.userId) throw new Error("Not authenticated");
151
+ const rows = await sql<{ query_auth_profile: UserRow }[]>`
152
+ SELECT query_auth_profile(${ctx.userId}, ${sql.json({})})
153
+ `;
154
+ for (const row of rows) {
155
+ yield toUser(row.query_auth_profile);
156
+ }
157
+ });
158
+ }
159
+
160
+ export const channels: string[] = [];
@@ -0,0 +1,23 @@
1
+ import type { Command, Query } from "seiro";
2
+
3
+ export type User = {
4
+ id: number;
5
+ email: string;
6
+ createdAt: string;
7
+ };
8
+
9
+ export type AuthResult = {
10
+ token: string;
11
+ user: User;
12
+ };
13
+
14
+ export type AuthCommands = {
15
+ "auth.register": Command<{ email: string; password: string }, AuthResult>;
16
+ "auth.login": Command<{ email: string; password: string }, AuthResult>;
17
+ };
18
+
19
+ export type AuthQueries = {
20
+ "auth.profile": Query<void, User>;
21
+ };
22
+
23
+ export type AuthEvents = Record<string, never>;
@@ -0,0 +1,120 @@
1
+ import { signal, effect } from "seiro/client";
2
+ import type { Client } from "seiro";
3
+ import type { Commands, Queries, Events, User } from "../types";
4
+
5
+ type AppClient = Client<Commands, Queries, Events>;
6
+
7
+ // State
8
+ export const user = signal<User | null>(null);
9
+ const authError = signal<string | null>(null);
10
+
11
+ let client: AppClient;
12
+
13
+ export function initAuth(c: AppClient, profile: User | null) {
14
+ client = c;
15
+ user.value = profile;
16
+ }
17
+
18
+ function handleAuthSuccess(result: { token: string; user: User }) {
19
+ client.setToken(result.token);
20
+ user.value = result.user;
21
+ authError.value = null;
22
+ }
23
+
24
+ export function logout() {
25
+ client.logout();
26
+ user.value = null;
27
+ }
28
+
29
+ class AuthForm extends HTMLElement {
30
+ private userView!: HTMLElement;
31
+ private formsView!: HTMLElement;
32
+ private emailSpan!: HTMLElement;
33
+ private errorDiv!: HTMLElement;
34
+
35
+ connectedCallback() {
36
+ this.innerHTML = `
37
+ <div class="auth-user hidden items-center gap-4">
38
+ <span class="text-zinc-300">Logged in as <strong class="user-email text-white"></strong></span>
39
+ <button id="logout" class="bg-zinc-700 hover:bg-zinc-600 text-white px-3 py-1 rounded text-sm">Logout</button>
40
+ </div>
41
+ <div class="auth-forms space-y-4">
42
+ <form id="login-form" class="flex flex-wrap items-center gap-2">
43
+ <span class="text-zinc-400 w-20">Login</span>
44
+ <input name="email" type="email" placeholder="Email" value="test@example.com" required
45
+ class="bg-zinc-800 border border-zinc-700 rounded px-3 py-1.5 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-500" />
46
+ <input name="password" type="password" placeholder="Password" value="password123" required
47
+ class="bg-zinc-800 border border-zinc-700 rounded px-3 py-1.5 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-500" />
48
+ <button type="submit" class="bg-zinc-700 hover:bg-zinc-600 text-white px-4 py-1.5 rounded text-sm">Login</button>
49
+ </form>
50
+ <form id="register-form" class="flex flex-wrap items-center gap-2">
51
+ <span class="text-zinc-400 w-20">Register</span>
52
+ <input name="email" type="email" placeholder="Email" value="test@example.com" required
53
+ class="bg-zinc-800 border border-zinc-700 rounded px-3 py-1.5 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-500" />
54
+ <input name="password" type="password" placeholder="Password" value="password123" required
55
+ class="bg-zinc-800 border border-zinc-700 rounded px-3 py-1.5 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-500" />
56
+ <button type="submit" class="bg-zinc-700 hover:bg-zinc-600 text-white px-4 py-1.5 rounded text-sm">Register</button>
57
+ </form>
58
+ <div class="auth-error hidden text-red-400 text-sm"></div>
59
+ </div>
60
+ `;
61
+
62
+ this.userView = this.querySelector(".auth-user")!;
63
+ this.formsView = this.querySelector(".auth-forms")!;
64
+ this.emailSpan = this.querySelector(".user-email")!;
65
+ this.errorDiv = this.querySelector(".auth-error")!;
66
+
67
+ // Update visibility based on state
68
+ effect(() => {
69
+ if (user.value) {
70
+ this.userView.classList.remove("hidden");
71
+ this.userView.classList.add("flex");
72
+ this.formsView.classList.add("hidden");
73
+ this.emailSpan.textContent = user.value.email;
74
+ } else {
75
+ this.userView.classList.add("hidden");
76
+ this.userView.classList.remove("flex");
77
+ this.formsView.classList.remove("hidden");
78
+ }
79
+ });
80
+
81
+ effect(() => {
82
+ if (authError.value) {
83
+ this.errorDiv.classList.remove("hidden");
84
+ this.errorDiv.textContent = authError.value;
85
+ } else {
86
+ this.errorDiv.classList.add("hidden");
87
+ }
88
+ });
89
+
90
+ this.addEventListener("submit", (e) => {
91
+ e.preventDefault();
92
+ const form = e.target as HTMLFormElement;
93
+ const data = new FormData(form);
94
+ const email = data.get("email") as string;
95
+ const password = data.get("password") as string;
96
+
97
+ const callbacks = {
98
+ onSuccess: handleAuthSuccess,
99
+ onError: (err: string) => {
100
+ authError.value = err;
101
+ },
102
+ };
103
+
104
+ if (form.id === "login-form") {
105
+ client.cmd("auth.login", { email, password }, callbacks);
106
+ } else if (form.id === "register-form") {
107
+ client.cmd("auth.register", { email, password }, callbacks);
108
+ }
109
+ });
110
+
111
+ this.addEventListener("click", (e) => {
112
+ const target = e.target as HTMLElement;
113
+ if (target.id === "logout") {
114
+ logout();
115
+ }
116
+ });
117
+ }
118
+ }
119
+
120
+ customElements.define("auth-form", AuthForm);
@@ -0,0 +1,205 @@
1
+ // Modal utilities - styled overlays, no browser dialogs
2
+
3
+ export function showToast(message: string, duration = 3000): void {
4
+ const toast = document.createElement("div");
5
+ toast.style.cssText =
6
+ "position:fixed;bottom:1rem;left:50%;transform:translateX(-50%);background:#27272a;border:1px solid #3f3f46;padding:0.5rem 1rem;border-radius:0.5rem;color:#f4f4f5;z-index:9999";
7
+ toast.textContent = message;
8
+
9
+ document.body.appendChild(toast);
10
+ setTimeout(() => toast.remove(), duration);
11
+ }
12
+
13
+ // Simple modal for text input - replaces window.prompt()
14
+
15
+ export function showInputModal(
16
+ title: string,
17
+ initialValue = "",
18
+ ): Promise<string | null> {
19
+ return new Promise((resolve) => {
20
+ const overlay = document.createElement("div");
21
+ overlay.style.cssText =
22
+ "position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:9999;background:rgba(0,0,0,0.5)";
23
+ overlay.innerHTML = `
24
+ <div style="background:#27272a;color:#f4f4f5;padding:1.5rem;border-radius:0.5rem;min-width:18rem">
25
+ <h2 style="font-size:1.125rem;font-weight:bold;margin-bottom:1rem">${title}</h2>
26
+ <input data-input style="width:100%;background:#3f3f46;border:1px solid #52525b;border-radius:0.375rem;padding:0.5rem 0.75rem;color:#f4f4f5;outline:none;margin-bottom:1rem" />
27
+ <div style="display:flex;gap:0.5rem">
28
+ <button data-cancel style="flex:1;background:#3f3f46;padding:0.5rem 1rem;border-radius:0.375rem;cursor:pointer;border:none;color:#f4f4f5">Cancel</button>
29
+ <button data-ok style="flex:1;background:#52525b;padding:0.5rem 1rem;border-radius:0.375rem;cursor:pointer;border:none;color:#f4f4f5">OK</button>
30
+ </div>
31
+ </div>
32
+ `;
33
+
34
+ const input = overlay.querySelector<HTMLInputElement>("[data-input]")!;
35
+ input.value = initialValue;
36
+
37
+ const cancelBtn =
38
+ overlay.querySelector<HTMLButtonElement>("[data-cancel]")!;
39
+ const okBtn = overlay.querySelector<HTMLButtonElement>("[data-ok]")!;
40
+
41
+ const close = (value: string | null) => {
42
+ overlay.remove();
43
+ resolve(value);
44
+ };
45
+
46
+ const submit = () => {
47
+ const value = input.value.trim();
48
+ close(value || null);
49
+ };
50
+
51
+ okBtn.onclick = submit;
52
+ cancelBtn.onclick = () => close(null);
53
+ input.onkeydown = (e) => {
54
+ if (e.key === "Enter") submit();
55
+ if (e.key === "Escape") close(null);
56
+ };
57
+
58
+ // Close on backdrop click
59
+ overlay.onclick = (e) => {
60
+ if (e.target === overlay) close(null);
61
+ };
62
+
63
+ document.body.appendChild(overlay);
64
+ queueMicrotask(() => {
65
+ input.focus();
66
+ input.select();
67
+ });
68
+ });
69
+ }
70
+
71
+ // Form modal with multiple fields
72
+
73
+ export type FormField = {
74
+ name: string;
75
+ label: string;
76
+ placeholder?: string;
77
+ required?: boolean;
78
+ };
79
+
80
+ export function showFormModal(
81
+ title: string,
82
+ fields: FormField[],
83
+ ): Promise<Record<string, string> | null> {
84
+ return new Promise((resolve) => {
85
+ const overlay = document.createElement("div");
86
+ overlay.style.cssText =
87
+ "position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:9999;background:rgba(0,0,0,0.5)";
88
+
89
+ const fieldsHtml = fields
90
+ .map(
91
+ (f) => `
92
+ <div style="display:flex;flex-direction:column;gap:0.25rem">
93
+ <label style="font-size:0.875rem;color:#a1a1aa">${f.label}${f.required ? " *" : ""}</label>
94
+ <input name="${f.name}" placeholder="${f.placeholder || ""}" ${f.required ? "required" : ""}
95
+ style="width:100%;background:#3f3f46;border:1px solid #52525b;border-radius:0.375rem;padding:0.5rem 0.75rem;color:#f4f4f5;outline:none" />
96
+ </div>
97
+ `,
98
+ )
99
+ .join("");
100
+
101
+ overlay.innerHTML = `
102
+ <form style="background:#27272a;color:#f4f4f5;padding:1.5rem;border-radius:0.5rem;min-width:20rem">
103
+ <h2 style="font-size:1.125rem;font-weight:bold;margin-bottom:1rem">${title}</h2>
104
+ <div style="display:flex;flex-direction:column;gap:0.75rem;margin-bottom:1rem">
105
+ ${fieldsHtml}
106
+ </div>
107
+ <div style="display:flex;gap:0.5rem">
108
+ <button type="button" data-cancel style="flex:1;background:#3f3f46;padding:0.5rem 1rem;border-radius:0.375rem;cursor:pointer;border:none;color:#f4f4f5">Cancel</button>
109
+ <button type="submit" style="flex:1;background:#52525b;padding:0.5rem 1rem;border-radius:0.375rem;cursor:pointer;border:none;color:#f4f4f5">OK</button>
110
+ </div>
111
+ </form>
112
+ `;
113
+
114
+ const form = overlay.querySelector<HTMLFormElement>("form")!;
115
+ const cancelBtn =
116
+ overlay.querySelector<HTMLButtonElement>("[data-cancel]")!;
117
+ const firstInput = form.querySelector<HTMLInputElement>("input")!;
118
+
119
+ const close = (value: Record<string, string> | null) => {
120
+ overlay.remove();
121
+ resolve(value);
122
+ };
123
+
124
+ form.onsubmit = (e) => {
125
+ e.preventDefault();
126
+ const data = new FormData(form);
127
+ const result: Record<string, string> = {};
128
+ for (const field of fields) {
129
+ result[field.name] = (data.get(field.name) as string)?.trim() || "";
130
+ }
131
+ // Check required fields
132
+ for (const field of fields) {
133
+ if (field.required && !result[field.name]) return;
134
+ }
135
+ close(result);
136
+ };
137
+
138
+ cancelBtn.onclick = () => close(null);
139
+
140
+ overlay.onclick = (e) => {
141
+ if (e.target === overlay) close(null);
142
+ };
143
+
144
+ overlay.onkeydown = (e) => {
145
+ if (e.key === "Escape") close(null);
146
+ };
147
+
148
+ document.body.appendChild(overlay);
149
+ queueMicrotask(() => firstInput?.focus());
150
+ });
151
+ }
152
+
153
+ // Simple confirmation modal - replaces window.confirm()
154
+
155
+ export function showConfirmModal(
156
+ title: string,
157
+ message: string,
158
+ ): Promise<boolean> {
159
+ return new Promise((resolve) => {
160
+ const overlay = document.createElement("div");
161
+ overlay.style.cssText =
162
+ "position:fixed;inset:0;display:flex;align-items:center;justify-content:center;z-index:9999;background:rgba(0,0,0,0.5)";
163
+ overlay.innerHTML = `
164
+ <div style="background:#27272a;color:#f4f4f5;padding:1.5rem;border-radius:0.5rem;min-width:18rem">
165
+ <h2 style="font-size:1.125rem;font-weight:bold;margin-bottom:0.75rem">${title}</h2>
166
+ <p style="color:#a1a1aa;margin-bottom:1rem">${message}</p>
167
+ <div style="display:flex;gap:0.5rem">
168
+ <button data-cancel style="flex:1;background:#3f3f46;padding:0.5rem 1rem;border-radius:0.375rem;cursor:pointer;border:none;color:#f4f4f5">Cancel</button>
169
+ <button data-ok style="flex:1;background:#b91c1c;padding:0.5rem 1rem;border-radius:0.375rem;cursor:pointer;border:none;color:#f4f4f5">Confirm</button>
170
+ </div>
171
+ </div>
172
+ `;
173
+
174
+ const cancelBtn =
175
+ overlay.querySelector<HTMLButtonElement>("[data-cancel]")!;
176
+ const okBtn = overlay.querySelector<HTMLButtonElement>("[data-ok]")!;
177
+
178
+ let closed = false;
179
+ const close = (value: boolean) => {
180
+ if (closed) return;
181
+ closed = true;
182
+ document.removeEventListener("keydown", handleKey);
183
+ overlay.remove();
184
+ resolve(value);
185
+ };
186
+
187
+ okBtn.onclick = () => close(true);
188
+ cancelBtn.onclick = () => close(false);
189
+
190
+ // Close on backdrop click
191
+ overlay.onclick = (e) => {
192
+ if (e.target === overlay) close(false);
193
+ };
194
+
195
+ // Handle keyboard
196
+ const handleKey = (e: KeyboardEvent) => {
197
+ if (e.key === "Escape") close(false);
198
+ if (e.key === "Enter") close(true);
199
+ };
200
+ document.addEventListener("keydown", handleKey);
201
+
202
+ document.body.appendChild(overlay);
203
+ queueMicrotask(() => okBtn.focus());
204
+ });
205
+ }
@@ -0,0 +1,11 @@
1
+ import { signal } from "seiro/client";
2
+
3
+ export const route = signal(window.location.hash || "#/");
4
+
5
+ window.addEventListener("hashchange", () => {
6
+ route.value = window.location.hash || "#/";
7
+ });
8
+
9
+ export function navigate(hash: string) {
10
+ window.location.hash = hash;
11
+ }
@@ -0,0 +1,18 @@
1
+ name: seiro-test
2
+
3
+ services:
4
+ postgres:
5
+ image: postgres:17-alpine
6
+ environment:
7
+ POSTGRES_USER: seiro
8
+ POSTGRES_PASSWORD: seiro
9
+ POSTGRES_DB: seiro_test
10
+ ports:
11
+ - "5433:5432"
12
+ volumes:
13
+ - ./init_db:/docker-entrypoint-initdb.d:ro
14
+ healthcheck:
15
+ test: ["CMD-SHELL", "pg_isready -U seiro"]
16
+ interval: 2s
17
+ timeout: 5s
18
+ retries: 10
@@ -0,0 +1,18 @@
1
+ name: seiro-dev
2
+
3
+ services:
4
+ postgres:
5
+ image: postgres:17-alpine
6
+ environment:
7
+ POSTGRES_USER: seiro
8
+ POSTGRES_PASSWORD: seiro
9
+ POSTGRES_DB: seiro
10
+ ports:
11
+ - "5432:5432"
12
+ volumes:
13
+ - ./init_db:/docker-entrypoint-initdb.d:ro
14
+ healthcheck:
15
+ test: ["CMD-SHELL", "pg_isready -U seiro"]
16
+ interval: 2s
17
+ timeout: 5s
18
+ retries: 10
@@ -0,0 +1,23 @@
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>Seiro App</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ </head>
9
+ <body class="bg-zinc-900 text-zinc-100 min-h-screen">
10
+ <header class="border-b border-zinc-800 p-4">
11
+ <div class="max-w-6xl mx-auto flex items-center justify-between">
12
+ <h1 class="text-xl font-bold">Seiro App</h1>
13
+ <auth-form></auth-form>
14
+ </div>
15
+ </header>
16
+ <main class="max-w-6xl mx-auto p-4">
17
+ <div class="text-center py-12">
18
+ <p class="text-zinc-400">Loading...</p>
19
+ </div>
20
+ </main>
21
+ <script type="module" src="/app.js"></script>
22
+ </body>
23
+ </html>
@@ -0,0 +1,2 @@
1
+ -- Enable extensions
2
+ CREATE EXTENSION IF NOT EXISTS pgcrypto;
@@ -0,0 +1,7 @@
1
+ -- Users table
2
+ CREATE TABLE IF NOT EXISTS users (
3
+ id SERIAL PRIMARY KEY,
4
+ email TEXT UNIQUE NOT NULL,
5
+ password TEXT NOT NULL,
6
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
7
+ );
@@ -0,0 +1,55 @@
1
+ -- Auth functions
2
+
3
+ CREATE OR REPLACE FUNCTION cmd_auth_register(data JSONB)
4
+ RETURNS JSONB AS $$
5
+ DECLARE
6
+ v_user RECORD;
7
+ BEGIN
8
+ INSERT INTO users (email, password)
9
+ VALUES (
10
+ data->>'email',
11
+ crypt(data->>'password', gen_salt('bf'))
12
+ )
13
+ RETURNING id, email, created_at INTO v_user;
14
+
15
+ RETURN jsonb_build_object(
16
+ 'id', v_user.id,
17
+ 'email', v_user.email,
18
+ 'created_at', v_user.created_at
19
+ );
20
+ EXCEPTION
21
+ WHEN unique_violation THEN
22
+ RAISE EXCEPTION 'Email already registered';
23
+ END;
24
+ $$ LANGUAGE plpgsql;
25
+
26
+ CREATE OR REPLACE FUNCTION cmd_auth_login(data JSONB)
27
+ RETURNS JSONB AS $$
28
+ DECLARE
29
+ v_user RECORD;
30
+ BEGIN
31
+ SELECT id, email, created_at INTO v_user
32
+ FROM users
33
+ WHERE email = data->>'email'
34
+ AND password = crypt(data->>'password', password);
35
+
36
+ IF v_user IS NULL THEN
37
+ RAISE EXCEPTION 'Invalid email or password';
38
+ END IF;
39
+
40
+ RETURN jsonb_build_object(
41
+ 'id', v_user.id,
42
+ 'email', v_user.email,
43
+ 'created_at', v_user.created_at
44
+ );
45
+ END;
46
+ $$ LANGUAGE plpgsql;
47
+
48
+ CREATE OR REPLACE FUNCTION query_auth_profile(p_user_id INT, params JSONB DEFAULT '{}')
49
+ RETURNS SETOF JSONB AS $$
50
+ SELECT jsonb_build_object(
51
+ 'id', id,
52
+ 'email', email,
53
+ 'created_at', created_at
54
+ ) FROM users WHERE id = p_user_id;
55
+ $$ LANGUAGE sql;
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "my-seiro-app",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "bun run --hot server.ts",
7
+ "check": "tsc --noEmit",
8
+ "db": "docker compose down -v && docker compose up -d",
9
+ "down": "docker compose down -v",
10
+ "test": "bun test server.test.ts"
11
+ },
12
+ "dependencies": {
13
+ "seiro": "^0.1.0",
14
+ "@preact/signals-core": "^1.12.2",
15
+ "postgres": "^3.4.8"
16
+ },
17
+ "devDependencies": {
18
+ "@types/bun": "latest",
19
+ "typescript": "^5.9.3"
20
+ }
21
+ }