feat: Add auth flow (#146)

This adds a simple way to incorporate a centralized authentication flow.

The idea is to have the user, API and SSH (for machine authentication)
all in one place using `openauthjs` + `SST`

We also have a database now :)

> We are using InstantDB as it allows us to authenticate a use with just
the email. Plus it is super simple simple to use _of course after the
initial fumbles trying to design the db and relationships_
This commit is contained in:
Wanjohi
2025-01-04 00:02:28 +03:00
committed by GitHub
parent 33895974a7
commit fc5a755408
136 changed files with 3512 additions and 1914 deletions

View File

@@ -1,73 +0,0 @@
// image-brightness-analyzer.js
export class ImageBrightnessAnalyzer {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D ;
constructor() {
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d')!;
}
analyze(imgElement: HTMLImageElement) {
if (!(imgElement instanceof HTMLImageElement)) {
throw new Error('Input must be an HTMLImageElement');
}
this.canvas.width = imgElement.width;
this.canvas.height = imgElement.height;
this.ctx.drawImage(imgElement, 0, 0);
const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
const data = imageData.data;
let brightestPixel = { value: 0, x: 0, y: 0 };
let dullestPixel = { value: 765, x: 0, y: 0 }; // 765 is the max value (255 * 3)
for (let y = 0; y < this.canvas.height; y++) {
for (let x = 0; x < this.canvas.width; x++) {
const index = (y * this.canvas.width + x) * 4;
const brightness = data[index] + data[index + 1] + data[index + 2];
if (brightness > brightestPixel.value) {
brightestPixel = { value: brightness, x, y };
}
if (brightness < dullestPixel.value) {
dullestPixel = { value: brightness, x, y };
}
}
}
return {
brightest: {
x: brightestPixel.x,
y: brightestPixel.y,
color: this.getPixelColor(data, brightestPixel.x, brightestPixel.y)
},
dullest: {
x: dullestPixel.x,
y: dullestPixel.y,
color: this.getPixelColor(data, dullestPixel.x, dullestPixel.y)
}
};
}
getPixelColor(data: any[] | Uint8ClampedArray, x: number, y: number) {
const index = (y * this.canvas.width + x) * 4;
return {
r: data[index],
g: data[index + 1],
b: data[index + 2]
};
}
}
// // Export the class for use in browser environments
// if (typeof window !== 'undefined') {
// window.ImageBrightnessAnalyzer = ImageBrightnessAnalyzer;
// }
// // Export for module environments (if using a bundler)
// if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
// module.exports = ImageBrightnessAnalyzer;
// }

View File

@@ -1 +0,0 @@
export * from "./image-brightness-analyzer.ts"

View File

@@ -0,0 +1,35 @@
// Docs: https://www.instantdb.com/docs/permissions
import type { InstantRules } from "@instantdb/core";
const rules = {
/**
* Welcome to Instant's permission system!
* Right now your rules are empty. To start filling them in, check out the docs:
* https://www.instantdb.com/docs/permissions
*
* Here's an example to give you a feel:
* posts: {
* allow: {
* view: "true",
* create: "isOwner",
* update: "isOwner",
* delete: "isOwner",
* },
* bind: ["isOwner", "auth.id != null && auth.id == data.ownerId"],
* },
*/
"$default": {
"allow": {
"$default": "false"
}
},
machines: {
allow: {
"$default": "isOwner",
},
bind: ["isOwner", "auth.id != null && auth.id == data.ownerID"],
}
} satisfies InstantRules;
export default rules;

View File

