feat: Add a lot of stuff

This commit is contained in:
Wanjohi
2025-10-25 22:11:33 +03:00
parent a3ee9aadd9
commit c5d0242d50
85 changed files with 6718 additions and 1377 deletions

34
packages/function/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View File

@@ -0,0 +1,15 @@
# function
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.2.23. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

View File

@@ -0,0 +1,20 @@
{
"name": "@nestri/function",
"type": "module",
"private": true,
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@types/bun": "catalog:",
"@tsconfig/node22": "catalog:"
},
"peerDependencies": {
"typescript": "^5",
"@nestri/core": "workspace:^"
},
"dependencies": {
"@openauthjs/openauth": "catalog:",
"hono": "^4.10.1",
"hono-openapi": "^1.1.0",
"zod": "catalog:"
}
}

View File

@@ -0,0 +1,69 @@
import { subjects } from "../subjects";
import { MiddlewareHandler } from "hono";
import { Actor } from "@nestri/core/actor";
import { Api } from "@nestri/core/api/index";
import { Log } from "@nestri/core/utils/log";
import { createClient } from "@openauthjs/openauth/client";
import { VisibleError, ErrorCodes } from "@nestri/core/error";
const client = createClient({
clientID: "api",
issuer: process.env.AUTH_URL,
subjects,
});
const log = Log.create({ namespace: "api" });
const auth: MiddlewareHandler = async (c, next) => {
const authHeader =
c.req.query("authorization") ?? c.req.header("authorization");
if (authHeader) {
const match = authHeader.match(/^Bearer (.+)$/);
if (!match || !match[1]) {
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
"Bearer token not found or improperly formatted",
);
}
const bearerToken = match[1];
if (bearerToken?.startsWith("nst_")) {
const token = await Api.Personal.fromToken(bearerToken);
if (!token)
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.INVALID_TOKEN,
"Invalid personal access token",
);
return Actor.provide(
"token",
{
teamID: token.teamID,
tokenID: token.id,
},
next,
);
}
const result = await client.verify(bearerToken!);
if (result.err)
throw new VisibleError(
"authentication",
ErrorCodes.Authentication.INVALID_TOKEN,
"Invalid bearer token",
);
if (result.subject.type === "team") {
return Actor.provide(
"team",
{
email: result.subject.properties.email,
teamID: result.subject.properties.teamID,
},
next,
);
}
}
return Actor.provide("public", {}, next);
};

View File

@@ -0,0 +1,76 @@
import { z } from "zod/v4";
import { subjects } from "../subjects.js";
import {redis} from 'bun';
import { issuer } from "@openauthjs/openauth";
import { RedisStorage } from "./src/storage.js";
import { Team } from "@nestri/core/team/index";
import { CodeProvider } from "@openauthjs/openauth/provider/code";
//!TODO: Add an auth storage adapter, maybe redis or postgres
const storage = RedisStorage({
redis: RedisClient.create(),
});
const app = issuer({
subjects,
providers: {
email: CodeProvider({
async request(_req, state, form, error) {
console.log(state);
const params = new URLSearchParams();
if (error) {
params.set("error", error.type);
}
if (state.type === "start") {
return Response.redirect(
process.env.AUTH_FRONTEND_URL + "/auth/email?" + params.toString(),
302,
);
}
if (state.type === "code") {
return Response.redirect(
process.env.AUTH_FRONTEND_URL + "/auth/code?" + params.toString(),
302,
);
}
return new Response("ok");
},
async sendCode(claims, code) {
const email = z.email().parse(claims.email);
console.log(`Sending code ${code} to email ${email}`);
},
}),
},
success: async (ctx, res) => {
let email = res.claims.email;
if (!email) throw new Error("No email found");
let teamID = await Team.fromEmail(email).then((x) => x?.id);
if (!teamID) {
console.log("creating account for", email);
teamID = await Team.create({
email: email!,
});
}
return ctx.subject("team", email!, {
teamID: teamID!,
email: email,
});
},
async allow(input) {
const url = new URL(input.redirectURI);
return (
url.hostname.endsWith("localhost") ||
url.hostname.endsWith("nestri.io") ||
url.hostname.endsWith("console.nestri.io")
);
},
});
export default {
port: 3000,
fetch: app.fetch,
};

