mirror of
https://github.com/nestriness/nestri.git
synced 2026-08-02 01:35:18 +03:00
feat: Add a lot of stuff
This commit is contained in:
34
packages/function/.gitignore
vendored
Normal file
34
packages/function/.gitignore
vendored
Normal 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
|
||||
15
packages/function/README.md
Normal file
15
packages/function/README.md
Normal 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.
|
||||
20
packages/function/package.json
Normal file
20
packages/function/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
69
packages/function/src/api/index.ts
Normal file
69
packages/function/src/api/index.ts
Normal 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);
|
||||
};
|
||||
76
packages/function/src/auth/index.ts
Normal file
76
packages/function/src/auth/index.ts
Normal 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,
|
||||
};
|
||||
126
packages/function/src/auth/src/storage.ts
Normal file
126
packages/function/src/auth/src/storage.ts
Normal 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");
|
||||
},
|
||||
};
|
||||
}
|
||||
9
packages/function/src/subjects.ts
Normal file
9
packages/function/src/subjects.ts
Normal 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(),
|
||||
}),
|
||||
});
|
||||
30
packages/function/src/utils/logger.ts
Normal file
30
packages/function/src/utils/logger.ts
Normal 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");
|
||||
}
|
||||
12
packages/function/tsconfig.json
Normal file
12
packages/function/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user