@@ -0,0 +1,80 @@
import { i } from "@instantdb/core";
const _schema = i.schema({
// This section lets you define entities: think `posts`, `comments`, etc
// Take a look at the docs to learn more:
// https://www.instantdb.com/docs/modeling-data#2-attributes
entities: {
$users: i.entity({
email: i.string().unique().indexed(),
}),
// This is here because the $users entity has no more than 1 property; email
// profiles: i.entity({
// name: i.string(),
// location: i.string(),
// createdAt: i.date(),
// deletedAt: i.date().optional()
// }),
machines: i.entity({
hostname: i.string(),
location: i.string(),
fingerprint: i.string().indexed(),
createdAt: i.date(),
deletedAt: i.date().optional().indexed()
}),
// teams: i.entity({
// name: i.string(),
// type: i.string(), // "Personal" or "Family"
// createdAt: i.date(),
// deletedAt: i.date().optional()
// }),
// subscriptions: i.entity({
// quantity: i.number(),
// polarOrderID: i.string(),
// frequency: i.string(),
// next: i.date().optional(),
// }),
// productVariants: i.entity({
// name: i.string(),
// price: i.number()
// })
},
// links: {
// userProfiles: {
// forward: { on: 'profiles', has: 'one', label: 'owner' },
// reverse: { on: '$users', has: 'one', label: 'profile' },
// },
// machineOwners: {
// forward: { on: 'machines', has: 'one', label: 'owner' },
// reverse: { on: '$users', has: 'many', label: 'machinesOwned' },
// },
// machineTeams: {
// forward: { on: 'machines', has: 'one', label: 'team' },
// reverse: { on: 'teams', has: 'many', label: 'machines' },
// },
// userTeams: {
// forward: { on: 'teams', has: 'one', label: 'owner' },
// reverse: { on: '$users', has: 'many', label: 'teamsOwned' },
// },
// teamMembers: {
// forward: { on: 'teams', has: 'many', label: 'members' },
// reverse: { on: '$users', has: 'many', label: 'teams' },
// },
// subscribedProduct: {
// forward: { on: "subscriptions", has: "one", label: "productVariant" },
// reverse: { on: "productVariants", has: "many", label: "subscriptions" }
// },
// subscribedUser: {
// forward: { on: "subscriptions", has: "one", label: "owner" },
// reverse: { on: "$users", has: "many", label: "subscriptions" }
// }
// }
});
// This helps Typescript display nicer intellisense
type _AppSchema = typeof _schema;
interface AppSchema extends _AppSchema { }
const schema: AppSchema = _schema;
export type { AppSchema };
export default schema;

View File

@@ -1,13 +1,20 @@
{
"name": "@nestri/core",
"version": "0.0.0",
"private": true,
"sideEffects": false,
"exports":{
".":"./index.ts"
"type": "module",
"exports": {
"./*": "./src/*.ts"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240529.0",
"wrangler": "^3.57.2"
"@tsconfig/node20": "^20.1.4",
"loops": "^3.4.1",
"ulid": "^2.3.0",
"uuid": "^11.0.3",
"zod": "^3.24.1",
"zod-openapi": "^4.2.2"
},
"dependencies": {
"@instantdb/admin": "^0.17.3"
}
}
}

View File

@@ -0,0 +1,85 @@
import { createContext } from "./context";
import { VisibleError } from "./error";
export interface UserActor {
type: "user";
properties: {
accessToken: string;
userID: string;
auth?:
| {
type: "personal";
token: string;
}
| {
type: "oauth";
clientID: string;
};
};
}
export interface DeviceActor {
type: "device";
properties: {
fingerprint: string;
id: string;
auth?:
| {
type: "personal";
token: string;
}
| {
type: "oauth";
clientID: string;
};
};
}
export interface PublicActor {
type: "public";
properties: {};
}
type Actor = UserActor | PublicActor | DeviceActor;
export const ActorContext = createContext<Actor>();
export function useCurrentUser() {
const actor = ActorContext.use();
if (actor.type === "user") return {
id:actor.properties.userID,
token: actor.properties.accessToken
};
throw new VisibleError(
"auth",
"unauthorized",
`You don't have permission to access this resource`,
);
}
export function useCurrentDevice() {
const actor = ActorContext.use();
if (actor.type === "device") return {
fingerprint:actor.properties.fingerprint,
id: actor.properties.id
};
throw new VisibleError(
"auth",
"unauthorized",
`You don't have permission to access this resource`,
);
}
export function useActor() {
try {
return ActorContext.use();
} catch {
return { type: "public", properties: {} } as PublicActor;
}
}
export function assertActor<T extends Actor["type"]>(type: T) {
const actor = useActor();
if (actor.type !== type)
throw new VisibleError("auth", "actor.invalid", `Actor is not "${type}"`);
return actor as Extract<Actor, { type: T }>;
}

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
import "zod-openapi/extend";
export module Common {
export const IdDescription = `Unique object identifier.
The format and length of IDs may change over time.`;
}

View File

