hapta 1.0.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/README.md ADDED
@@ -0,0 +1,252 @@
1
+
2
+ # 🛠️ **Hapta**
3
+
4
+ **Hapta** is a modular, scalable, and feature-rich backend framework designed to extend [Pocketbase](https://pocketbase.io) with authentication, schema validation, caching, and tenant-based service orchestration.
5
+
6
+ > Designed to integrate easily into any modern Node.js backend — and purpose-built to unlock Pocketbase for production-scale deployments.
7
+
8
+ ---
9
+
10
+ ## 📦 Installation
11
+
12
+ ```bash
13
+ npm install hapta
14
+ # or
15
+ bun add hapta
16
+ # or
17
+ yarn add hapta
18
+ ```
19
+
20
+ ---
21
+
22
+ ## 🚀 Key Features
23
+
24
+ * ✅ **Authentication via Clover**
25
+ Multi-strategy login (OAuth, password, OTP, MFA) with tenant role/clearance support.
26
+
27
+ * 🔐 **Context-Based Auth (`ctx.principal`)**
28
+ Automatically injects authenticated user state and metadata into each request.
29
+
30
+ * 📦 **Zod-Based Schema Validation**
31
+ Auto-validates route inputs with fully typed schema files.
32
+
33
+ * ⚡ **Smart Caching Layer**
34
+ Dynamic TTL, auto-invalidation, optimistic scaffolds, and memory-based caching.
35
+
36
+ * 🧱 **Modular Context System**
37
+ Unified `Context` class handles services, request metadata, auth, and responses.
38
+
39
+ * 🏗️ **Batch Write Mode**
40
+ Queue DB changes, defer execution, and optimize complex flows.
41
+
42
+ ---
43
+
44
+ ## 📁 Project Structure (Recommended)
45
+
46
+ ```
47
+ /
48
+ └── routes/
49
+ └── auth/
50
+ └── index.ts # Example auth endpoint
51
+ └── schemas/
52
+ └── auth/
53
+ └── index.ts # Zod validation for auth route
54
+
55
+ ```
56
+
57
+ ---
58
+
59
+ ## 🧠 Quick Start
60
+
61
+ ### 🧩 Set up config
62
+ > hapta-config.json
63
+ ```json
64
+ {
65
+ "port": 8080,
66
+ "logLevel": "info",
67
+ "origin":"http://localhost:8081",
68
+ "AI_ENABLED": false,
69
+ "Clover_Tenant_ID": "",
70
+ "Clover_Secret":"",
71
+ "Clover_Server_Url":"clover.postlyapp.com",
72
+ "JWT_SECRET":"*",
73
+ "DatabaseUrl":"http://localhost:8080",
74
+ "ADMIN_EMAIL":"malikwhitterb@gmail.com",
75
+ "ADMIN_PASSWORD":""
76
+ }
77
+
78
+ ```
79
+
80
+ ### 🔐 Use the `principal`
81
+
82
+ ```ts
83
+ if (ctx.principal.isAuthenticated) {
84
+ const username = ctx.principal.username;
85
+ const clearance = ctx.principal.highest_clearance;
86
+ }
87
+ ```
88
+
89
+ ---
90
+
91
+ ## ⚙️ Usage Example
92
+
93
+ ### `/routes/auth/index.ts`
94
+
95
+ ```ts
96
+ import Context from "hapta";
97
+ import { Database } from "hapta";
98
+
99
+ export default async function POST(ctx: Context, DB: Database) {
100
+ const { type } = ctx.metadata.query as any;
101
+
102
+ if (type === "oauth") {
103
+ const result = await ctx.services.Clover.Authenticate({ type: "oauth", ...ctx.metadata.query });
104
+
105
+ return result.isAuthenticated
106
+ ? ctx.json(result)
107
+ : ctx.json({ error: true, message: "OAuth failed" }, 401);
108
+
109
+ } else if (type === "password") {
110
+ const result = await ctx.services.Clover.Authenticate({
111
+ type: "passwordAuth",
112
+ ...ctx.metadata.json
113
+ });
114
+
115
+ return result.isAuthenticated
116
+ ? ctx.json(result)
117
+ : ctx.json({ error: true, message: "Invalid credentials" }, 400);
118
+ }
119
+
120
+ return ctx.json({ error: true, message: "Unsupported auth type" }, 400);
121
+ }
122
+ ```
123
+
124
+ ## Then simply run
125
+
126
+ ```bash
127
+ bun --hot run hapta
128
+ ```
129
+
130
+ ---
131
+
132
+ ## 🧬 Database API
133
+
134
+ ```ts
135
+ import { DatabaseService } from "hapta";
136
+
137
+ const DB = new DatabaseService(pocketbase, cacheHandler);
138
+ ```
139
+
140
+ ### 🔹 Get one (cached)
141
+
142
+ ```ts
143
+ const record = await DB.get("users", "abc123");
144
+ ```
145
+
146
+ ### 🔹 List (cached + paginated)
147
+
148
+ ```ts
149
+ await DB.list("posts", { page: 1, limit: 20 });
150
+ ```
151
+
152
+ ### 🔹 Create with scaffold
153
+
154
+ ```ts
155
+ DB.setBatch(true);
156
+ await DB.create("comments", { body: "Hello!" }, true);
157
+ await DB.saveChanges();
158
+ ```
159
+
160
+ ---
161
+
162
+ ## 🧰 Built-in Response Helpers
163
+
164
+ ```ts
165
+ ctx.json({ success: true }, 200);
166
+ ctx.html("<h1>Welcome</h1>");
167
+ ctx.text("Hello world");
168
+ ```
169
+
170
+ All responses include CORS headers out of the box:
171
+
172
+ ```http
173
+ Access-Control-Allow-Origin: *
174
+ Access-Control-Allow-Headers: Content-Type, Authorization
175
+ ```
176
+
177
+ ---
178
+
179
+ ## 🧪 Validation with Zod
180
+
181
+ ```ts
182
+ // /schemas/auth/index.ts
183
+ import { z } from "zod";
184
+
185
+ export default z.object({
186
+ emailOrUsername: z.string().min(3),
187
+ password: z.string().min(6),
188
+ });
189
+ ```
190
+
191
+ Hapta automatically loads and validates this schema for `/auth` requests before your handler runs.
192
+
193
+ ---
194
+
195
+ ## 🔄 Batch Mode
196
+
197
+ ```ts
198
+ DB.setBatch(true);
199
+ await DB.create("logs", { message: "Init" });
200
+ await DB.update("users", "abc", { active: false });
201
+ await DB.delete("sessions", "xyz");
202
+ await DB.saveChanges(); // executes all queued ops
203
+ ```
204
+
205
+ ---
206
+
207
+ ## 📦 Caching Details
208
+
209
+ | Method | Caches | TTL | Invalidation |
210
+ | ---------- | ------ | ------- | ------------------------------- |
211
+ | `get()` | ✅ | dynamic | on `update`, `delete` |
212
+ | `list()` | ✅ | dynamic | on `create`, `update`, `delete` |
213
+ | `create()` | ➖ | ➖ | invalidates all list caches |
214
+ | `update()` | ➖ | ➖ | invalidates list + get cache |
215
+ | `delete()` | ➖ | ➖ | invalidates list + get cache |
216
+
217
+ ---
218
+
219
+ ## 🧱 Integrating with Express, Bun, or Custom Server
220
+
221
+ You can wire Hapta into any server environment:
222
+
223
+ ```ts
224
+ const ctx = new Context();
225
+ // inject principal, services, metadata, etc.
226
+ ctx.metadata = {
227
+ requestID: "xyz",
228
+ timestamp: new Date(),
229
+ json: await req.json(),
230
+ headers: req.headers,
231
+ ...
232
+ };
233
+
234
+ // call route handler
235
+ const res = await handler(ctx, DB);
236
+ return res;
237
+ ```
238
+
239
+ ---
240
+
241
+ ## 🧾 License
242
+
243
+ MIT © Postr-Inc — Built for scale.
244
+
245
+ ---
246
+
247
+ ## 💬 Questions?
248
+
249
+ * Open an issue
250
+ * PRs welcome
251
+ * Built with ❤️ by the Postr-Inc team
252
+
package/bun.lock ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "workspaces": {
4
+ "": {
5
+ "dependencies": {
6
+ "pocketbase": "^0.26.1",
7
+ },
8
+ },
9
+ },
10
+ "packages": {
11
+ "pocketbase": ["pocketbase@0.26.1", "", {}, "sha512-fjcPDpxyqTZCwqGUTPUV7vssIsNMqHxk9GxbhxYHPEf18RqX2d9cpSqbbHk7aas30jqkgptuKfG7aY/Mytjj3g=="],
12
+ }
13
+ }
@@ -0,0 +1,42 @@
1
+ // A base type that all database records must have.
2
+ export type BaseRecord = {
3
+ id: string;
4
+ cacheKey: string,
5
+ collectionName: string,
6
+ collectionId: string
7
+ };
8
+
9
+ // Data for creating a new record, which is the full object minus the 'id'.
10
+ export type CreateData<T> = Omit<T, 'id'>;
11
+
12
+ // A robust options object for querying lists of items.
13
+ export type QueryOptions<T> = {
14
+ // Example: { name: 'Hapta', version: 2 }
15
+ filter?: Partial<T>;
16
+ sort?: {
17
+ field: keyof T;
18
+ direction: 'asc' | 'desc';
19
+ };
20
+ pagination?: {
21
+ limit: number;
22
+ offset: number;
23
+ };
24
+ };
25
+
26
+ // A fully-typed, generic, and async interface for database operations.
27
+ export type Database = {
28
+ /** Gets a single item by its ID. */
29
+ get<T extends BaseRecord>(collection: string, id: string): Promise<T | null>;
30
+
31
+ /** Gets multiple items using a flexible query. */
32
+ list<T extends BaseRecord>(collection: string, options?: QueryOptions<T>): Promise<T[]>;
33
+
34
+ /** Creates a new item in the database. */
35
+ create<T extends BaseRecord>(collection: string, data: CreateData<T>): Promise<T>;
36
+
37
+ /** Updates an existing item by its ID. */
38
+ update<T extends BaseRecord>(collection: string, id: string, data: Partial<CreateData<T>>): Promise<T>;
39
+
40
+ /** Deletes an item by its ID. */
41
+ delete(collection: string, id: string): Promise<void>;
42
+ };
@@ -0,0 +1,209 @@
1
+ import { config } from "../../../src/core/config";
2
+ const corsHeaders = {
3
+ "Access-Control-Allow-Origin": config.origin,
4
+ "Access-Control-Allow-Methods": "OPTIONS, GET, POST, PUT, PATCH, DELETE",
5
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
6
+ };
7
+
8
+ /**
9
+ * A discriminated union to safely represent the authenticated actor.
10
+ * The state is either fully authenticated or explicitly not.
11
+ */
12
+
13
+ export type AuthenticatedPrincipal = {
14
+ isAuthenticated: true;
15
+ id: string; // The unique ID of the actor from Clover
16
+ token: string; // The auth token used for this context
17
+ Roles: string[];
18
+ username: string,
19
+ avatar: string,
20
+
21
+ /**
22
+ * @description used to determine the highest rated security clearance a user has this helps identify users with low security levels.
23
+ */
24
+ highest_clearance: number,
25
+ createdAt: Date;
26
+ /**
27
+ * Assigned to allows devs to see which group the user is assigned to and which clover instance id they are authenticated in
28
+ */
29
+ clover_group_assigned_To: string
30
+ clover_assigned_id: string,
31
+
32
+ };
33
+
34
+ export type UnauthenticatedPrincipal = {
35
+ isAuthenticated: false;
36
+ };
37
+
38
+ export type Principal = AuthenticatedPrincipal | UnauthenticatedPrincipal;
39
+
40
+ // ---
41
+
42
+ type PasswordAuthOptions = {
43
+ type: 'passwordAuth';
44
+ emailOrUsername: string;
45
+ password: string;
46
+ };
47
+
48
+ type MfaAuthOptions = {
49
+ type: 'mfa';
50
+ userId: string; // MFA usually follows a login, so you'd have a user ID
51
+ mfaCode: string;
52
+ };
53
+
54
+ type OAuthAuthOptions = {
55
+ type: 'oauth';
56
+ code: string;
57
+ redirectUri: string | null;
58
+ authenticated_id: string;
59
+ accessToken?: string; // token received from Clover OAuth flow
60
+ };
61
+
62
+
63
+ type OtpAuthOptions = {
64
+ type: 'otp';
65
+ email: string;
66
+ otpCode: string;
67
+ };
68
+
69
+ // Create a union of all possible authentication methods
70
+ type AuthOptions = PasswordAuthOptions | MfaAuthOptions | OtpAuthOptions | OAuthAuthOptions;
71
+
72
+ // Define the final function type
73
+ // (Assuming it returns a Promise with the Principal type from our previous discussions)
74
+ type AuthenticateFn = (options: AuthOptions) => Promise<Principal>;
75
+ type RegisterFn = (data: any ) => Promise<boolean>
76
+ type TokenOptions = {
77
+ payload: {
78
+ id: string,
79
+ },
80
+ iat: number
81
+ }
82
+ type VerifyFn = (token: string) => Promise<Principal>;
83
+
84
+ export type ServiceConfigs = {
85
+ Clover: {
86
+ Tenant_ID: string;
87
+ Roles?: { name: string; security_level: number }[];
88
+ Authorized_Users: string[];
89
+ Authenticate: AuthenticateFn;
90
+ Register: RegisterFn;
91
+ Verify?: VerifyFn; // just verify token
92
+ };
93
+ };
94
+
95
+ // ---
96
+
97
+ /**
98
+ * Metadata for logging, tracing, and auditing.
99
+ * Key fields are now required for better traceability.
100
+ */
101
+ export type RequestMetadata = {
102
+ requestID: string;
103
+ timestamp: Date;
104
+ ipAddress?: string;
105
+ userAgent?: string;
106
+ body: any,
107
+ json: {},
108
+ headers: Headers | {};
109
+ query:{},
110
+ params: {}
111
+ };
112
+
113
+ // ---
114
+
115
+ /**
116
+ * The main application context, redesigned for clarity.
117
+ */
118
+ interface IContext {
119
+ /**
120
+ * The authenticated user or system actor for this request,
121
+ * populated by the Clover authentication backend.
122
+ */
123
+ principal: Principal;
124
+
125
+ /**
126
+ * Configurations for downstream services.
127
+ */
128
+ services: ServiceConfigs;
129
+
130
+ /**
131
+ * Optional tenant or organization context.
132
+ */
133
+ tenantId?: string | undefined | null;
134
+
135
+ /**
136
+ * Metadata about the incoming request.
137
+ * @prop headers
138
+ * @prop requestID
139
+ * @prop timestamp
140
+ * @prop ipAddress
141
+ * @prop userAgent
142
+ * @prop body
143
+ * @prop json
144
+ */
145
+ metadata: RequestMetadata;
146
+ json: (value: any, status?: number, statusText?:string) => Response
147
+ html: (value: string, status?: number, statusText?:string) => Response
148
+ text: (value: string, status?: number, statusText?:string) => Response
149
+ }
150
+
151
+ export default class Context implements IContext {
152
+ public principal: Principal;
153
+ public services: ServiceConfigs;
154
+ public tenantId?: string;
155
+ public metadata: RequestMetadata;
156
+
157
+ constructor() {
158
+ // Initialize with default/empty values
159
+ this.principal = { isAuthenticated: false };
160
+ this.services = {} as ServiceConfigs; // Cast as empty, to be populated by the builder
161
+ this.metadata = {
162
+ requestID: '',
163
+ timestamp: new Date(),
164
+ json: {},
165
+ headers: {},
166
+ query: {},
167
+ params: {}
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Helper method to create a JSON response.
173
+ */
174
+ json(value: any, status: number = 200): Response {
175
+ return Response.json(value, {
176
+ status,
177
+ headers: {
178
+ "Content-Type": "application/json",
179
+ ...corsHeaders
180
+ }
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Helper method to create an HTML response.
186
+ */
187
+ html(value: string, status: number = 200): Response {
188
+ return new Response(value, {
189
+ status,
190
+ headers: {
191
+ "Content-Type": "text/html",
192
+ ...corsHeaders
193
+ }
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Helper method to create a plain text response.
199
+ */
200
+ text(value: string, status: number = 200): Response {
201
+ return new Response(value, {
202
+ status,
203
+ headers: {
204
+ "Content-Type": "text/plain",
205
+ ...corsHeaders
206
+ }
207
+ });
208
+ }
209
+ }
package/index.ts ADDED
@@ -0,0 +1,384 @@
1
+ #!/usr/bin/env bun
2
+ import Context from "./helpers/HTTP/Request/Context";
3
+ import { serve, FileSystemRouter, type Serve, type Server } from "bun";
4
+ import { config } from "./src/core/config";
5
+ import Pocketbase from "pocketbase";
6
+ import path from "path";
7
+ import { DatabaseService } from "./src/core/CrudManager";
8
+ import Cache from "./src/core/CacheManager";
9
+ import process from "process";
10
+ import jwt from "jsonwebtoken";
11
+ import crypto from "crypto";
12
+ import * as z from "zod";
13
+ import { watch } from "fs";
14
+ import fs from "fs"
15
+ import { pathToFileURL } from "url";
16
+
17
+ // --- Global server and router instances ---
18
+ let server: Server;
19
+ // MODIFIED: Make the router a single, persistent instance
20
+ const router = new FileSystemRouter({
21
+ style: "nextjs",
22
+ dir: path.join(process.cwd(), "routes"),
23
+ });
24
+
25
+
26
+ // --- Validation: Ensure required config vars are present ---
27
+ if (!config.Clover_Secret || !config.Clover_Tenant_ID) {
28
+ console.error("❌ Clover_Secret and Clover_Tenant_ID must be set in your config.");
29
+ process.exit(1);
30
+ }
31
+
32
+ if (!config.ADMIN_EMAIL || !config.ADMIN_PASSWORD) {
33
+ console.error("❌ Please set an admin email and admin password for database authentication");
34
+ process.exit(1);
35
+ }
36
+
37
+ if(!fs.existsSync(path.join(process.cwd(), "routes"))){
38
+ console.error("❌ Please create a routes folder and create your first route");
39
+ process.exit(1);
40
+ }
41
+
42
+ // --- Pocketbase Connection ---
43
+ const pb = new Pocketbase(config.DatabaseUrl);
44
+ try {
45
+ await pb.collection("_superusers").authWithPassword(config.ADMIN_EMAIL, config.ADMIN_PASSWORD);
46
+ } catch (_) { }
47
+
48
+ // --- Primary Tenant Data ---
49
+ const primaryTenantData = await fetch(`${config.Clover_Server_Url}/tenants/${config.Clover_Tenant_ID}`, {
50
+ headers: { Authorization_Secret: config.Clover_Secret },
51
+ }).then(async (res) => {
52
+ if (!res.ok) throw new Error(`Failed to fetch primary tenant data: ${res.status} ${res.statusText}`);
53
+ return res.json();
54
+ });
55
+ console.log("✅ Primary tenant data loaded.");
56
+
57
+ const cache = new Cache();
58
+ const db = new DatabaseService(pb, cache);
59
+ // --- Server Configuration Builder ---
60
+ async function createServeConfig(): Promise<Serve> {
61
+ console.log("🛠️ Building server configuration...");
62
+
63
+ // MODIFIED: Reload the router to detect new/deleted files
64
+ router.reload();
65
+
66
+ const routeHandlers = new Map<string, (ctx: Context, db: DatabaseService) => Promise<Response>>();
67
+ const schemas = new Map();
68
+ const middlewares = new Map<string, (ctx: Context) => Promise<Response | boolean>>();
69
+
70
+ const reimportModule = async (modulePath: string) => {
71
+ const resolvedPath = path.resolve(modulePath);
72
+ if (require.cache[resolvedPath]) {
73
+ delete require.cache[resolvedPath];
74
+ }
75
+ const module = require(modulePath)
76
+ return module
77
+ };
78
+
79
+ for (const [pathname, routePath] of Object.entries(router.routes)) {
80
+ try {
81
+ const routeModule = await reimportModule(routePath as string);
82
+ if (routeModule.default) {
83
+ routeHandlers.set(pathname, routeModule.default);
84
+ }
85
+
86
+ const schemaPath = path.join(process.cwd(), "schemas", pathname, "index.ts");
87
+ if (await Bun.file(schemaPath).exists()) {
88
+ const schema = await reimportModule(schemaPath);
89
+ if (schema.default) schemas.set(pathname, schema.default);
90
+ }
91
+
92
+ const middlewarePath = path.join(path.dirname(routePath as string), "middleware.ts");
93
+ if (await Bun.file(middlewarePath).exists()) {
94
+ const middleware = await reimportModule(middlewarePath);
95
+ if (middleware.default) middlewares.set(pathname, middleware.default);
96
+ }
97
+ } catch (e) {
98
+ console.error(`❌ Error loading module for route ${pathname}:`, e);
99
+ }
100
+ }
101
+
102
+ console.log("✅ All modules loaded.");
103
+
104
+ return {
105
+ port: config.port,
106
+ async fetch(req: Request) {
107
+ if (req.method === "OPTIONS") {
108
+ return new Response(null, {
109
+ status: 204,
110
+ headers: {
111
+ "Access-Control-Allow-Origin": config.origin,
112
+ "Access-Control-Allow-Methods": "OPTIONS, GET, POST, PUT, PATCH, DELETE",
113
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
114
+ "Access-Control-Max-Age": "86400",
115
+ },
116
+ });
117
+ }
118
+ const url = new URL(req.url);
119
+ const routeMatch = router.match(url.href);
120
+
121
+ if (!routeMatch) {
122
+ return new Response("404 Not Found", { status: 404 });
123
+ }
124
+
125
+ const routeHandler = routeHandlers.get(routeMatch.name);
126
+ const middleware = middlewares.get(routeMatch.name);
127
+ const schema = schemas.get(routeMatch.name);
128
+
129
+ if (!routeHandler) {
130
+ return new Response(`Route handler for ${routeMatch.name} not found.`, { status: 404 });
131
+ }
132
+
133
+ try {
134
+ const context = await buildRequestContext(req, req.headers);
135
+ context.metadata.params = routeMatch.params;
136
+ context.metadata.query = routeMatch.query;
137
+ context.metadata.headers = Object.fromEntries(req.headers.entries());
138
+
139
+ if (context.principal.isAuthenticated) {
140
+
141
+ }
142
+ if (middleware) {
143
+ const result = await middleware(context);
144
+ if (result instanceof Response) return result;
145
+ if (result === false) return new Response("Forbidden", { status: 403 });
146
+ }
147
+
148
+ if (schema) {
149
+ const validation = {
150
+ body: schema.body?.safeParse(context.metadata.json),
151
+ query: schema.query?.safeParse(context.metadata.query),
152
+ headers: schema.headers?.safeParse(context.metadata.headers),
153
+ };
154
+ for (const key of ["body", "query", "headers"] as const) {
155
+ if (validation[key] && !validation[key]?.success) {
156
+ return new Response(JSON.stringify({ success: false, error: validation[key]?.error.flatten() }), { status: 400, headers: { "Content-Type": "application/json" } });
157
+ }
158
+ }
159
+ }
160
+
161
+ return await routeHandler(context, db);
162
+ } catch (err) {
163
+ console.error(`❌ Error handling ${url.pathname}:`, err);
164
+ return new Response("Internal Server Error", { status: 500 });
165
+ }
166
+ },
167
+ error(error) {
168
+ console.error("☠️ Uncaught Error:", error);
169
+ return new Response("Something went wrong!", { status: 500 });
170
+ },
171
+ };
172
+ }
173
+
174
+ // --- Debounced File Watcher ---
175
+ let reloadTimeout: Timer | null = null;
176
+ function watchFiles() {
177
+ const watchDirs = [
178
+ path.join(process.cwd(), "routes"),
179
+ path.join(process.cwd(), "schemas")
180
+ ];
181
+
182
+ const triggerReload = (changeType: string, filename: string | null) => {
183
+ if (!filename) return;
184
+ if (reloadTimeout) clearTimeout(reloadTimeout);
185
+
186
+ reloadTimeout = setTimeout(async () => {
187
+ console.log(`\n🔁 Change detected in ${changeType}: ${filename}.`);
188
+ try {
189
+ const newConfig = await createServeConfig();
190
+ server.reload(newConfig);
191
+ console.log("✅ Server reloaded successfully.");
192
+ } catch (e) {
193
+ console.error("❌ Server reload failed:", e)
194
+ }
195
+ }, 100); // Debounce for 100ms
196
+ };
197
+
198
+ for (const dir of watchDirs) {
199
+ watch(dir, { recursive: true }, (changeType, filename) => triggerReload(changeType, filename));
200
+ }
201
+
202
+ console.log("👀 Watching for file changes in routes/ and schemas/...");
203
+ }
204
+
205
+ // --- Helper Functions ---
206
+ function validateToken(token: string) {
207
+ try {
208
+ return jwt.verify(token, config.JWT_SECRET) as jwt.JwtPayload;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ async function buildRequestContext(req: Request, headers: Headers) {
215
+ const reqJSON = await req.json().catch(() => ({}));
216
+
217
+ const hasToken = headers.has("Authorization")
218
+ var isAuthenticated = false;
219
+ if (hasToken) {
220
+ try {
221
+ jwt.verify(token as string, config.JWT_SECRET)
222
+ isAuthenticated = true
223
+ } catch (error) {
224
+ isAuthenticated = false
225
+ }
226
+ }
227
+ const context = {
228
+ principal: { isAuthenticated },
229
+ services: {
230
+ Clover: {
231
+ Tenant_ID: primaryTenantData.id,
232
+ Authorized_Users: primaryTenantData.expand?.Tenant_Groups?.Users || [],
233
+ Register: async (data: any) => {
234
+ const fetchBody = {
235
+ Tenant_Id: primaryTenantData.id,
236
+ Secret: config.Clover_Secret,
237
+ ...data
238
+ }
239
+
240
+ if (!data.record.email || !data.record.password || !data.record.username) {
241
+ return { success: false, error: "Missing email username or password" }
242
+ }
243
+ const res = await fetch(`${config.Clover_Server_Url}/auth/signup`, {
244
+ method: "POST",
245
+ headers: {
246
+ "Authorization_Secret": config.Clover_Secret
247
+ },
248
+ body: JSON.stringify(fetchBody)
249
+ })
250
+
251
+ return await res.json()
252
+
253
+ },
254
+ Authenticate: async (options) => {
255
+ const fetchBody = {
256
+ Tenant_Id: primaryTenantData.id,
257
+ Secret: config.Clover_Secret,
258
+ type: options.type,
259
+ };
260
+
261
+ if (options.type === "passwordAuth") {
262
+ fetchBody.emailOrUsername = options.emailOrUsername;
263
+ fetchBody.password = options.password;
264
+ }
265
+
266
+ if (options.type === "oauth") {
267
+ fetchBody.code = options.code;
268
+ fetchBody.redirectUri = options.redirectUri;
269
+ fetchBody.client_secret = config.Clover_Secret
270
+ fetchBody.authenticated_id = options.authenticated_id
271
+ }
272
+
273
+
274
+ const authRes = await fetch(`${config.Clover_Server_Url}/oauth/token`, {
275
+ method: "POST",
276
+ headers: {
277
+ "Content-Type": "application/json",
278
+ Authorization_Secret: config.Clover_Secret,
279
+ tenantid: config.Clover_Tenant_ID,
280
+ "User-Agent": headers.get("User-Agent") || "",
281
+ },
282
+ body: JSON.stringify(fetchBody),
283
+ });
284
+
285
+ if (!authRes.ok) {
286
+ return {
287
+ isAuthenticated: false,
288
+ error: true,
289
+ message: "Authentication failed",
290
+ };
291
+ }
292
+
293
+ const responseJson = await authRes.json();
294
+ const record = responseJson.AuthenticatedModal;
295
+ record.clover_assigned_id = primaryTenantData.id;
296
+
297
+ record.token = jwt.sign(
298
+ {
299
+ id: record.id,
300
+ clover_assigned_id: record.clover_assigned_id,
301
+ Roles: record.Roles,
302
+ Group: record.clover_group_assigned_To,
303
+ },
304
+ config.JWT_SECRET,
305
+ );
306
+
307
+ record.isAuthenticated = true;
308
+
309
+ return record;
310
+ },
311
+ Verify: async (token) => {
312
+ try {
313
+ const payload = jwt.verify(token, config.JWT_SECRET);
314
+ return {
315
+ isAuthenticated: true,
316
+ id: payload.id,
317
+ clover_group_assigned_To: payload.Group,
318
+ clover_assigned_id: payload.clover_assigned_id,
319
+ Roles: payload.Roles,
320
+ token,
321
+ };
322
+ } catch {
323
+ return { isAuthenticated: false };
324
+ }
325
+ },
326
+ Roles: Array.isArray(primaryTenantData.Tenant_Roles)
327
+ ? primaryTenantData.Tenant_Roles
328
+ : [primaryTenantData.Tenant_Roles],
329
+ },
330
+ },
331
+ metadata: {
332
+ requestID: crypto.randomUUID(),
333
+ timestamp: new Date(),
334
+ params: {},
335
+ query: {},
336
+ json: reqJSON,
337
+ headers : {}
338
+ },
339
+ tenantId: headers.get("tenantid") ?? undefined,
340
+ json: (value: any, status?: number, statusText?:string) => {
341
+ return Response.json(value, {
342
+ ...(status && {status}),
343
+ headers: {
344
+ "Access-Control-Origin": config.origin,
345
+ "Access-Control-Allow-Methods": "GET,PATCH,PUT,DELETE",
346
+ "Access-Control-Allow-Headers": "Content-Type, Authorization"
347
+ }
348
+ })
349
+ },
350
+
351
+ html: (value: string, status: number, statusText?: string) => {
352
+ return new Response(value, {
353
+ ...(status && {status}),
354
+ ...(statusText && {statusText}),
355
+ headers:{
356
+ "Content-Type": "text/html",
357
+ "Access-Control-Origin": config.origin,
358
+ "Access-Control-Allow-Methods": "GET,PATCH,PUT,DELETE",
359
+ "Access-Control-Allow-Headers": "Content-Type, Authorization"
360
+ }
361
+ })
362
+ }
363
+ };
364
+
365
+ const authHeader = headers.get("authorization");
366
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.split(" ")[1] : authHeader;
367
+
368
+ if (token) {
369
+ context.principal = await context.services.Clover.Verify(token);
370
+ }
371
+
372
+ return context;
373
+ }
374
+
375
+
376
+ // --- Initial Server Start ---
377
+ server = Bun.serve(await createServeConfig());
378
+ console.log(`🚀 Hapta listening at http://localhost:${server.port}`);
379
+ watchFiles();
380
+
381
+ export {
382
+ DatabaseService,
383
+ Context
384
+ };
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "hapta",
3
+ "bin": {
4
+ "hapta":"./index.ts"
5
+ },
6
+ "version": "1.0.0",
7
+ "description": "modular, scalable, and feature-rich backend framework designed to extend Pocketbase with authentication, schema validation, caching, and tenant-based service orchestration.",
8
+ "dependencies": {
9
+ "jsonwebtoken": "^9.0.2",
10
+ "pocketbase": "^0.26.1",
11
+ "zod": "^4.0.5"
12
+ }
13
+ }
File without changes
@@ -0,0 +1,117 @@
1
+ //@ts-nocheck
2
+ const COMPRESSION_THRESHOLD = 1024;
3
+
4
+ interface CacheSyncMessage {
5
+ action: "set" | "delete" | "invalidate";
6
+ key: string;
7
+ data?: any; // For 'set'
8
+ expiresAt?: number;
9
+ source: number;
10
+ }
11
+
12
+ export default class CacheHandler {
13
+ private cache = new Map<string, { data: any; ttl: number; compressed?: boolean }>();
14
+ private broadcastCallback?: (msg: CacheSyncMessage) => void;
15
+
16
+ constructor() {
17
+ this.startExpirationCheck();
18
+ }
19
+
20
+ public setBroadcastCallback(callback: (msg: CacheSyncMessage) => void) {
21
+ this.broadcastCallback = callback;
22
+ }
23
+ public timesVisited = new Map<string, { incremental: number }>();
24
+ /** Store data in cache, compress if large */
25
+ public set(key: string, data: any, ttlSeconds = 0, isInternal = false): any {
26
+ if (!key.includes("undefined") && !key.includes("null")) {
27
+ const expiresAt = ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : 0;
28
+ try {
29
+ const jsonStr = JSON.stringify(data);
30
+ if (jsonStr.length > COMPRESSION_THRESHOLD) {
31
+ const compressed = Bun.gzipSync(new TextEncoder().encode(jsonStr));
32
+ this.cache.set(key, { data: compressed, ttl: expiresAt, compressed: true });
33
+ } else {
34
+ this.cache.set(key, { data, ttl: expiresAt });
35
+ }
36
+ } catch {
37
+ this.cache.set(key, { data, ttl: expiresAt });
38
+ }
39
+
40
+ if (this.broadcastCallback && !isInternal) {
41
+ this.broadcastCallback({ action: "set", key, data, expiresAt, source: parseInt(config.Server.NodeId as any) });
42
+ }
43
+ } else {
44
+ console.warn(`[CacheHandler] Invalid cache key: ${key}`);
45
+ }
46
+ return data;
47
+ }
48
+ public getDynamicTTL(key: string, mode: "immediate" | "short" | "medium" | "long" | "dynamic" = "dynamic"): number {
49
+ if (mode !== "dynamic") {
50
+ switch (mode) {
51
+ case "immediate": return 5 * 60 * 1000;
52
+ case "short": return 30 * 60 * 1000;
53
+ case "medium": return 2 * 60 * 60 * 1000;
54
+ case "long": return 6 * 60 * 60 * 1000;
55
+ }
56
+ }
57
+ let status = this.timesVisited.get(key) ?? { incremental: 0 };
58
+ status.incremental++;
59
+ this.timesVisited.set(key, status);
60
+
61
+ if (status.incremental > 5) {
62
+ return 30 * 60 * 1000; // 30 mins
63
+ } else if (status.incremental > 0) {
64
+ return 2 * 60 * 60 * 1000; // 2 hrs
65
+ } else {
66
+ return 6 * 60 * 60 * 1000; // 6 hrs default
67
+ }
68
+ }
69
+ public get<T>(key: string): T | null {
70
+ const entry = this.cache.get(key);
71
+ if (!entry) return null;
72
+ if (entry.ttl > 0 && entry.ttl < Date.now()) {
73
+ this.cache.delete(key);
74
+ return null;
75
+ }
76
+ if (entry.compressed) {
77
+ try {
78
+ const decompressed = Bun.gunzipSync(entry.data);
79
+ return JSON.parse(new TextDecoder().decode(decompressed));
80
+ } catch {
81
+ this.cache.delete(key);
82
+ return null;
83
+ }
84
+ }
85
+ return entry.data;
86
+ }
87
+
88
+ public delete(key: string): boolean {
89
+ if (this.broadcastCallback) {
90
+ this.broadcastCallback({ action: "delete", key, source: parseInt(config.Server.NodeId as any) });
91
+ }
92
+ return this.cache.delete(key);
93
+ }
94
+
95
+ /** Invalidate all keys starting with prefix */
96
+ public invalidateByPrefix(prefix: string): void {
97
+ for (const key of this.cache.keys()) {
98
+ if (key.startsWith(prefix)) {
99
+ this.cache.delete(key);
100
+ if (this.broadcastCallback) {
101
+ this.broadcastCallback({ action: "invalidate", key, source: parseInt(config.Server.NodeId as any) });
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ private startExpirationCheck() {
108
+ setInterval(() => {
109
+ const now = Date.now();
110
+ for (const [key, { ttl }] of this.cache.entries()) {
111
+ if (ttl > 0 && ttl < now) {
112
+ this.cache.delete(key);
113
+ }
114
+ }
115
+ }, 60000);
116
+ }
117
+ }
@@ -0,0 +1,197 @@
1
+ import Pocketbase from "pocketbase";
2
+ import CacheHandler from "./CacheManager";
3
+
4
+ export type BaseRecord = {
5
+ id: string;
6
+ created: string;
7
+ updated: string;
8
+ };
9
+
10
+ export type ListOptions<T> = {
11
+ page?: number;
12
+ limit?: number;
13
+ filter?: string;
14
+ sort?: string;
15
+ expand?: string[];
16
+ };
17
+
18
+ export type PaginatedResponse<T> = {
19
+ items: T[];
20
+ totalItems: number;
21
+ totalPages: number;
22
+ page: number;
23
+ limit: number;
24
+ cacheKey: string; // ✅ clients can store this key!
25
+ };
26
+
27
+ export class DatabaseService {
28
+ private pb: Pocketbase;
29
+ private cache: CacheHandler;
30
+ private isBatchMode = false;
31
+ private batchQueue: Array<{ action: "create" | "update" | "delete"; payload: any }> = [];
32
+
33
+ constructor(pocketbaseInstance: Pocketbase, cacheController: CacheHandler) {
34
+ this.pb = pocketbaseInstance;
35
+ this.cache = cacheController;
36
+ }
37
+
38
+ public setBatch(enable: boolean) {
39
+ this.isBatchMode = enable;
40
+ }
41
+
42
+ /** Combines parts into a normalized cache key. */
43
+ private generateCacheKey(parts: (string | number | undefined)[]): string {
44
+ return parts.filter(Boolean).join(":");
45
+ }
46
+
47
+ /** Dynamic TTL based on usage or type. */
48
+ private getDynamicTTL(key: string, mode: "short" | "medium" | "long" = "medium"): number {
49
+ switch (mode) {
50
+ case "short": return 10 * 60; // 10 min
51
+ case "medium": return 60 * 60; // 1 hour
52
+ case "long": return 6 * 60 * 60; // 6 hours
53
+ }
54
+ }
55
+
56
+ public async saveChanges() {
57
+ for (const op of this.batchQueue) {
58
+ switch (op.action) {
59
+ case "create":
60
+ await this.create(op.payload.collection, op.payload.data, false);
61
+ break;
62
+ case "update":
63
+ await this.update(op.payload.collection, op.payload.id, op.payload.data);
64
+ break;
65
+ case "delete":
66
+ await this.delete(op.payload.collection, op.payload.id);
67
+ break;
68
+ }
69
+ }
70
+ this.batchQueue = [];
71
+ this.isBatchMode = false;
72
+ }
73
+
74
+ /** Get one record, with caching. */
75
+ public async get<T extends BaseRecord>(
76
+ collection: string,
77
+ id: string,
78
+ expand?: string[]
79
+ ): Promise<(T & { cacheKey: string }) | null> {
80
+ const cacheKey = this.generateCacheKey([collection, "get", id, expand?.join(",")]);
81
+ const cached = this.cache.get<T>(cacheKey);
82
+ if (cached) return { ...cached, cacheKey };
83
+
84
+ try {
85
+ const record = await this.pb.collection(collection).getOne<T>(id, {
86
+ ...(expand && { expand: expand.join(",") }),
87
+ });
88
+ this.cache.set(cacheKey, record, this.getDynamicTTL(cacheKey));
89
+ return { ...record, cacheKey };
90
+ } catch (error: any) {
91
+ if (error.status === 404) return null;
92
+ console.error(`[DatabaseService] get failed: ${collection}/${id}`, error);
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ /** List records with pagination + caching. */
98
+ public async list<T extends BaseRecord>(
99
+ collection: string,
100
+ options: ListOptions<T>
101
+ ): Promise<PaginatedResponse<T>> {
102
+ const { page = 1, limit = 10, filter, sort, expand } = options;
103
+ const cacheKey = this.generateCacheKey([collection, "list", page, limit, filter, sort, expand?.join(",")]);
104
+
105
+ const cached = this.cache.get<PaginatedResponse<T>>(cacheKey);
106
+ if (cached) return cached;
107
+
108
+ const result = await this.pb.collection(collection).getList<T>(page, limit, {
109
+ filter,
110
+ sort,
111
+ ...(expand && { expand: expand.join(",") }),
112
+ });
113
+
114
+ const response: PaginatedResponse<T> = {
115
+ items: result.items,
116
+ totalItems: result.totalItems,
117
+ totalPages: result.totalPages,
118
+ page: result.page,
119
+ limit: result.perPage,
120
+ cacheKey,
121
+ };
122
+
123
+ this.cache.set(cacheKey, response, this.getDynamicTTL(cacheKey));
124
+ return response;
125
+ }
126
+
127
+ /** Create a record. Supports batching + scaffold. */
128
+ public async create<T extends BaseRecord>(
129
+ collection: string,
130
+ data: Partial<T>,
131
+ useScaffold: boolean = false
132
+ ): Promise<T> {
133
+ if (this.isBatchMode && useScaffold) {
134
+ const scaffoldId = Math.random().toString(36).substring(2, 10);
135
+ const scaffoldRecord: any = {
136
+ ...data,
137
+ id: scaffoldId,
138
+ created: new Date().toISOString(),
139
+ updated: new Date().toISOString(),
140
+ scaffold: true,
141
+ };
142
+
143
+ // Prepend to any feed cache:
144
+ this.cache.keys().forEach((key) => {
145
+ if (key.startsWith(`${collection}:list`)) {
146
+ const existing = this.cache.get<any>(key);
147
+ if (existing && Array.isArray(existing.items)) {
148
+ existing.items = [scaffoldRecord, ...existing.items];
149
+ this.cache.set(key, existing, this.getDynamicTTL(key));
150
+ }
151
+ }
152
+ });
153
+
154
+ this.batchQueue.push({ action: "create", payload: { collection, data } });
155
+ return scaffoldRecord;
156
+ }
157
+
158
+ const record = await this.pb.collection(collection).create<T>(data);
159
+ this.cache.invalidateByPrefix(this.generateCacheKey([collection, "list"]));
160
+ return record;
161
+ }
162
+
163
+ public async update<T extends BaseRecord>(
164
+ collection: string,
165
+ id: string,
166
+ data: Partial<T>,
167
+ expand?: string[]
168
+ ): Promise<T> {
169
+ if (this.isBatchMode) {
170
+ this.batchQueue.push({ action: "update", payload: { collection, id, data } });
171
+ return { id, created: "", updated: "", ...(data as any) };
172
+ }
173
+
174
+ const record = await this.pb.collection(collection).update<T>(id, data, {
175
+ ...(expand && { expand: expand.join(",") }),
176
+ });
177
+
178
+ this.cache.delete(this.generateCacheKey([collection, "get", id]));
179
+ this.cache.invalidateByPrefix(this.generateCacheKey([collection, "list"]));
180
+ return record;
181
+ }
182
+
183
+ /** Delete a record. */
184
+ public async delete(collection: string, id: string): Promise<boolean> {
185
+ if (this.isBatchMode) {
186
+ this.batchQueue.push({ action: "delete", payload: { collection, id } });
187
+ return true;
188
+ }
189
+
190
+ const success = await this.pb.collection(collection).delete(id);
191
+ if (success) {
192
+ this.cache.delete(this.generateCacheKey([collection, "get", id]));
193
+ this.cache.invalidateByPrefix(this.generateCacheKey([collection, "list"]));
194
+ }
195
+ return success;
196
+ }
197
+ }
@@ -0,0 +1,56 @@
1
+ // src/core/config.ts
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ // Define the shape of your configuration
7
+ export interface AppConfig {
8
+ port: number;
9
+ logLevel: 'debug' | 'info' | 'warn' | 'error';
10
+ // Add other configuration properties here
11
+ origin: string,
12
+ AI_ENABLED: boolean,
13
+ Clover_Tenant_ID: string,
14
+ Clover_Secret: string,
15
+ Clover_Server_Url: string,
16
+ NodeId: string,
17
+ JWT_SECRET: String,
18
+ ADMIN_EMAIL: string,
19
+ ADMIN_PASSWORD: string,
20
+ DatabaseUrl:string,
21
+ }
22
+
23
+ // Define your default configuration as a fallback
24
+ const defaultConfig: AppConfig = {
25
+ port: 8080,
26
+ logLevel: 'info',
27
+ };
28
+
29
+ function findAndLoadConfig(): AppConfig {
30
+ // 1. Look for 'hapta.config.json' in the current directory
31
+ const localConfigPath = path.join(process.cwd(), 'hapta.config.json');
32
+
33
+ // Add other locations to check here if you want (e.g., home directory)
34
+
35
+ try {
36
+ if (fs.existsSync(localConfigPath)) {
37
+ console.log(`Loading configuration from: ${localConfigPath}`);
38
+ const fileContent = fs.readFileSync(localConfigPath, 'utf-8');
39
+ const userConfig = JSON.parse(fileContent);
40
+
41
+ // Merge user config with defaults, so user only has to specify what they want to change
42
+ return { ...defaultConfig, ...userConfig };
43
+ }
44
+ } catch (error) {
45
+ console.error('Error reading or parsing config file:', error);
46
+ // Fallback to defaults if the file is malformed
47
+ return defaultConfig;
48
+ }
49
+
50
+ // 2. If no file is found, return the default configuration
51
+ console.log('No hapta.config.json found. Using default settings.');
52
+ return defaultConfig;
53
+ }
54
+
55
+ // Load the config once and export it for the rest of the app to use
56
+ export const config = findAndLoadConfig();
@@ -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>Document</title>
7
+ </head>
8
+ <body>
9
+ <input type="text" placeholder="Username" id ="username" />
10
+ <input type="text" placeholder="Password" id="password" />
11
+ <button onclick="login()"></button>
12
+ <script>
13
+ function login(){
14
+
15
+ const username = document.getElementById("username").value
16
+ const password = document.getElementById("password").value
17
+ console.log(username,password)
18
+ }
19
+
20
+
21
+ </script>
22
+ </body>
23
+ </html>