View File

@@ -0,0 +1,126 @@
/**
* Configure OpenAuth to use Redis DB as a
* storage adapter.
*
* ```ts
* import { RedisStorage } from "./src/storage.js"
* import { Redis } from "@nestri/core/redis/client"
*
* const storage = RedisStorage({
* redis: Redis.Client.create()
* })
*
*
* export default issuer({
* storage,
* // ...
* })
* ```
*
* @packageDocumentation
*/
import {
joinKey,
StorageAdapter,
} from "@openauthjs/openauth/storage/storage";
import { Cluster, Redis } from "@nestri/core/redis/client";
const separator = String.fromCharCode(0x1f);
/**
* Configure the Redis connection that's created.
*/
export interface RedisStorageOptions {
redis: Cluster | Redis;
keyPrefix?: string;
defaultTTL?: number;
}
/**
* Creates a Cloudflare KV store.
* @param options - The config for the adapter.
*/
export function RedisStorage(options: RedisStorageOptions): StorageAdapter {
const { redis } = options;
const prefix = "storage";
function createKey(key: string[]): string {
return `${prefix}:${joinKey(key)}`;
}
function parseKey(redisKey: string): string[] {
const withoutPrefix = redisKey.slice(prefix.length + 1);
return withoutPrefix.split(separator);
}
return {
async get(key: string[]) {
const redisKey = createKey(key);
const value = await redis.get(redisKey);
if (!value) return;
return JSON.parse(value) as Record<string, any>;
},
async set(key: string[], value: any, expiry?: Date) {
const redisKey = createKey(key);
const ttlSeconds = expiry
? Math.max(Math.floor((expiry.getTime() - Date.now()) / 1000), 1)
: undefined;
if (ttlSeconds) {
// SET key value EX ttlSeconds
await options.redis.set(
redisKey,
JSON.stringify(value),
"EX",
ttlSeconds,
);
} else {
// No expiry → simple SET
await options.redis.set(redisKey, JSON.stringify(value));
}
},
async remove(key: string[]) {
const redisKey = createKey(key);
await options.redis.del(redisKey);
},
async *scan(prefix: string[]) {
const scanPattern = `${createKey(prefix)}${
prefix.length ? separator : ""
}*`;
let cursor = "0";
do {
// SCAN cursor MATCH pattern COUNT 100
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
scanPattern,
"COUNT",
100,
);
cursor = nextCursor;
if (keys.length === 0) {
continue;
}
const values = await redis.mget(...keys);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (!key) {
continue;
}
const value = values[i];
if (value !== null) {
const parsedValue =
typeof value === "string" ? JSON.parse(value) : value;
yield [parseKey(key), parsedValue];
}
}
} while (cursor !== "0");
},
};
}

View File

@@ -0,0 +1,9 @@
import { z } from "zod/v4";
import { createSubjects } from "@openauthjs/openauth/subject";
export const subjects = createSubjects({
team: z.object({
email: z.email(),
teamID: z.string(),
}),
});

View File

@@ -0,0 +1,30 @@
import { format } from "node:util";
/**
* Overrides the default Node.js console logging methods with a custom logger.
*
* This function patches console.log, console.warn, console.error, console.trace, and console.debug so that each logs
* messages prefixed with a log level. The messages are formatted using Node.js formatting conventions, with newline
* characters replaced by carriage returns, and are written directly to standard output.
*
* @example
* patchLogger();
* console.info("Server started on port %d", 3000);
*/
export function patchLogger() {
const log =
(level: "INFO" | "WARN" | "TRACE" | "DEBUG" | "ERROR") =>
(msg: string, ...rest: any[]) => {
let formattedMessage = format(msg, ...rest);
// Split by newlines, prefix each line with the level, and join back
const lines = formattedMessage.split("\n");
const prefixedLines = lines.map((line) => `${level}\t${line}`);
const output = prefixedLines.join("\n");
process.stdout.write(output + "\n");
};
console.log = log("INFO");
console.warn = log("WARN");
console.error = log("ERROR");
console.trace = log("TRACE");
console.debug = log("DEBUG");
}

View File

@@ -0,0 +1,12 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"module": "esnext",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"noUncheckedIndexedAccess": true,
"paths": {
"@/*": ["./src/*"]
}
}
}