@@ -0,0 +1,17 @@
import { AsyncLocalStorage } from "node:async_hooks";
export function createContext<T>() {
const storage = new AsyncLocalStorage<T>();
return {
use() {
const result = storage.getStore();
if (!result) {
throw new Error("No context available");
}
return result;
},
with<R>(value: T, fn: () => R) {
return storage.run<R>(value, fn);
},
};
}

View File

@@ -0,0 +1,12 @@
import { Resource } from "sst";
import { init } from "@instantdb/admin";
import schema from "../instant.schema";
const databaseClient = () => init({
appId: Resource.InstantAppId.value,
adminToken: Resource.InstantAdminToken.value,
schema
})
export default databaseClient

View File

@@ -0,0 +1,25 @@
import { LoopsClient } from "loops";
import { Resource } from "sst/resource"
export namespace Email {
export const Client = () => new LoopsClient(Resource.LoopsApiKey.value);
export async function send(
to: string,
body: string,
) {
try {
await Client().sendTransactionalEmail(
{
transactionalId: "cm58pdf8d03upb5ecirnmvrfb",
email: to,
dataVariables: {
logincode: body
}
}
);
} catch (error) {
console.log("error sending email", error)
}
}
}

View File

@@ -0,0 +1,9 @@
export class VisibleError extends Error {
constructor(
public kind: "input" | "auth",
public code: string,
public message: string,
) {
super(message);
}
}

View File

@@ -0,0 +1,45 @@
export module Examples {
export const User = {
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
email: "john@example.com",
};
export const Machine = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
hostname: "desktopeuo8vsf",
fingerprint: "fc27f428f9ca47d4b41b70889ae0c62090",
location: "KE, AF"
}
// export const Team = {
// id: createID(),
// name: "Jane's Family",
// type: "Family"
// }
// export const ProductVariant = {
// id: createID(),
// name: "FamilySM",
// price: 10,
// };
// export const Product = {
// id: createID(),
// name: "Family",
// description: "The ideal subscription tier for dedicated gamers who crave more flexibility and social gaming experiences.",
// variants: [ProductVariant],
// subscription: "allowed" as const,
// };
// export const Subscription = {
// id: createID(),
// productVariant: ProductVariant,
// quantity: 1,
// polarOrderID: createID(),
// frequency: "monthly" as const,
// next: new Date("2024-02-01 19:36:19.000").getTime(),
// owner: User
// };
}

View File

@@ -0,0 +1,140 @@
import { z } from "zod"
import { fn } from "../utils";
import { Common } from "../common";
import { Examples } from "../examples";
import databaseClient from "../database"
import { useCurrentUser } from "../actor";
import { id as createID } from "@instantdb/admin";
export module Machine {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Machine.id,
}),
hostname: z.string().openapi({
description: "Hostname of the machine",
example: Examples.Machine.hostname,
}),
fingerprint: z.string().openapi({
description: "The machine's fingerprint, derived from the machine's Linux machine ID.",
example: Examples.Machine.fingerprint,
}),
location: z.string().openapi({
description: "The machine's approximate location; country and continent.",
example: Examples.Machine.location,
})
})
.openapi({
ref: "Machine",
description: "A machine running on the Nestri network.",
example: Examples.Machine,
});
export const create = fn(z.object({
fingerprint: z.string(),
hostname: z.string(),
location: z.string()
}), async (input) => {
const id = createID()
const now = new Date().getTime()
const db = databaseClient()
await db.transact(
db.tx.machines[id]!.update({
fingerprint: input.fingerprint,
hostname: input.hostname,
location: input.location,
createdAt: now,
})
)
return id
})
export const remove = fn(z.string(), async (id) => {
const now = new Date().getTime()
// const device = useCurrentDevice()
// const db = databaseClient()
// if (device.id) { // the machine can delete itself
// await db.transact(db.tx.machines[device.id]!.update({ deletedAt: now }))
// } else {// the user can delete it manually
const user = useCurrentUser()
const db = databaseClient().asUser({ token: user.token })
await db.transact(db.tx.machines[id]!.update({ deletedAt: now }))
// }
return "ok"
})
export const fromID = fn(z.string(), async (id) => {
const user = useCurrentUser()
const db = databaseClient().asUser({ token: user.token })
const query = {
machines: {
$: {
where: {
id: id,
deletedAt: { $isNull: true }
}
}
}
}
const res = await db.query(query)
return res.machines[0]
})
export const fromFingerprint = fn(z.string(), async (input) => {
const db = databaseClient()
const query = {
machines: {
$: {
where: {
fingerprint: input,
deletedAt: { $isNull: true }
}
}
}
}
const res = await db.query(query)
return res.machines[0]
})
export const list = async () => {
const user = useCurrentUser()
const db = databaseClient().asUser({ token: user.token })
const query = {
$users: {
$: { where: { id: user.id } },
machines: {
$: {
deletedAt: { $isNull: true }
}
}
},
}
const res = await db.query(query)
return res.$users[0]?.machines
}
export const link = fn(z.object({
machineId: z.string()
}), async (input) => {
const user = useCurrentUser()
const db = databaseClient()
await db.transact(db.tx.machines[input.machineId]!.link({ owner: user.id }))
return "ok"
})
}

View File

@@ -0,0 +1,48 @@
import databaseClient from "../database"
import { z } from "zod"
import { Common } from "../common";
import { createID, fn } from "../utils";
import { Examples } from "../examples";
export module Team {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Team.id,
}),
name: z.string().openapi({
description: "Name of the machine",
example: Examples.Team.name,
}),
type: z.string().nullable().openapi({
description: "Whether this is a personal or family type of team",
example: Examples.Team.type,
})
})
.openapi({
ref: "Team",
description: "A group of Nestri user's who share the same machine",
example: Examples.Team,
});
export const create = fn(z.object({
name: z.string(),
type: z.enum(["personal", "family"]),
owner: z.string(),
}), async (input) => {
const id = createID("machine")
const now = new Date().getTime()
const db = databaseClient()
await db.transact(db.tx.teams[id]!.update({
name: input.name,
type: input.type,
createdAt: now
}).link({
owner: input.owner,
}))
return id
})
}

View File

@@ -0,0 +1,17 @@
export interface CloudflareCF {
colo: string;
continent: string;
country: string,
city: string;
region: string;
longitude: number;
latitude: number;
metroCode: string;
postalCode: string;
timezone: string;
regionCode: number;
}
export interface CFRequest extends Request {
cf: CloudflareCF
}

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import databaseClient from "../database"
import { fn } from "../utils";
import { Common } from "../common";
import { Examples } from "../examples";
export module User {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.User.id,
}),
email: z.string().nullable().openapi({
description: "Email address of the user.",
example: Examples.User.email,
}),
})
.openapi({
ref: "User",
description: "A Nestri console user.",
example: Examples.User,
});
export const fromEmail = fn(z.string(), async (email) => {
const db = databaseClient()
const res = await db.auth.getUser({ email })
return res
})
export const create = fn(z.string(), async (email) => {
const db = databaseClient()
const token = await db.auth.createToken(email)
return token
})
}

View File

@@ -0,0 +1,13 @@
import { ZodSchema, z } from "zod";
export function fn<
Arg1 extends ZodSchema,
Callback extends (arg1: z.output<Arg1>) => any,
>(arg1: Arg1, cb: Callback) {
const result = function (input: z.input<typeof arg1>): ReturnType<Callback> {
const parsed = arg1.parse(input);
return cb.apply(cb, [parsed as any]);
};
result.schema = arg1;
return result;
}

View File

@@ -0,0 +1 @@
export * from "./fn"

42
packages/core/sst-env.d.ts vendored Normal file
View File

@@ -0,0 +1,42 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
import "sst"
export {}
declare module "sst" {
export interface Resource {
"Api": {
"type": "sst.cloudflare.Worker"
"url": string
}
"Auth": {
"type": "sst.cloudflare.Worker"
"url": string
}
"AuthFingerprintKey": {
"type": "random.index/randomString.RandomString"
"value": string
}
"CloudflareAuthKV": {
"type": "sst.cloudflare.Kv"
}
"InstantAdminToken": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAppId": {
"type": "sst.sst.Secret"
"value": string
}
"LoopsApiKey": {
"type": "sst.sst.Secret"
"value": string
}
"Urls": {
"api": string
"auth": string
"type": "sst.sst.Linkable"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": {
"module": "esnext",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"noUncheckedIndexedAccess": true,
}
}