feat(www): Finish up on the onboarding (#210)

Merging this prematurely to make sure the team is on the same boat... like dang! We need to find a better way to do this. 

Plus it has become too big
This commit is contained in:
Wanjohi
2025-03-26 02:21:53 +03:00
committed by GitHub
parent 957eca7794
commit f62fc1fb4b
106 changed files with 6329 additions and 866 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -44,7 +44,7 @@
"@nestri/libmoq": "*", "@nestri/libmoq": "*",
"@nestri/sdk": "0.1.0-alpha.14", "@nestri/sdk": "0.1.0-alpha.14",
"@nestri/ui": "*", "@nestri/ui": "*",
"@openauthjs/openauth": "^0.2.6", "@openauthjs/openauth": "*",
"@polar-sh/checkout": "^0.1.8", "@polar-sh/checkout": "^0.1.8",
"@polar-sh/sdk": "^0.21.1", "@polar-sh/sdk": "^0.21.1",
"@qwik-ui/headless": "^0.6.4", "@qwik-ui/headless": "^0.6.4",

845
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
import { vpc } from "./vpc";
import { bus } from "./bus"; import { bus } from "./bus";
import { domain } from "./dns"; import { domain } from "./dns";
import { email } from "./email";
import { secret } from "./secret"; import { secret } from "./secret";
import { database } from "./database"; import { postgres } from "./postgres";
sst.Linkable.wrap(random.RandomString, (resource) => ({ sst.Linkable.wrap(random.RandomString, (resource) => ({
properties: { properties: {
@@ -14,51 +14,17 @@ export const urls = new sst.Linkable("Urls", {
properties: { properties: {
api: "https://api." + domain, api: "https://api." + domain,
auth: "https://auth." + domain, auth: "https://auth." + domain,
site: $dev ? "http://localhost:4321" : "https://" + domain, site: $dev ? "http://localhost:3000" : "https://" + domain,
}, },
}); });
export const authFingerprintKey = new random.RandomString(
"AuthFingerprintKey",
{
length: 32,
},
);
export const auth = new sst.aws.Auth("Auth", {
issuer: {
timeout: "3 minutes",
handler: "./packages/functions/src/auth.handler",
link: [
bus,
email,
database,
authFingerprintKey,
secret.PolarSecret,
secret.GithubClientID,
secret.DiscordClientID,
secret.GithubClientSecret,
secret.DiscordClientSecret,
],
permissions: [
{
actions: ["ses:SendEmail"],
resources: ["*"],
},
],
},
domain: {
name: "auth." + domain,
dns: sst.cloudflare.dns(),
},
})
export const apiFunction = new sst.aws.Function("ApiFn", { export const apiFunction = new sst.aws.Function("ApiFn", {
vpc,
handler: "packages/functions/src/api/index.handler", handler: "packages/functions/src/api/index.handler",
link: [ link: [
bus, bus,
urls, urls,
database, postgres,
secret.PolarSecret, secret.PolarSecret,
], ],
timeout: "3 minutes", timeout: "3 minutes",
@@ -77,6 +43,5 @@ export const api = new sst.aws.Router("Api", {
}) })
export const outputs = { export const outputs = {
auth: auth.url,
api: api.url, api: api.url,
}; };

46
infra/auth.ts Normal file
View File

@@ -0,0 +1,46 @@
import { vpc } from "./vpc";
import { bus } from "./bus";
import { domain } from "./dns";
import { email } from "./email";
import { secret } from "./secret";
import { postgres } from "./postgres";
export const authFingerprintKey = new random.RandomString(
"AuthFingerprintKey",
{
length: 32,
},
);
export const auth = new sst.aws.Auth("Auth", {
issuer: {
vpc,
timeout: "3 minutes",
handler: "packages/functions/src/auth.handler",
link: [
bus,
email,
postgres,
authFingerprintKey,
secret.PolarSecret,
secret.GithubClientID,
secret.DiscordClientID,
secret.GithubClientSecret,
secret.DiscordClientSecret,
],
permissions: [
{
actions: ["ses:SendEmail"],
resources: ["*"],
},
],
},
domain: {
name: "auth." + domain,
dns: sst.cloudflare.dns(),
},
})
export const outputs = {
auth: auth.url,
};

View File

@@ -1,15 +1,18 @@
import { vpc } from "./vpc";
import { email } from "./email"; import { email } from "./email";
import { allSecrets } from "./secret"; import { allSecrets } from "./secret";
import { database } from "./database"; import { postgres } from "./postgres";
export const bus = new sst.aws.Bus("Bus"); export const bus = new sst.aws.Bus("Bus");
bus.subscribe("Event", { bus.subscribe("Event", {
vpc,
handler: "./packages/functions/src/event/event.handler", handler: "./packages/functions/src/event/event.handler",
link: [ link: [
database,
email, email,
...allSecrets], postgres,
...allSecrets
],
timeout: "5 minutes", timeout: "5 minutes",
permissions: [ permissions: [
{ {

6
infra/cluster.ts Normal file
View File

@@ -0,0 +1,6 @@
import { vpc } from "./vpc";
export const cluster = new sst.aws.Cluster("Cluster", {
vpc,
forceUpgrade: "v2"
});

View File

@@ -1,40 +0,0 @@
//Created manually from the dashboard and shared with the whole team/org
const dbProject = neon.getProjectOutput({
id: "black-sky-26872933"
})
const dbBranchId = $app.stage !== "production" ?
new neon.Branch("NeonBranch", {
parentId: dbProject.defaultBranchId,
projectId: dbProject.id,
name: $app.stage,
}).id : dbProject.defaultBranchId
const dbEndpoint = new neon.Endpoint("NeonEndpoint", {
projectId: dbProject.id,
branchId: dbBranchId,
poolerEnabled: true,
type: "read_write",
})
const dbRole = new neon.Role("NeonRole", {
name: "admin",
branchId: dbBranchId,
projectId: dbProject.id,
})
const db = new neon.Database("NeonDatabase", {
branchId: dbBranchId,
projectId: dbProject.id,
ownerName: dbRole.name,
name: `nestri-${$app.stage}`,
})
export const database = new sst.Linkable("Database", {
properties: {
name: db.name,
user: dbRole.name,
host: dbEndpoint.host,
password: dbRole.password,
},
});

68
infra/postgres.ts Normal file
View File

@@ -0,0 +1,68 @@
import { vpc } from "./vpc";
import { isPermanentStage } from "./stage";
// TODO: Add a dev db to use, this will help with running zero locally... and testing it
export const postgres = new sst.aws.Aurora("Postgres", {
vpc,
engine: "postgres",
scaling: isPermanentStage
? undefined
: {
min: "0 ACU",
max: "1 ACU",
},
transform: {
clusterParameterGroup: {
parameters: [
{
name: "rds.logical_replication",
value: "1",
applyMethod: "pending-reboot",
},
{
name: "max_slot_wal_keep_size",
value: "10240",
applyMethod: "pending-reboot",
},
{
name: "rds.force_ssl",
value: "0",
applyMethod: "pending-reboot",
},
{
name: "max_connections",
value: "1000",
applyMethod: "pending-reboot",
},
],
},
},
});
new sst.x.DevCommand("Studio", {
link: [postgres],
dev: {
command: "bun db studio",
directory: "packages/core",
autostart: true,
},
});
const migrator = new sst.aws.Function("DatabaseMigrator", {
handler: "packages/functions/src/migrator.handler",
link: [postgres],
copyFiles: [
{
from: "packages/core/migrations",
to: "./migrations",
},
],
});
if (!$dev) {
new aws.lambda.Invocation("DatabaseMigratorInvocation", {
input: Date.now().toString(),
functionName: migrator.name,
});
}

2
infra/stage.ts Normal file
View File

@@ -0,0 +1,2 @@
export const isPermanentStage =
$app.stage === "production" || $app.stage === "dev";

43
infra/steam.ts Normal file
View File

@@ -0,0 +1,43 @@
import { domain } from "./dns";
import { cluster } from "./cluster";
import { auth } from "./auth";
export const steam = new sst.aws.Service("Steam", {
cluster,
wait: true,
image: {
context: "packages/steam",
},
loadBalancer: {
domain:
$app.stage === "production"
? undefined
: {
name: "steam." + domain,
dns: sst.cloudflare.dns(),
},
rules: [
{ listen: "443/https", forward: "5289/http" },
{ listen: "80/http", forward: "5289/http" },
],
},
environment: {
NESTRI_AUTH_JWKS_URL: $interpolate`${auth.url}`
},
scaling:
$app.stage === "production"
? {
min: 2,
max: 4,
}
: undefined,
logging: {
retention: "1 month",
},
architecture: "arm64",
dev: {
directory: "packages/steam",
command: "dotnet run",
url: "http://localhost:5289",
},
})

1
infra/storage.ts Normal file
View File

@@ -0,0 +1 @@
export const storage = new sst.aws.Bucket("Storage");

17
infra/vpc.ts Normal file
View File

@@ -0,0 +1,17 @@
// import { isPermanentStage } from "./stage";
// export const vpc = isPermanentStage
// ? new sst.aws.Vpc("Vpc", {
// az: 2,
// })
// //FIXME: Change this ID
// : undefined //sst.aws.Vpc.get("Vpc", "vpc-070a1a7598f4c12d1");
// //
export const vpc = new sst.aws.Vpc("NestriVpc", {
az: 2,
// For lambdas to work in this VPC
nat: "ec2",
// For SST tunnel to work
bastion: true
})

View File

@@ -1,6 +1,9 @@
// This is the website part where people play and connect // This is the website part where people play and connect
import { api } from "./api";
import { auth } from "./auth";
import { zero } from "./zero";
import { domain } from "./dns"; import { domain } from "./dns";
import { auth, api } from "./api"; import { steam } from "./steam";
new sst.aws.StaticSite("Web", { new sst.aws.StaticSite("Web", {
path: "./packages/www", path: "./packages/www",
@@ -14,7 +17,9 @@ new sst.aws.StaticSite("Web", {
}, },
environment: { environment: {
VITE_API_URL: api.url, VITE_API_URL: api.url,
VITE_AUTH_URL: auth.url,
VITE_STAGE: $app.stage, VITE_STAGE: $app.stage,
VITE_AUTH_URL: auth.url,
VITE_ZERO_URL: zero.url,
VITE_STEAM_URL: steam.url,
}, },
}) })

196
infra/zero.ts Normal file
View File

@@ -0,0 +1,196 @@
import { vpc } from "./vpc";
import { auth } from "./auth";
import { domain } from "./dns";
import { readFileSync } from "fs";
import { cluster } from "./cluster";
import { storage } from "./storage";
import { postgres } from "./postgres";
// const connectionString = $interpolate`postgresql://${postgres.username}:${postgres.password}@${postgres.host}/${postgres.database}`
const connectionString = $interpolate`postgresql://${postgres.username}:${postgres.password}@${postgres.host}:${postgres.port}/${postgres.database}`;
const tag = $dev
? `latest`
: JSON.parse(
readFileSync("./node_modules/@rocicorp/zero/package.json").toString(),
).version.replace("+", "-");
const zeroEnv = {
FORCE: "1",
NO_COLOR: "1",
ZERO_LOG_LEVEL: "info",
ZERO_LITESTREAM_LOG_LEVEL: "info",
ZERO_UPSTREAM_DB: connectionString,
ZERO_IMAGE_URL: `rocicorp/zero:${tag}`,
ZERO_CVR_DB: connectionString,
ZERO_CHANGE_DB: connectionString,
ZERO_REPLICA_FILE: "/tmp/nestri.db",
ZERO_LITESTREAM_RESTORE_PARALLELISM: "64",
ZERO_SHARD_ID: $app.stage,
ZERO_AUTH_JWKS_URL: $interpolate`${auth.url}/.well-known/jwks.json`,
...($dev
? {
}
: {
ZERO_LITESTREAM_BACKUP_URL: $interpolate`s3://${storage.name}/zero`,
}),
};
// Replication Manager Service
const replicationManager = !$dev
? new sst.aws.Service(`ZeroReplication`, {
cluster,
wait: true,
...($app.stage === "production"
? {
cpu: "2 vCPU",
memory: "4 GB",
}
: {}),
architecture: "arm64",
image: zeroEnv.ZERO_IMAGE_URL,
link: [storage, postgres],
health: {
command: ["CMD-SHELL", "curl -f http://localhost:4849/ || exit 1"],
interval: "5 seconds",
retries: 3,
startPeriod: "300 seconds",
},
environment: {
...zeroEnv,
ZERO_CHANGE_MAX_CONNS: "3",
ZERO_NUM_SYNC_WORKERS: "0",
},
logging: {
retention: "1 month",
},
loadBalancer: {
public: false,
ports: [
{
listen: "80/http",
forward: "4849/http",
},
],
},
transform: {
loadBalancer: {
idleTimeout: 3600,
},
service: {
healthCheckGracePeriodSeconds: 900,
},
},
}) : undefined;
// Permissions deployment
const permissions = new sst.aws.Function(
"ZeroPermissions",
{
vpc,
link: [postgres],
handler: "packages/functions/src/zero.handler",
// environment: { ["ZERO_UPSTREAM_DB"]: connectionString },
copyFiles: [{
from: "packages/zero/.permissions.sql",
to: "./.permissions.sql"
}],
}
);
if (replicationManager) {
new aws.lambda.Invocation(
"ZeroPermissionsInvocation",
{
input: Date.now().toString(),
functionName: permissions.name,
},
{ dependsOn: replicationManager }
);
// new command.local.Command(
// "ZeroPermission",
// {
// dir: process.cwd() + "/packages/zero",
// environment: {
// ZERO_UPSTREAM_DB: connectionString,
// },
// create: "bun run zero-deploy-permissions",
// triggers: [Date.now()],
// },
// {
// dependsOn: [replicationManager],
// },
// );
}
export const zero = new sst.aws.Service("Zero", {
cluster,
image: zeroEnv.ZERO_IMAGE_URL,
link: [storage, postgres],
architecture: "arm64",
...($app.stage === "production"
? {
cpu: "2 vCPU",
memory: "4 GB",
capacity: "spot"
}
: {
capacity: "spot"
}),
environment: {
...zeroEnv,
...($dev
? {
ZERO_NUM_SYNC_WORKERS: "1",
}
: {
ZERO_CHANGE_STREAMER_URI: replicationManager.url.apply((val) =>
val.replace("http://", "ws://"),
),
ZERO_UPSTREAM_MAX_CONNS: "15",
ZERO_CVR_MAX_CONNS: "160",
}),
},
wait: true,
health: {
retries: 3,
command: ["CMD-SHELL", "curl -f http://localhost:4848/ || exit 1"],
interval: "5 seconds",
startPeriod: "300 seconds",
},
loadBalancer: {
domain: {
name: "zero." + domain,
dns: sst.cloudflare.dns()
},
rules: [
{ listen: "443/https", forward: "4848/http" },
{ listen: "80/http", forward: "4848/http" },
],
},
scaling: {
min: 1,
max: 4,
},
logging: {
retention: "1 month",
},
transform: {
service: {
healthCheckGracePeriodSeconds: 900,
},
// taskDefinition: {
// ephemeralStorage: {
// sizeInGib: 200,
// },
// },
loadBalancer: {
idleTimeout: 3600,
},
},
dev: {
command: "bun dev",
directory: "packages/zero",
url: "http://localhost:4848",
},
});

29
nestri.sln Normal file
View File

@@ -0,0 +1,29 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "steam", "packages\steam\steam.csproj", "{96118F95-BF02-0ED3-9042-36FA1B740D67}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Debug|Any CPU.Build.0 = Debug|Any CPU
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Release|Any CPU.ActiveCfg = Release|Any CPU
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{96118F95-BF02-0ED3-9042-36FA1B740D67} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {526AD703-4D15-43CF-B7C0-83F10D3158DB}
EndGlobalSection
EndGlobal

View File

@@ -16,9 +16,19 @@
"format": "prettier --write \"**/*.{ts,tsx,md}\"", "format": "prettier --write \"**/*.{ts,tsx,md}\"",
"sso": "aws sso login --sso-session=nestri --no-browser --use-device-code" "sso": "aws sso login --sso-session=nestri --no-browser --use-device-code"
}, },
"overrides": {
"@openauthjs/openauth": "0.4.3",
"@rocicorp/zero": "0.16.2025022000"
},
"patchedDependencies": {
"@macaron-css/solid@1.5.3": "patches/@macaron-css%2Fsolid@1.5.3.patch",
"drizzle-orm@0.36.1": "patches/drizzle-orm@0.36.1.patch"
},
"trustedDependencies": [ "trustedDependencies": [
"core-js-pure", "core-js-pure",
"esbuild", "esbuild",
"protobufjs",
"@rocicorp/zero-sqlite3",
"workerd" "workerd"
], ],
"workspaces": [ "workspaces": [
@@ -26,6 +36,6 @@
"packages/*" "packages/*"
], ],
"dependencies": { "dependencies": {
"sst": "3.9.1" "sst": "3.9.36"
} }
} }

View File

@@ -1,20 +1,19 @@
import { Resource } from "sst"; import { Resource } from "sst";
import { defineConfig } from "drizzle-kit"; import { defineConfig } from "drizzle-kit";
function addPoolerSuffix(original: string): string { const connection = {
const firstDotIndex = original.indexOf('.'); user: Resource.Postgres.username,
if (firstDotIndex === -1) return original + '-pooler'; password: Resource.Postgres.password,
return original.slice(0, firstDotIndex) + '-pooler' + original.slice(firstDotIndex); host: Resource.Postgres.host,
} };
const dbHost = addPoolerSuffix(Resource.Database.host)
export default defineConfig({ export default defineConfig({
schema: "./src/**/*.sql.ts", verbose: true,
strict: true,
out: "./migrations", out: "./migrations",
dialect: "postgresql", dialect: "postgresql",
verbose: true,
dbCredentials: { dbCredentials: {
url: `postgresql://${Resource.Database.user}:${Resource.Database.password}@${dbHost}/${Resource.Database.name}?sslmode=require`, url: `postgres://${connection.user}:${connection.password}@${connection.host}/nestri`,
}, },
schema: "./src/**/*.sql.ts",
}); });

View File

@@ -14,8 +14,9 @@ CREATE TABLE "team" (
"time_created" timestamp with time zone DEFAULT now() NOT NULL, "time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL, "time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone, "time_deleted" timestamp with time zone,
"name" varchar(255) NOT NULL,
"slug" varchar(255) NOT NULL, "slug" varchar(255) NOT NULL,
"name" varchar(255) NOT NULL "plan_type" text NOT NULL
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE "user" ( CREATE TABLE "user" (
@@ -24,14 +25,15 @@ CREATE TABLE "user" (
"time_updated" timestamp with time zone DEFAULT now() NOT NULL, "time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone, "time_deleted" timestamp with time zone,
"avatar_url" text, "avatar_url" text,
"email" varchar(255) NOT NULL,
"name" varchar(255) NOT NULL, "name" varchar(255) NOT NULL,
"discriminator" integer NOT NULL, "discriminator" integer NOT NULL,
"polar_customer_id" varchar(255) NOT NULL, "email" varchar(255) NOT NULL,
"polar_customer_id" varchar(255),
"flags" json DEFAULT '{}'::json,
CONSTRAINT "user_polar_customer_id_unique" UNIQUE("polar_customer_id") CONSTRAINT "user_polar_customer_id_unique" UNIQUE("polar_customer_id")
); );
--> statement-breakpoint --> statement-breakpoint
CREATE UNIQUE INDEX "member_email" ON "member" USING btree ("team_id","email");--> statement-breakpoint
CREATE INDEX "email_global" ON "member" USING btree ("email");--> statement-breakpoint CREATE INDEX "email_global" ON "member" USING btree ("email");--> statement-breakpoint
CREATE UNIQUE INDEX "slug" ON "team" USING btree ("slug");--> statement-breakpoint CREATE UNIQUE INDEX "member_email" ON "member" USING btree ("team_id","email");--> statement-breakpoint
CREATE UNIQUE INDEX "team_slug" ON "team" USING btree ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "user_email" ON "user" USING btree ("email"); CREATE UNIQUE INDEX "user_email" ON "user" USING btree ("email");

View File

@@ -1 +0,0 @@
ALTER TABLE "user" ALTER COLUMN "polar_customer_id" DROP NOT NULL;

View File

@@ -0,0 +1,2 @@
DROP INDEX "team_slug";--> statement-breakpoint
CREATE UNIQUE INDEX "slug" ON "team" USING btree ("slug");

View File

@@ -1,5 +1,5 @@
{ {
"id": "08ba0262-ce0a-4d87-b4e2-0d17dc0ee28c", "id": "f09034df-208a-42b3-b61f-f842921c6e24",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -54,6 +54,21 @@
} }
}, },
"indexes": { "indexes": {
"email_global": {
"name": "email_global",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"member_email": { "member_email": {
"name": "member_email", "name": "member_email",
"columns": [ "columns": [
@@ -74,21 +89,6 @@
"concurrently": false, "concurrently": false,
"method": "btree", "method": "btree",
"with": {} "with": {}
},
"email_global": {
"name": "email_global",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
} }
}, },
"foreignKeys": {}, "foreignKeys": {},
@@ -136,22 +136,28 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"slug": { "slug": {
"name": "slug", "name": "slug",
"type": "varchar(255)", "type": "varchar(255)",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"name": { "plan_type": {
"name": "name", "name": "plan_type",
"type": "varchar(255)", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
} }
}, },
"indexes": { "indexes": {
"slug": { "team_slug": {
"name": "slug", "name": "team_slug",
"columns": [ "columns": [
{ {
"expression": "slug", "expression": "slug",
@@ -209,12 +215,6 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"name": { "name": {
"name": "name", "name": "name",
"type": "varchar(255)", "type": "varchar(255)",
@@ -227,11 +227,24 @@
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"polar_customer_id": { "polar_customer_id": {
"name": "polar_customer_id", "name": "polar_customer_id",
"type": "varchar(255)", "type": "varchar(255)",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": false
},
"flags": {
"name": "flags",
"type": "json",
"primaryKey": false,
"notNull": false,
"default": "'{}'::json"
} }
}, },
"indexes": { "indexes": {

View File

@@ -1,6 +1,6 @@
{ {
"id": "c09359df-19fe-4246-9a41-43b3a429c12f", "id": "6f428226-b5d8-4182-a676-d04f842f9ded",
"prevId": "08ba0262-ce0a-4d87-b4e2-0d17dc0ee28c", "prevId": "f09034df-208a-42b3-b61f-f842921c6e24",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"tables": { "tables": {
@@ -54,6 +54,21 @@
} }
}, },
"indexes": { "indexes": {
"email_global": {
"name": "email_global",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"member_email": { "member_email": {
"name": "member_email", "name": "member_email",
"columns": [ "columns": [
@@ -74,21 +89,6 @@
"concurrently": false, "concurrently": false,
"method": "btree", "method": "btree",
"with": {} "with": {}
},
"email_global": {
"name": "email_global",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
} }
}, },
"foreignKeys": {}, "foreignKeys": {},
@@ -136,15 +136,21 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"slug": { "slug": {
"name": "slug", "name": "slug",
"type": "varchar(255)", "type": "varchar(255)",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"name": { "plan_type": {
"name": "name", "name": "plan_type",
"type": "varchar(255)", "type": "text",
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
} }
@@ -209,12 +215,6 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"name": { "name": {
"name": "name", "name": "name",
"type": "varchar(255)", "type": "varchar(255)",
@@ -227,11 +227,24 @@
"primaryKey": false, "primaryKey": false,
"notNull": true "notNull": true
}, },
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"polar_customer_id": { "polar_customer_id": {
"name": "polar_customer_id", "name": "polar_customer_id",
"type": "varchar(255)", "type": "varchar(255)",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
},
"flags": {
"name": "flags",
"type": "json",
"primaryKey": false,
"notNull": false,
"default": "'{}'::json"
} }
}, },
"indexes": { "indexes": {

View File

@@ -5,15 +5,15 @@
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1740345380808, "when": 1741759978256,
"tag": "0000_wise_black_widow", "tag": "0000_flaky_matthew_murdock",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "7", "version": "7",
"when": 1740487217291, "when": 1741955636085,
"tag": "0001_flaky_tomorrow_man", "tag": "0001_nifty_sauron",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@@ -4,12 +4,10 @@
"sideEffects": false, "sideEffects": false,
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsc --noEmit",
"db": "sst shell drizzle-kit", "db": "sst shell drizzle-kit",
"db:push": "sst shell drizzle-kit push", "db:exec": "sst shell ../scripts/src/psql.sh",
"db:migrate": "sst shell drizzle-kit migrate", "db:reset": "sst shell ../scripts/src/db-reset.sh"
"db:generate": "sst shell drizzle-kit generate",
"db:connect": "sst shell ../scripts/src/psql.ts",
"db:move": "sst shell drizzle-kit generate && sst shell drizzle-kit migrate && sst shell drizzle-kit push"
}, },
"exports": { "exports": {
"./*": "./src/*.ts" "./*": "./src/*.ts"
@@ -18,7 +16,6 @@
"@tsconfig/node20": "^20.1.4", "@tsconfig/node20": "^20.1.4",
"aws-iot-device-sdk-v2": "^1.21.1", "aws-iot-device-sdk-v2": "^1.21.1",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
"drizzle-kit": "^0.30.4",
"loops": "^3.4.1", "loops": "^3.4.1",
"mqtt": "^5.10.3", "mqtt": "^5.10.3",
"remeda": "^2.19.0", "remeda": "^2.19.0",
@@ -28,13 +25,14 @@
"zod-openapi": "^4.2.2" "zod-openapi": "^4.2.2"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-rds-data": "^3.758.0",
"@aws-sdk/client-sesv2": "^3.753.0", "@aws-sdk/client-sesv2": "^3.753.0",
"@instantdb/admin": "^0.17.7", "@instantdb/admin": "^0.17.7",
"@neondatabase/serverless": "^0.10.4", "@openauthjs/openauth": "*",
"@openauthjs/openauth": "0.4.3",
"@openauthjs/openevent": "^0.0.27", "@openauthjs/openevent": "^0.0.27",
"@polar-sh/sdk": "^0.26.1", "@polar-sh/sdk": "^0.26.1",
"drizzle-orm": "^0.39.3", "drizzle-kit": "^0.30.5",
"ws": "^8.18.1" "drizzle-orm": "^0.40.0",
"postgres": "^3.4.5"
} }
} }

View File

@@ -1,30 +1,17 @@
export * from "drizzle-orm"; export * from "drizzle-orm";
import ws from 'ws';
import { Resource } from "sst"; import { Resource } from "sst";
import { drizzle as neonDrizzle, NeonDatabase } from "drizzle-orm/neon-serverless"; import postgres from "postgres";
// import { drizzle } from 'drizzle-orm/postgres-js'; import { drizzle } from "drizzle-orm/postgres-js";
import { Pool, neonConfig } from "@neondatabase/serverless";
neonConfig.webSocketConstructor = ws; const client = postgres({
idle_timeout: 30000,
function addPoolerSuffix(original: string): string { connect_timeout: 30000,
const firstDotIndex = original.indexOf('.'); host: Resource.Postgres.host,
if (firstDotIndex === -1) return original + '-pooler'; database: Resource.Postgres.database,
return original.slice(0, firstDotIndex) + '-pooler' + original.slice(firstDotIndex); user: Resource.Postgres.username,
} password: Resource.Postgres.password,
port: Resource.Postgres.port,
const dbHost = addPoolerSuffix(Resource.Database.host) max: parseInt(process.env.POSTGRES_POOL_MAX || "1"),
const client = new Pool({ connectionString: `postgres://${Resource.Database.user}:${Resource.Database.password}@${dbHost}/${Resource.Database.name}?sslmode=require` })
export const db = neonDrizzle(client, {
logger:
process.env.DRIZZLE_LOG === "true"
? {
logQuery(query, params) {
console.log("query", query);
console.log("params", params);
},
}
: undefined,
}); });
export const db = drizzle(client, {});

View File

@@ -4,14 +4,13 @@ import {
PgTransactionConfig PgTransactionConfig
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { import {
NeonQueryResultHKT PostgresJsQueryResultHKT
// NeonHttpQueryResultHKT } from "drizzle-orm/postgres-js";
} from "drizzle-orm/neon-serverless";
import { ExtractTablesWithRelations } from "drizzle-orm"; import { ExtractTablesWithRelations } from "drizzle-orm";
import { createContext } from "../context"; import { createContext } from "../context";
export type Transaction = PgTransaction< export type Transaction = PgTransaction<
NeonQueryResultHKT, PostgresJsQueryResultHKT,
Record<string, never>, Record<string, never>,
ExtractTablesWithRelations<Record<string, never>> ExtractTablesWithRelations<Record<string, never>>
>; >;
@@ -59,7 +58,6 @@ export async function createTransaction<T>(
}, },
); );
await Promise.all(effects.map((x) => x())); await Promise.all(effects.map((x) => x()));
// await db.$client.end()
return result as T; return result as T;
} }
} }

View File

@@ -1,8 +1,145 @@
import { z } from "zod"
/**
* Standard error response schema used for OpenAPI documentation
*/
export const ErrorResponse = z
.object({
type: z
.enum([
"validation",
"authentication",
"forbidden",
"not_found",
"already_exists",
"rate_limit",
"internal",
])
.openapi({
description: "The error type category",
examples: ["validation", "authentication"],
}),
code: z.string().openapi({
description: "Machine-readable error code identifier",
examples: ["invalid_parameter", "missing_required_field", "unauthorized"],
}),
message: z.string().openapi({
description: "Human-readable error message",
examples: ["The request was invalid", "Authentication required"],
}),
param: z
.string()
.optional()
.openapi({
description: "The parameter that caused the error (if applicable)",
examples: ["email", "user_id", "team_id"],
}),
details: z.any().optional().openapi({
description: "Additional error context information",
}),
})
.openapi({ ref: "ErrorResponse" });
export type ErrorResponseType = z.infer<typeof ErrorResponse>;
/**
* Standardized error codes for the API
*/
export const ErrorCodes = {
// Validation errors (400)
Validation: {
MISSING_REQUIRED_FIELD: "missing_required_field",
ALREADY_EXISTS: "resource_already_exists",
TEAM_ALREADY_EXISTS: "team_already_exists",
INVALID_PARAMETER: "invalid_parameter",
INVALID_FORMAT: "invalid_format",
INVALID_STATE: "invalid_state",
IN_USE: "resource_in_use",
},
// Authentication errors (401)
Authentication: {
UNAUTHORIZED: "unauthorized",
INVALID_TOKEN: "invalid_token",
EXPIRED_TOKEN: "expired_token",
INVALID_CREDENTIALS: "invalid_credentials",
},
// Permission errors (403)
Permission: {
FORBIDDEN: "forbidden",
INSUFFICIENT_PERMISSIONS: "insufficient_permissions",
ACCOUNT_RESTRICTED: "account_restricted",
},
// Resource not found errors (404)
NotFound: {
RESOURCE_NOT_FOUND: "resource_not_found",
},
// Rate limit errors (429)
RateLimit: {
TOO_MANY_REQUESTS: "too_many_requests",
QUOTA_EXCEEDED: "quota_exceeded",
},
// Server errors (500)
Server: {
INTERNAL_ERROR: "internal_error",
SERVICE_UNAVAILABLE: "service_unavailable",
DEPENDENCY_FAILURE: "dependency_failure",
},
};
/**
* Standard error that will be exposed to clients through API responses
*/
export class VisibleError extends Error { export class VisibleError extends Error {
constructor( constructor(
public code: string, public type: ErrorResponseType["type"],
public message: string, public code: string,
) { public message: string,
super(message); public param?: string,
public details?: any,
) {
super(message);
}
/**
* Convert this error to an HTTP status code
*/
public statusCode(): number {
switch (this.type) {
case "validation":
return 400;
case "authentication":
return 401;
case "forbidden":
return 403;
case "not_found":
return 404;
case "already_exists":
return 409;
case "rate_limit":
return 429;
case "internal":
return 500;
} }
} }
/**
* Convert this error to a standard response object
*/
public toResponse(): ErrorResponseType {
const response: ErrorResponseType = {
type: this.type,
code: this.code,
message: this.message,
};
if (this.param) response.param = this.param;
if (this.details) response.details = this.details;
return response;
}
}

View File

@@ -16,6 +16,7 @@ export module Examples {
id: Id("team"), id: Id("team"),
name: "John Does' Team", name: "John Does' Team",
slug: "john_doe", slug: "john_doe",
planType: "BYOG" as const
} }
export const Member = { export const Member = {

View File

@@ -66,15 +66,11 @@ export module Member {
const id = input.id ?? createID("member"); const id = input.id ?? createID("member");
await tx.insert(memberTable).values({ await tx.insert(memberTable).values({
id, id,
email: input.email,
teamID: useTeam(), teamID: useTeam(),
timeSeen: input.first ? sql`CURRENT_TIMESTAMP()` : null, email: input.email,
}).onConflictDoUpdate({ timeSeen: input.first ? sql`now()` : null,
target: memberTable.id,
set: {
timeDeleted: null,
}
}) })
await afterTx(() => await afterTx(() =>
async () => bus.publish(Resource.Bus, Events.Created, { memberID: id }), async () => bus.publish(Resource.Bus, Events.Created, { memberID: id }),
); );
@@ -87,7 +83,7 @@ export module Member {
await tx await tx
.update(memberTable) .update(memberTable)
.set({ .set({
timeDeleted: sql`CURRENT_TIMESTAMP()`, timeDeleted: sql`now()`,
}) })
.where(and(eq(memberTable.id, input), eq(memberTable.teamID, useTeam()))) .where(and(eq(memberTable.id, input), eq(memberTable.teamID, useTeam())))
.execute(); .execute();

View File

@@ -12,7 +12,7 @@ export const memberTable = pgTable(
}, },
(table) => [ (table) => [
...teamIndexes(table), ...teamIndexes(table),
uniqueIndex("member_email").on(table.teamID, table.email),
index("email_global").on(table.email), index("email_global").on(table.email),
uniqueIndex("member_email").on(table.teamID, table.email),
], ],
); );

View File

@@ -4,7 +4,7 @@ import { Resource } from "sst";
import { eq, and } from "../drizzle"; import { eq, and } from "../drizzle";
import { useTeam } from "../actor"; import { useTeam } from "../actor";
import { createEvent } from "../event"; import { createEvent } from "../event";
import { polarTable, Standing } from "./polar.sql"; // import { polarTable, Standing } from "./polar.sql.ts.test";
import { Polar as PolarSdk } from "@polar-sh/sdk"; import { Polar as PolarSdk } from "@polar-sh/sdk";
import { useTransaction } from "../drizzle/transaction"; import { useTransaction } from "../drizzle/transaction";
@@ -15,10 +15,10 @@ export module Polar {
export const Info = z.object({ export const Info = z.object({
teamID: z.string(), teamID: z.string(),
customerID: z.string(),
subscriptionID: z.string().nullable(), subscriptionID: z.string().nullable(),
customerID: z.string(),
subscriptionItemID: z.string().nullable(), subscriptionItemID: z.string().nullable(),
standing: z.enum(Standing), // standing: z.enum(Standing),
}); });
export type Info = z.infer<typeof Info>; export type Info = z.infer<typeof Info>;
@@ -53,16 +53,16 @@ export module Polar {
), ),
}; };
export function get() { // export function get() {
return useTransaction(async (tx) => // return useTransaction(async (tx) =>
tx // tx
.select() // .select()
.from(polarTable) // .from(polarTable)
.where(eq(polarTable.teamID, useTeam())) // .where(eq(polarTable.teamID, useTeam()))
.execute() // .execute()
.then((rows) => rows.map(serialize).at(0)), // .then((rows) => rows.map(serialize).at(0)),
); // );
} // }
export const fromUserEmail = fn(z.string().min(1), async (email) => { export const fromUserEmail = fn(z.string().min(1), async (email) => {
try { try {
@@ -81,89 +81,89 @@ export module Polar {
} }
}) })
export const setCustomerID = fn(Info.shape.customerID, async (customerID) => // export const setCustomerID = fn(Info.shape.customerID, async (customerID) =>
useTransaction(async (tx) => // useTransaction(async (tx) =>
tx // tx
.insert(polarTable) // .insert(polarTable)
.values({ // .values({
teamID: useTeam(), // teamID: useTeam(),
customerID, // customerID,
standing: "new", // standing: "new",
}) // })
.execute(), // .execute(),
), // ),
); // );
export const setSubscription = fn( // export const setSubscription = fn(
Info.pick({ // Info.pick({
subscriptionID: true, // subscriptionID: true,
subscriptionItemID: true, // subscriptionItemID: true,
}), // }),
(input) => // (input) =>
useTransaction(async (tx) => // useTransaction(async (tx) =>
tx // tx
.update(polarTable) // .update(polarTable)
.set({ // .set({
subscriptionID: input.subscriptionID, // subscriptionID: input.subscriptionID,
subscriptionItemID: input.subscriptionItemID, // subscriptionItemID: input.subscriptionItemID,
}) // })
.where(eq(polarTable.teamID, useTeam())) // .where(eq(polarTable.teamID, useTeam()))
.returning() // .returning()
.execute() // .execute()
.then((rows) => rows.map(serialize).at(0)), // .then((rows) => rows.map(serialize).at(0)),
), // ),
); // );
export const removeSubscription = fn( // export const removeSubscription = fn(
z.string().min(1), // z.string().min(1),
(stripeSubscriptionID) => // (stripeSubscriptionID) =>
useTransaction((tx) => // useTransaction((tx) =>
tx // tx
.update(polarTable) // .update(polarTable)
.set({ // .set({
subscriptionItemID: null, // subscriptionItemID: null,
subscriptionID: null, // subscriptionID: null,
}) // })
.where(and(eq(polarTable.subscriptionID, stripeSubscriptionID))) // .where(and(eq(polarTable.subscriptionID, stripeSubscriptionID)))
.execute(), // .execute(),
), // ),
); // );
export const setStanding = fn( // export const setStanding = fn(
Info.pick({ // Info.pick({
subscriptionID: true, // subscriptionID: true,
standing: true, // standing: true,
}), // }),
(input) => // (input) =>
useTransaction((tx) => // useTransaction((tx) =>
tx // tx
.update(polarTable) // .update(polarTable)
.set({ standing: input.standing }) // .set({ standing: input.standing })
.where(and(eq(polarTable.subscriptionID, input.subscriptionID!))) // .where(and(eq(polarTable.subscriptionID, input.subscriptionID!)))
.execute(), // .execute(),
), // ),
); // );
export const fromCustomerID = fn(Info.shape.customerID, (customerID) => // export const fromCustomerID = fn(Info.shape.customerID, (customerID) =>
useTransaction((tx) => // useTransaction((tx) =>
tx // tx
.select() // .select()
.from(polarTable) // .from(polarTable)
.where(and(eq(polarTable.customerID, customerID))) // .where(and(eq(polarTable.customerID, customerID)))
.execute() // .execute()
.then((rows) => rows.map(serialize).at(0)), // .then((rows) => rows.map(serialize).at(0)),
), // ),
); // );
function serialize( // function serialize(
input: typeof polarTable.$inferSelect, // input: typeof polarTable.$inferSelect,
): z.infer<typeof Info> { // ): z.infer<typeof Info> {
return { // return {
teamID: input.teamID, // teamID: input.teamID,
customerID: input.customerID, // customerID: input.customerID,
subscriptionID: input.subscriptionID, // subscriptionID: input.subscriptionID,
subscriptionItemID: input.subscriptionItemID, // subscriptionItemID: input.subscriptionItemID,
standing: input.standing, // standing: input.standing,
}; // };
} // }
} }

View File

@@ -2,6 +2,7 @@ import { timestamps, teamID } from "../drizzle/types";
import { teamIndexes, teamTable } from "../team/team.sql"; import { teamIndexes, teamTable } from "../team/team.sql";
import { pgTable, text, varchar } from "drizzle-orm/pg-core"; import { pgTable, text, varchar } from "drizzle-orm/pg-core";
// FIXME: This is causing errors while trying to db push
export const Standing = ["new", "good", "overdue"] as const; export const Standing = ["new", "good", "overdue"] as const;
export const polarTable = pgTable( export const polarTable = pgTable(
@@ -16,7 +17,7 @@ export const polarTable = pgTable(
}), }),
standing: text("standing", { enum: Standing }).notNull(), standing: text("standing", { enum: Standing }).notNull(),
}, },
(table) => ({ (table) => [
...teamIndexes(table), ...teamIndexes(table),
}) ]
) )

View File

@@ -2,14 +2,14 @@ import { z } from "zod";
import { Resource } from "sst"; import { Resource } from "sst";
import { bus } from "sst/aws/bus"; import { bus } from "sst/aws/bus";
import { Common } from "../common"; import { Common } from "../common";
import { createID, fn } from "../utils";
import { Examples } from "../examples"; import { Examples } from "../examples";
import { teamTable } from "./team.sql";
import { createEvent } from "../event"; import { createEvent } from "../event";
import { assertActor, withActor } from "../actor"; import { createID, fn } from "../utils";
import { and, eq, sql } from "../drizzle"; import { and, eq, sql } from "../drizzle";
import { PlanType, teamTable } from "./team.sql";
import { assertActor, withActor } from "../actor";
import { memberTable } from "../member/member.sql"; import { memberTable } from "../member/member.sql";
import { HTTPException } from 'hono/http-exception'; import { ErrorCodes, VisibleError } from "../error";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
export module Team { export module Team {
@@ -19,13 +19,17 @@ export module Team {
description: Common.IdDescription, description: Common.IdDescription,
example: Examples.Team.id, example: Examples.Team.id,
}), }),
slug: z.string().openapi({ slug: z.string().regex(/^[a-z0-9\-]+$/, "Use a URL friendly name.").openapi({
description: "The unique and url-friendly slug of this team", description: "The unique and url-friendly slug of this team",
example: Examples.Team.slug example: Examples.Team.slug
}), }),
name: z.string().openapi({ name: z.string().openapi({
description: "The name of this team", description: "The name of this team",
example: Examples.Team.name example: Examples.Team.name
}),
planType: z.enum(PlanType).openapi({
description: "The type of Plan this team is subscribed to",
example: Examples.Team.planType
}) })
}) })
.openapi({ .openapi({
@@ -45,40 +49,36 @@ export module Team {
), ),
}; };
export class TeamExistsError extends HTTPException { export class TeamExistsError extends VisibleError {
constructor(slug: string) { constructor(slug: string) {
super( super(
400, "already_exists",
{ message: `There is already a team named "${slug}"`, } ErrorCodes.Validation.TEAM_ALREADY_EXISTS,
`There is already a team named "${slug}"`
); );
} }
} }
export const create = fn( export const create = fn(
Info.pick({ slug: true, id: true, name: true }).partial({ Info.pick({ slug: true, id: true, name: true, planType: true }).partial({
id: true, id: true,
}), (input) => { }), (input) =>
createTransaction(async (tx) => { createTransaction(async (tx) => {
const id = input.id ?? createID("team"); const id = input.id ?? createID("team");
const result = await tx.insert(teamTable).values({ const result = await tx.insert(teamTable).values({
id, id,
slug: input.slug, //Remove spaces and make sure it is lowercase (this is just to make sure the frontend did this)
name: input.name slug: input.slug, //.toLowerCase().replace(/[\s]/g, ''),
}) planType: input.planType,
.onConflictDoNothing({ target: teamTable.slug }) name: input.name
if (!result.rowCount) throw new TeamExistsError(input.slug);
await afterTx(() =>
withActor({ type: "system", properties: { teamID: id } }, () =>
bus.publish(Resource.Bus, Events.Created, {
teamID: id,
})
),
);
return id;
}) })
.onConflictDoNothing({ target: teamTable.slug })
if (result.count === 0) throw new TeamExistsError(input.slug);
return id;
}) })
)
export const remove = fn(Info.shape.id, (input) => export const remove = fn(Info.shape.id, (input) =>
useTransaction(async (tx) => { useTransaction(async (tx) => {
@@ -147,6 +147,7 @@ export module Team {
id: input.id, id: input.id,
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
planType: input.planType,
}; };
} }

View File

@@ -1,21 +1,27 @@
import {} from "drizzle-orm/postgres-js"; import { } from "drizzle-orm/postgres-js";
import { timestamps, id } from "../drizzle/types"; import { timestamps, id } from "../drizzle/types";
import { import {
varchar,
pgTable, pgTable,
primaryKey, primaryKey,
uniqueIndex, uniqueIndex,
varchar, text
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
export const PlanType = ["Hosted", "BYOG"] as const;
export const teamTable = pgTable( export const teamTable = pgTable(
"team", "team",
{ {
...id, ...id,
...timestamps, ...timestamps,
slug: varchar("slug", { length: 255 }).notNull(),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull(),
planType: text("plan_type", { enum: PlanType }).notNull()
}, },
(table) => [uniqueIndex("slug").on(table.slug)], (table) => [
uniqueIndex("slug").on(table.slug)
],
); );
export function teamIndexes(table: any) { export function teamIndexes(table: any) {

View File

@@ -1,8 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { Polar } from "../polar";
import { Team } from "../team"; import { Team } from "../team";
import { bus } from "sst/aws/bus"; import { bus } from "sst/aws/bus";
import { Common } from "../common"; import { Common } from "../common";
import { Polar } from "../polar/index";
import { createID, fn } from "../utils"; import { createID, fn } from "../utils";
import { userTable } from "./user.sql"; import { userTable } from "./user.sql";
import { createEvent } from "../event"; import { createEvent } from "../event";
@@ -188,7 +188,7 @@ export module User {
await tx await tx
.update(userTable) .update(userTable)
.set({ .set({
timeDeleted: sql`CURRENT_TIMESTAMP()`, timeDeleted: sql`now()`,
}) })
.where(and(eq(userTable.id, input))) .where(and(eq(userTable.id, input)))
.execute(); .execute();

View File

@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { id, timestamps } from "../drizzle/types"; import { id, timestamps } from "../drizzle/types";
import { integer, pgTable, text, uniqueIndex, varchar,json } from "drizzle-orm/pg-core"; import { integer, pgTable, text, uniqueIndex, varchar, json } from "drizzle-orm/pg-core";
// Whether this user is part of the Nestri Team, comes with privileges // Whether this user is part of the Nestri Team, comes with privileges
export const UserFlags = z.object({ export const UserFlags = z.object({
@@ -15,13 +15,13 @@ export const userTable = pgTable(
...id, ...id,
...timestamps, ...timestamps,
avatarUrl: text("avatar_url"), avatarUrl: text("avatar_url"),
email: varchar("email", { length: 255 }).notNull(),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
discriminator: integer("discriminator").notNull(), discriminator: integer("discriminator").notNull(),
email: varchar("email", { length: 255 }).notNull(),
polarCustomerID: varchar("polar_customer_id", { length: 255 }).unique(), polarCustomerID: varchar("polar_customer_id", { length: 255 }).unique(),
flags: json("flags").$type<UserFlags>().default({}), flags: json("flags").$type<UserFlags>().default({}),
}, },
(user) => [ (user) => [
uniqueIndex("user_email").on(user.email), uniqueIndex("user_email").on(user.email),
], ]
); );

View File

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

View File

@@ -1,7 +1,9 @@
{ {
"name": "@nestri/functions", "name": "@nestri/functions",
"module": "index.ts",
"type": "module", "type": "module",
"exports": {
"./*": "./src/*.ts"
},
"devDependencies": { "devDependencies": {
"@aws-sdk/client-ecs": "^3.738.0", "@aws-sdk/client-ecs": "^3.738.0",
"@aws-sdk/client-sqs": "^3.734.0", "@aws-sdk/client-sqs": "^3.734.0",
@@ -14,9 +16,10 @@
"typescript": "^5.0.0" "typescript": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"@openauthjs/openauth": "0.4.3", "@openauthjs/openauth": "*",
"hono": "^4.6.15", "hono": "^4.6.15",
"hono-openapi": "^0.3.1", "hono-openapi": "^0.3.1",
"partysocket": "1.0.3" "partysocket": "1.0.3",
"postgres": "^3.4.5"
} }
} }

View File

@@ -1,12 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import { Hono } from "hono"; import { Hono } from "hono";
import { notPublic } from "./auth"; import { notPublic } from "./auth";
import { Result } from "../common";
import { resolver } from "hono-openapi/zod";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { User } from "@nestri/core/user/index"; import { User } from "@nestri/core/user/index";
import { Team } from "@nestri/core/team/index"; import { Team } from "@nestri/core/team/index";
import { assertActor } from "@nestri/core/actor"; import { assertActor } from "@nestri/core/actor";
import { Examples } from "@nestri/core/examples";
import { ErrorResponses, Result } from "./common";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
export module AccountApi { export module AccountApi {
export const route = new Hono() export const route = new Hono()
@@ -14,8 +15,8 @@ export module AccountApi {
.get("/", .get("/",
describeRoute({ describeRoute({
tags: ["Account"], tags: ["Account"],
summary: "Retrieve the current user's details", summary: "Get user account",
description: "Returns the user's account details, plus the teams they have joined", description: "Get the current user's account details",
responses: { responses: {
200: { 200: {
content: { content: {
@@ -24,35 +25,36 @@ export module AccountApi {
z.object({ z.object({
...User.Info.shape, ...User.Info.shape,
teams: Team.Info.array(), teams: Team.Info.array(),
}).openapi({
description: "User account information",
example: { ...Examples.User, teams: [Examples.Team] }
}) })
), ),
}, },
}, },
description: "Successfully retrieved account details" description: "User account details"
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "This account does not exist",
}, },
404: ErrorResponses[404]
} }
}), }),
async (c) => { async (c) => {
const actor = assertActor("user"); const actor = assertActor("user");
const [currentUser, teams] = await Promise.all([User.fromID(actor.properties.userID), User.teams()]) const [currentUser, teams] = await Promise.all([User.fromID(actor.properties.userID), User.teams()])
if (!currentUser) return c.json({ error: "This account does not exist; it may have been deleted" }, 404) if (!currentUser)
throw new VisibleError(
"not_found",
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
"User not found",
);
const { id, email, name, polarCustomerID, avatarUrl, discriminator } = currentUser const { id, email, name, polarCustomerID, avatarUrl, discriminator } = currentUser
return c.json({ return c.json({
data: { data: {
id, id,
email,
name, name,
email,
teams, teams,
avatarUrl, avatarUrl,
discriminator, discriminator,

View File

@@ -1,11 +1,9 @@
import { Resource } from "sst"; import { Resource } from "sst";
import { subjects } from "../subjects"; import { subjects } from "../subjects";
import { type MiddlewareHandler } from "hono"; import { type MiddlewareHandler } from "hono";
// import { User } from "@nestri/core/user/index";
import { VisibleError } from "@nestri/core/error";
import { HTTPException } from "hono/http-exception";
import { useActor, withActor } from "@nestri/core/actor"; import { useActor, withActor } from "@nestri/core/actor";
import { createClient } from "@openauthjs/openauth/client"; import { createClient } from "@openauthjs/openauth/client";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
const client = createClient({ const client = createClient({
issuer: Resource.Urls.auth, issuer: Resource.Urls.auth,
@@ -15,7 +13,11 @@ const client = createClient({
export const notPublic: MiddlewareHandler = async (c, next) => { export const notPublic: MiddlewareHandler = async (c, next) => {
const actor = useActor(); const actor = useActor();
if (actor.type === "public") if (actor.type === "public")
throw new HTTPException(401, { message: "Unauthorized" }); throw new VisibleError(
"authentication",
ErrorCodes.Authentication.UNAUTHORIZED,
"Missing authorization header",
);
return next(); return next();
}; };
@@ -26,16 +28,19 @@ export const auth: MiddlewareHandler = async (c, next) => {
const match = authHeader.match(/^Bearer (.+)$/); const match = authHeader.match(/^Bearer (.+)$/);
if (!match) { if (!match) {
throw new VisibleError( throw new VisibleError(
"auth.token", "authentication",
"Bearer token not found or improperly formatted", ErrorCodes.Authentication.INVALID_TOKEN,
"Invalid personal access token",
); );
} }
const bearerToken = match[1]; const bearerToken = match[1];
let result = await client.verify(subjects, bearerToken!); let result = await client.verify(subjects, bearerToken!);
if (result.err) { if (result.err) {
throw new HTTPException(401, { throw new VisibleError(
message: "Unauthorized", "authentication",
}); ErrorCodes.Authentication.INVALID_TOKEN,
"Invalid bearer token",
);
} }
if (result.subject.type === "user") { if (result.subject.type === "user") {
@@ -50,20 +55,20 @@ export const auth: MiddlewareHandler = async (c, next) => {
}, },
}, },
next next
// async () => { // async () => {
// const user = await User.fromEmail(email); // const user = await User.fromEmail(email);
// if (!user || user.length === 0) { // if (!user || user.length === 0) {
// c.status(401); // c.status(401);
// return c.text("Unauthorized"); // return c.text("Unauthorized");
// } // }
// return withActor( // return withActor(
// { // {
// type: "member", // type: "member",
// properties: { userID: user[0].id, workspaceID: user.workspaceID }, // properties: { userID: user[0].id, workspaceID: user.workspaceID },
// }, // },
// next, // next,
// ); // );
// }, // },
); );
} }
}; };

View File

@@ -0,0 +1,246 @@
import {type Hook } from "./hook";
import { z, ZodSchema } from "zod";
import { ErrorCodes, ErrorResponse } from "@nestri/core/error";
import type { MiddlewareHandler, ValidationTargets } from "hono";
import { resolver, validator as zodValidator } from "hono-openapi/zod";
export function Result<T extends z.ZodTypeAny>(schema: T) {
return resolver(
z.object({
data: schema,
}),
);
}
/**
* Custom validator wrapper around hono-openapi/zod validator that formats errors
* according to our standard API error format
*/
export const validator = <
T extends ZodSchema,
Target extends keyof ValidationTargets
>(
target: Target,
schema: T
): MiddlewareHandler<
any,
string,
{
in: {
[K in Target]: z.input<T>;
};
out: {
[K in Target]: z.output<T>;
};
}
> => {
// Create a custom error handler that formats errors according to our standards
// const standardErrorHandler: Parameters<typeof zodValidator>[2] = (
const standardErrorHandler: Hook<z.infer<T>, any, any, Target> = (
result,
c,
) => {
if (!result.success) {
// Get the validation issues
const issues = result.error.issues || result.error.errors || [];
if (issues.length === 0) {
// If there are no issues, return a generic error
return c.json(
{
type: "validation",
code: ErrorCodes.Validation.INVALID_PARAMETER,
message: "Invalid request data",
},
400,
);
}
// Get the first error for the main response
const firstIssue = issues[0]!;
const fieldPath = firstIssue.path
? Array.isArray(firstIssue.path)
? firstIssue.path.join(".")
: firstIssue.path
: undefined;
// Map Zod error codes to our standard error codes
let errorCode = ErrorCodes.Validation.INVALID_PARAMETER;
if (
firstIssue.code === "invalid_type" &&
firstIssue.received === "undefined"
) {
errorCode = ErrorCodes.Validation.MISSING_REQUIRED_FIELD;
} else if (
["invalid_string", "invalid_date", "invalid_regex"].includes(
firstIssue.code,
)
) {
errorCode = ErrorCodes.Validation.INVALID_FORMAT;
}
// Create our standardized error response
const response = {
type: "validation",
code: errorCode,
message: firstIssue.message,
param: fieldPath,
details: undefined as any,
};
// Add details if we have multiple issues
if (issues.length > 0) {
response.details = {
issues: issues.map((issue) => ({
path: issue.path
? Array.isArray(issue.path)
? issue.path.join(".")
: issue.path
: undefined,
code: issue.code,
message: issue.message,
// @ts-expect-error
expected: issue.expected,
// @ts-expect-error
received: issue.received,
})),
};
}
console.log("Validation error in validator:", response);
return c.json(response, 400);
}
};
// Use the original validator with our custom error handler
return zodValidator(target, schema, standardErrorHandler);
};
/**
* Standard error responses for OpenAPI documentation
*/
export const ErrorResponses = {
400: {
content: {
"application/json": {
schema: resolver(
ErrorResponse.openapi({
description: "Validation error",
example: {
type: "validation",
code: "invalid_parameter",
message: "The request was invalid",
param: "email",
},
}),
),
},
},
description:
"Bad Request - The request could not be understood or was missing required parameters.",
},
401: {
content: {
"application/json": {
schema: resolver(
ErrorResponse.openapi({
description: "Authentication error",
example: {
type: "authentication",
code: "unauthorized",
message: "Authentication required",
},
}),
),
},
},
description:
"Unauthorized - Authentication is required and has failed or has not been provided.",
},
403: {
content: {
"application/json": {
schema: resolver(
ErrorResponse.openapi({
description: "Permission error",
example: {
type: "forbidden",
code: "permission_denied",
message: "You do not have permission to access this resource",
},
}),
),
},
},
description:
"Forbidden - You do not have permission to access this resource.",
},
404: {
content: {
"application/json": {
schema: resolver(
ErrorResponse.openapi({
description: "Not found error",
example: {
type: "not_found",
code: "resource_not_found",
message: "The requested resource could not be found",
},
}),
),
},
},
description: "Not Found - The requested resource does not exist.",
},
409: {
content: {
"application/json": {
schema: resolver(
ErrorResponse.openapi({
description: "Conflict Error",
example: {
type: "already_exists",
code: "resource_already_exists",
message: "The resource could not be created because it already exists",
},
}),
),
},
},
description: "Conflict - The resource could not be created because it already exists.",
},
429: {
content: {
"application/json": {
schema: resolver(
ErrorResponse.openapi({
description: "Rate limit error",
example: {
type: "rate_limit",
code: "too_many_requests",
message: "Rate limit exceeded",
},
}),
),
},
},
description:
"Too Many Requests - You have made too many requests in a short period of time.",
},
500: {
content: {
"application/json": {
schema: resolver(
ErrorResponse.openapi({
description: "Server error",
example: {
type: "internal",
code: "internal_error",
message: "Internal server error",
},
}),
),
},
},
description: "Internal Server Error - Something went wrong on our end.",
},
};

View File

@@ -0,0 +1,20 @@
import { ZodError, ZodSchema, z } from 'zod';
import type { Env, ValidationTargets, Context, TypedResponse, Input, MiddlewareHandler } from 'hono';
type Hook<T, E extends Env, P extends string, Target extends keyof ValidationTargets = keyof ValidationTargets, O = {}> = (result: ({
success: true;
data: T;
} | {
success: false;
error: ZodError;
data: T;
}) & {
target: Target;
}, c: Context<E, P>) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>;
type HasUndefined<T> = undefined extends T ? true : false;
declare const zValidator: <T extends ZodSchema<any, z.ZodTypeDef, any>, Target extends keyof ValidationTargets, E extends Env, P extends string, In = z.input<T>, Out = z.output<T>, I extends Input = {
in: HasUndefined<In> extends true ? { [K in Target]?: (In extends ValidationTargets[K] ? In : { [K2 in keyof In]?: ValidationTargets[K][K2] | undefined; }) | undefined; } : { [K_1 in Target]: In extends ValidationTargets[K_1] ? In : { [K2_1 in keyof In]: ValidationTargets[K_1][K2_1]; }; };
out: { [K_2 in Target]: Out; };
}, V extends I = I>(target: Target, schema: T, hook?: Hook<z.TypeOf<T>, E, P, Target, {}> | undefined) => MiddlewareHandler<E, P, V>;
export { type Hook, zValidator };

View File

@@ -1,16 +1,16 @@
import "zod-openapi/extend"; import "zod-openapi/extend";
import { Hono } from "hono"; import { Hono } from "hono";
import { auth } from "./auth"; import { auth } from "./auth";
import { ZodError } from "zod"; import { TeamApi } from "./team";
import { logger } from "hono/logger"; import { logger } from "hono/logger";
import { AccountApi } from "./account"; import { AccountApi } from "./account";
import { openAPISpecs } from "hono-openapi"; import { openAPISpecs } from "hono-openapi";
import { VisibleError } from "@nestri/core/error";
import { HTTPException } from "hono/http-exception"; import { HTTPException } from "hono/http-exception";
import { handle, streamHandle } from "hono/aws-lambda"; import { handle, streamHandle } from "hono/aws-lambda";
import { ErrorCodes, VisibleError } from "@nestri/core/error";
const app = new Hono(); export const app = new Hono();
app app
.use(logger(), async (c, next) => { .use(logger(), async (c, next) => {
c.header("Cache-Control", "no-store"); c.header("Cache-Control", "no-store");
@@ -20,57 +20,47 @@ app
const routes = app const routes = app
.get("/", (c) => c.text("Hello World!")) .get("/", (c) => c.text("Hello World!"))
.route("/team", TeamApi.route)
.route("/account", AccountApi.route) .route("/account", AccountApi.route)
.onError((error, c) => { .onError((error, c) => {
console.warn(error); console.warn(error);
if (error instanceof VisibleError) { if (error instanceof VisibleError) {
return c.json( console.error("api error:", error);
{ // @ts-expect-error
code: error.code, return c.json(error.toResponse(), error.statusCode());
message: error.message,
},
400
);
}
if (error instanceof ZodError) {
const e = error.errors[0];
if (e) {
return c.json(
{
code: e?.code,
message: e?.message,
},
400,
);
}
} }
// Handle HTTP exceptions
if (error instanceof HTTPException) { if (error instanceof HTTPException) {
console.error("http error:", error);
return c.json( return c.json(
{ {
code: "request", type: "validation",
code: ErrorCodes.Validation.INVALID_PARAMETER,
message: "Invalid request", message: "Invalid request",
}, },
400, 400,
); );
} }
console.error("unhandled error:", error);
return c.json( return c.json(
{ {
code: "internal", type: "internal",
code: ErrorCodes.Server.INTERNAL_ERROR,
message: "Internal server error", message: "Internal server error",
}, },
500, 500,
); );
}); });
app.get( app.get(
"/doc", "/doc",
openAPISpecs(routes, { openAPISpecs(routes, {
documentation: { documentation: {
info: { info: {
title: "Nestri API", title: "Nestri API",
description: description: "The Nestri API gives you the power to run your own customized cloud gaming platform.",
"The Nestri API gives you the power to run your own customized cloud gaming platform.", version: "0.0.1",
version: "0.3.0",
}, },
components: { components: {
securitySchemes: { securitySchemes: {
@@ -81,13 +71,13 @@ app.get(
}, },
TeamID: { TeamID: {
type: "apiKey", type: "apiKey",
description:"The team ID to use for this query", description: "The team ID to use for this query",
in: "header", in: "header",
name: "x-nestri-team" name: "x-nestri-team"
}, },
}, },
}, },
security: [{ Bearer: [], TeamID:[] }], security: [{ Bearer: [], TeamID: [] }],
servers: [ servers: [
{ description: "Production", url: "https://api.nestri.io" }, { description: "Production", url: "https://api.nestri.io" },
], ],

View File

@@ -0,0 +1,95 @@
import { z } from "zod";
import { Hono } from "hono";
import { notPublic } from "./auth";
import { describeRoute } from "hono-openapi";
import { User } from "@nestri/core/user/index";
import { Team } from "@nestri/core/team/index";
import { Examples } from "@nestri/core/examples";
import { Member } from "@nestri/core/member/index";
import { assertActor, withActor } from "@nestri/core/actor";
import { ErrorResponses, Result, validator } from "./common";
export module TeamApi {
export const route = new Hono()
.use(notPublic)
.get("/",
describeRoute({
tags: ["Team"],
summary: "List teams",
description: "List the teams associated with the current user",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Team.Info.array().openapi({
description: "List of teams",
example: [Examples.Team]
})
),
},
},
description: "List of teams"
},
}
}),
async (c) => {
return c.json({
data: await User.teams()
}, 200);
},
)
.post("/",
describeRoute({
tags: ["Team"],
summary: "Create a team",
description: "Create a team for the current user",
responses: {
200: {
content: {
"application/json": {
schema: Result(
z.literal("ok")
)
}
},
description: "Team created succesfully"
},
400: ErrorResponses[400],
409: ErrorResponses[409],
429: ErrorResponses[429],
500: ErrorResponses[500],
}
}),
validator(
"json",
Team.create.schema.omit({ id: true }).openapi({
description: "Details of the team to create",
//@ts-expect-error
example: { ...Examples.Team, id: undefined }
})
),
async (c) => {
const body = c.req.valid("json")
const actor = assertActor("user");
const teamID = await Team.create(body);
await withActor(
{
type: "system",
properties: {
teamID,
},
},
() =>
Member.create({
first: true,
email: actor.properties.email,
}),
);
return c.json({ data: "ok" })
}
)
}

View File

@@ -136,12 +136,17 @@ const app = issuer({
return ctx.subject("user", { return ctx.subject("user", {
userID, userID,
email email
}, {
subject: email
}); });
} else if (matching) { } else if (matching) {
//Sign In //Sign In
return ctx.subject("user", { return ctx.subject("user", {
userID: matching.id, userID: matching.id,
email email
}, {
subject: email
}); });
} }
} }
@@ -175,12 +180,16 @@ const app = issuer({
return ctx.subject("user", { return ctx.subject("user", {
userID, userID,
email: user.primary.email email: user.primary.email
}, {
subject: user.primary.email
}); });
} else { } else {
//Sign In //Sign In
return await ctx.subject("user", { return await ctx.subject("user", {
userID: matching.id, userID: matching.id,
email: user.primary.email email: user.primary.email
}, {
subject: user.primary.email
}); });
} }

View File

@@ -1,10 +0,0 @@
import { z } from "zod";
import { resolver } from "hono-openapi/zod";
export function Result<T extends z.ZodTypeAny>(schema: T) {
return resolver(
z.object({
data: schema,
}),
);
}

View File

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

View File

@@ -0,0 +1,8 @@
import { db } from "@nestri/core/drizzle/index";
import { migrate } from "drizzle-orm/postgres-js/migrator";
export const handler = async (event: any) => {
await migrate(db, {
migrationsFolder: "./migrations",
});
};

View File

@@ -0,0 +1,10 @@
import fs from "node:fs";
// import postgres from "postgres";
import { db, sql } from "@nestri/core/drizzle/index";
export async function handler() {
// const sql = postgres(process.env.ZERO_UPSTREAM_DB!);
const perms = fs.readFileSync(".permissions.sql", "utf8");
// await sql.unsafe(perms);
await db.execute(sql.raw(perms))
}

View File

@@ -35,6 +35,10 @@ declare module "sst" {
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
"user": string "user": string
} }
"DatabaseMigrator": {
"name": string
"type": "sst.aws.Function"
}
"DiscordClientID": { "DiscordClientID": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
@@ -56,10 +60,34 @@ declare module "sst" {
"sender": string "sender": string
"type": "sst.aws.Email" "type": "sst.aws.Email"
} }
"NestriVpc": {
"bastion": string
"type": "sst.aws.Vpc"
}
"PolarSecret": { "PolarSecret": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
} }
"Postgres": {
"clusterArn": string
"database": string
"host": string
"password": string
"port": number
"reader": string
"secretArn": string
"type": "sst.aws.Aurora"
"username": string
}
"Steam": {
"service": string
"type": "sst.aws.Service"
"url": string
}
"Storage": {
"name": string
"type": "sst.aws.Bucket"
}
"Urls": { "Urls": {
"api": string "api": string
"auth": string "auth": string
@@ -70,6 +98,15 @@ declare module "sst" {
"type": "sst.aws.StaticSite" "type": "sst.aws.StaticSite"
"url": string "url": string
} }
"Zero": {
"service": string
"type": "sst.aws.Service"
"url": string
}
"ZeroPermissions": {
"name": string
"type": "sst.aws.Function"
}
} }
} }

View File

@@ -0,0 +1,24 @@
#!/bin/bash
database=$(echo $SST_RESOURCE_Postgres | jq -r '.database')
clusterArn=$(echo $SST_RESOURCE_Postgres | jq -r '.clusterArn')
secretArn=$(echo $SST_RESOURCE_Postgres | jq -r '.secretArn')
sql=$(cat <<-'STMT'
DO $$
DECLARE
row record;
BEGIN
FOR row IN SELECT * FROM pg_tables WHERE schemaname = 'public' OR schemaname = 'drizzle'
LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(row.tablename) || ' CASCADE';
EXECUTE 'DROP TABLE IF EXISTS drizzle.' || quote_ident(row.tablename) || ' CASCADE';
END LOOP;
END;
$$;
STMT
)
response=$(aws rds-data execute-statement --resource-arn $clusterArn --secret-arn $secretArn --database $database --sql "$sql" --format-records-as JSON)
json=$(echo $response | jq -r '.formattedRecords')
echo "$json" | jq .

10
packages/scripts/src/psql.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
database=$(echo $SST_RESOURCE_Postgres | jq -r '.database')
clusterArn=$(echo $SST_RESOURCE_Postgres | jq -r '.clusterArn')
secretArn=$(echo $SST_RESOURCE_Postgres | jq -r '.secretArn')
sql="$@"
response=$(aws rds-data execute-statement --resource-arn $clusterArn --secret-arn $secretArn --database $database --sql "$sql" --format-records-as JSON)
json=$(echo $response | jq -r '.formattedRecords')
echo "$json" | jq .

View File

@@ -1,16 +0,0 @@
#!/usr/bin/env bun
import { Resource } from "sst";
import { spawnSync } from "bun";
spawnSync(
[
"psql",
`postgresql://${Resource.Database.user}:${Resource.Database.password}@${Resource.Database.host}/${Resource.Database.name}?sslmode=require`,
],
{
stdout: "inherit",
stdin: "inherit",
stderr: "inherit",
},
);

484
packages/steam/.gitignore vendored Normal file
View File

@@ -0,0 +1,484 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

View File

@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
public class SteamDbContext : DbContext
{
public DbSet<SteamUserCredential> SteamUserCredentials { get; set; }
public SteamDbContext(DbContextOptions<SteamDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Create a unique index on TeamId and UserId
modelBuilder.Entity<SteamUserCredential>()
.HasIndex(c => new { c.TeamId, c.UserId })
.IsUnique();
}
}

View File

@@ -0,0 +1,12 @@
public class SteamUserCredential
{
public int Id { get; set; }
public required string TeamId { get; set; }
public required string UserId { get; set; }
public required string AccountName { get; set; }
public required string RefreshToken { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Composite key of TeamId and UserId will be unique
}

View File

@@ -0,0 +1,60 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace steam.Migrations
{
[DbContext(typeof(SteamDbContext))]
[Migration("20250322023207_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
modelBuilder.Entity("SteamUserCredential", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TeamId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TeamId", "UserId")
.IsUnique();
b.ToTable("SteamUserCredentials");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,46 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace steam.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SteamUserCredentials",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
TeamId = table.Column<string>(type: "TEXT", nullable: false),
UserId = table.Column<string>(type: "TEXT", nullable: false),
AccountName = table.Column<string>(type: "TEXT", nullable: false),
RefreshToken = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SteamUserCredentials", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_SteamUserCredentials_TeamId_UserId",
table: "SteamUserCredentials",
columns: new[] { "TeamId", "UserId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SteamUserCredentials");
}
}
}

View File

@@ -0,0 +1,57 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace steam.Migrations
{
[DbContext(typeof(SteamDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
modelBuilder.Entity("SteamUserCredential", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TeamId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TeamId", "UserId")
.IsUnique();
b.ToTable("SteamUserCredentials");
});
#pragma warning restore 612, 618
}
}
}

331
packages/steam/Program.cs Normal file
View File

@@ -0,0 +1,331 @@
using System.Text;
using System.Text.Json;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
// FYI: Am very new to C# if you find any bugs or have any feedback hit me up :P
// TBH i dunno what this code does, only God and Claude know(in the slightest) what it does.
// And yes! It does not sit right with me - am learning C# as we go, i guess 🤧
// This is the server to connect to the Steam APIs and do stuff like:
// - authenticate a user,
// - get their library,
// - generate .vdf files for Steam Client (Steam manifest files), etc etc
var builder = WebApplication.CreateBuilder(args);
// Add JWT Authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = Environment.GetEnvironmentVariable("NESTRI_AUTH_JWKS_URL"),
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
RequireSignedTokens = true,
RequireExpirationTime = true,
ClockSkew = TimeSpan.Zero,
// Configure the issuer signing key provider
IssuerSigningKeyResolver = (token, securityToken, kid, validationParameters) =>
{
// Fetch the JWKS manually
var jwksUrl = $"{Environment.GetEnvironmentVariable("NESTRI_AUTH_JWKS_URL")}/.well-known/jwks.json";
var httpClient = new HttpClient();
var jwksJson = httpClient.GetStringAsync(jwksUrl).Result;
var jwks = JsonSerializer.Deserialize<JsonWebKeySet>(jwksJson);
// Return all keys or filter by kid if provided
if (string.IsNullOrEmpty(kid))
return jwks?.Keys;
else
return jwks?.Keys.Where(k => k.Kid == kid);
}
};
// Add logging for debugging
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
Console.WriteLine($"Authentication failed: {context.Exception.Message}");
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
Console.WriteLine("Token successfully validated");
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
// Configure CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(
policy =>
{
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
});
});
builder.Services.AddSingleton<SteamService>();
builder.Services.AddDbContext<SteamDbContext>(options =>
options.UseSqlite($"Data Source=/tmp/steam.db"));
var app = builder.Build();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/", () => "Hello World!");
app.MapGet("/status", [Authorize] async (HttpContext context, SteamService steamService) =>
{
// Validate JWT
var jwtToken = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
var (isValid, userId, email) = await ValidateJwtToken(jwtToken);
if (!isValid)
{
return Results.Unauthorized();
}
// Get team ID
var teamId = context.Request.Headers["x-nestri-team"].ToString();
if (string.IsNullOrEmpty(teamId))
{
return Results.BadRequest("Missing team ID");
}
// Check if user is authenticated with Steam
var userInfo = await steamService.GetUserInfoFromStoredCredentials(teamId, userId!);
if (userInfo == null)
{
return Results.Ok(new { isAuthenticated = false });
}
return Results.Ok(new
{
isAuthenticated = true,
steamId = userInfo.SteamId,
username = userInfo.Username
});
});
app.MapGet("/login", [Authorize] async (HttpContext context, SteamService steamService) =>
{
// Validate JWT
var jwtToken = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
var (isValid, userId, email) = await ValidateJwtToken(jwtToken);
Console.WriteLine($"User data: {userId}:{email}");
if (!isValid)
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("Invalid JWT token");
return;
}
// Get team ID
var teamId = context.Request.Headers["x-nestri-team"].ToString();
if (string.IsNullOrEmpty(teamId))
{
context.Response.StatusCode = 400;
await context.Response.WriteAsync("Missing team ID");
return;
}
// Set SSE headers
context.Response.Headers.Append("Connection", "keep-alive");
context.Response.Headers.Append("Cache-Control", "no-cache");
context.Response.Headers.Append("Content-Type", "text/event-stream");
context.Response.Headers.Append("Access-Control-Allow-Origin", "*");
// Disable response buffering
var responseBodyFeature = context.Features.Get<IHttpResponseBodyFeature>();
if (responseBodyFeature != null)
{
responseBodyFeature.DisableBuffering();
}
// Create unique client ID
var clientId = $"{teamId}:{userId}";
var cancellationToken = context.RequestAborted;
// Start Steam authentication
await steamService.StartAuthentication(teamId, userId!);
// Register for updates
var subscription = steamService.SubscribeToEvents(clientId, async (evt) =>
{
try
{
// Serialize the event to SSE format
string eventMessage = evt.Serialize();
byte[] buffer = Encoding.UTF8.GetBytes(eventMessage);
await context.Response.Body.WriteAsync(buffer, 0, buffer.Length, cancellationToken);
await context.Response.Body.FlushAsync(cancellationToken);
Console.WriteLine($"Sent event type '{evt.Type}' to client {clientId}");
}
catch (Exception ex)
{
Console.WriteLine($"Error sending event to client {clientId}: {ex.Message}");
}
});
// Keep the connection alive until canceled
try
{
await Task.Delay(Timeout.Infinite, cancellationToken);
}
catch (TaskCanceledException)
{
Console.WriteLine($"Client {clientId} disconnected");
}
finally
{
steamService.Unsubscribe(clientId, subscription);
}
});
app.MapGet("/user", [Authorize] async (HttpContext context, SteamService steamService) =>
{
// Validate JWT
var jwtToken = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
var (isValid, userId, email) = await ValidateJwtToken(jwtToken);
if (!isValid)
{
return Results.Unauthorized();
}
// Get team ID
var teamId = context.Request.Headers["x-nestri-team"].ToString();
if (string.IsNullOrEmpty(teamId))
{
return Results.BadRequest("Missing team ID");
}
// Get user info from stored credentials
var userInfo = await steamService.GetUserInfoFromStoredCredentials(teamId, userId);
if (userInfo == null)
{
return Results.NotFound(new { error = "User not authenticated with Steam" });
}
return Results.Ok(new
{
steamId = userInfo.SteamId,
username = userInfo.Username
});
});
app.MapPost("/logout", [Authorize] async (HttpContext context, SteamService steamService) =>
{
// Validate JWT
var jwtToken = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", "");
var (isValid, userId, email) = await ValidateJwtToken(jwtToken);
if (!isValid)
{
return Results.Unauthorized();
}
// Get team ID
var teamId = context.Request.Headers["x-nestri-team"].ToString();
if (string.IsNullOrEmpty(teamId))
{
return Results.BadRequest("Missing team ID");
}
// Delete the stored credentials
using var scope = context.RequestServices.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<SteamDbContext>();
var credentials = await dbContext.SteamUserCredentials
.FirstOrDefaultAsync(c => c.TeamId == teamId && c.UserId == userId);
if (credentials != null)
{
dbContext.SteamUserCredentials.Remove(credentials);
await dbContext.SaveChangesAsync();
return Results.Ok(new { message = "Steam authentication revoked" });
}
return Results.NotFound(new { error = "No Steam authentication found" });
});
// JWT validation function
async Task<(bool IsValid, string? UserId, string? Email)> ValidateJwtToken(string token)
{
try
{
var jwksUrl = Environment.GetEnvironmentVariable("NESTRI_AUTH_JWKS_URL");
var handler = new JwtSecurityTokenHandler();
var jwtToken = handler.ReadJwtToken(token);
// Log all claims for debugging
// Console.WriteLine("JWT Claims:");
// foreach (var claim in jwtToken.Claims)
// {
// Console.WriteLine($" {claim.Type}: {claim.Value}");
// }
// Validate token using JWKS
var httpClient = new HttpClient();
var jwksJson = await httpClient.GetStringAsync($"{jwksUrl}/.well-known/jwks.json");
var jwks = JsonSerializer.Deserialize<JsonWebKeySet>(jwksJson);
// Extract the properties claim which contains nested JSON
var propertiesClaim = jwtToken.Claims.FirstOrDefault(c => c.Type == "properties")?.Value;
if (!string.IsNullOrEmpty(propertiesClaim))
{
// Parse the nested JSON
var properties = JsonSerializer.Deserialize<Dictionary<string, string>>(propertiesClaim);
// Extract userID from properties
var email = properties?.GetValueOrDefault("email");
var userId = properties?.GetValueOrDefault("userID");
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(email))
{
// Also check standard claims as fallback
userId = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
email = jwtToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value;
if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(email))
{
return (false, null, null);
}
}
return (true, userId, email);
}
return (false, null, null);
}
catch (Exception ex)
{
Console.WriteLine($"JWT validation error: {ex.Message}");
return (false, null, null);
}
}
Console.WriteLine("Server started. Press Ctrl+C to stop.");
await app.RunAsync();

View File

@@ -0,0 +1,38 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:12427",
"sslPort": 44354
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5289",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7168;http://localhost:5289",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,19 @@
using System.Text.Json;
public class ServerSentEvent
{
public string Type { get; set; }
public object Data { get; set; }
public ServerSentEvent(string type, object data)
{
Type = type;
Data = data;
}
public string Serialize()
{
var dataJson = JsonSerializer.Serialize(Data);
return $"event: {Type}\ndata: {dataJson}\n\n";
}
}

View File

@@ -0,0 +1,357 @@
using SteamKit2;
using SteamKit2.Authentication;
// Steam client handler
public class SteamClientHandler
{
private readonly string _clientId;
private readonly SteamClient _steamClient;
private readonly CallbackManager _manager;
private readonly SteamUser _steamUser;
public event Action<ServerSentEvent>? OnEvent;
private readonly List<Action<string>> _subscribers = new();
private QrAuthSession? _authSession;
private Task? _callbackTask;
private CancellationTokenSource? _cts;
private bool _isAuthenticated = false;
public SteamUserInfo? UserInfo { get; private set; }
// Add a callback for when credentials are obtained
private readonly Action<string, string>? _onCredentialsObtained;
// Update constructor to optionally receive the callback
public SteamClientHandler(string clientId, Action<string, string>? onCredentialsObtained = null)
{
_clientId = clientId;
_onCredentialsObtained = onCredentialsObtained;
_steamClient = new SteamClient(SteamConfiguration.Create(e => e.WithConnectionTimeout(TimeSpan.FromSeconds(120))));
_manager = new CallbackManager(_steamClient);
_steamUser = _steamClient.GetHandler<SteamUser>()!;
// Register callbacks
_manager.Subscribe<SteamClient.ConnectedCallback>(OnConnected);
_manager.Subscribe<SteamClient.DisconnectedCallback>(OnDisconnected);
_manager.Subscribe<SteamUser.LoggedOnCallback>(OnLoggedOn);
_manager.Subscribe<SteamUser.LoggedOffCallback>(OnLoggedOff);
}
// Add method to login with stored credentials
public async Task<bool> LoginWithStoredCredentialsAsync(string accountName, string refreshToken)
{
if (_callbackTask != null)
{
return _isAuthenticated; // Already connected
}
_cts = new CancellationTokenSource();
// Connect to Steam
Console.WriteLine($"[{_clientId}] Connecting to Steam with stored credentials...");
_steamClient.Connect();
// Start callback loop
_callbackTask = Task.Run(async () =>
{
while (!_cts.Token.IsCancellationRequested)
{
_manager.RunWaitCallbacks(TimeSpan.FromSeconds(1));
await Task.Delay(10);
}
}, _cts.Token);
// Wait for connection
var connectionTask = new TaskCompletionSource<bool>();
var connectedHandler = _manager.Subscribe<SteamClient.ConnectedCallback>(callback =>
{
// Once connected, try to log in with stored credentials
Console.WriteLine($"[{_clientId}] Connected to Steam, logging in with stored credentials");
_steamUser.LogOn(new SteamUser.LogOnDetails
{
Username = accountName,
AccessToken = refreshToken
});
connectionTask.TrySetResult(true);
});
// Set up a handler for the login result
var loginResultTask = new TaskCompletionSource<bool>();
var loggedOnHandler = _manager.Subscribe<SteamUser.LoggedOnCallback>(callback =>
{
if (callback.Result == EResult.OK)
{
Console.WriteLine($"[{_clientId}] Successfully logged on with stored credentials");
_isAuthenticated = true;
UserInfo = new SteamUserInfo
{
SteamId = callback.ClientSteamID.ToString(),
Username = accountName
};
loginResultTask.TrySetResult(true);
}
else
{
Console.WriteLine($"[{_clientId}] Failed to log on with stored credentials: {callback.Result}");
loginResultTask.TrySetResult(false);
}
});
// Add a timeout
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(30));
try
{
await connectionTask.Task;
var completedTask = await Task.WhenAny(loginResultTask.Task, timeoutTask);
if (completedTask == timeoutTask)
{
Console.WriteLine($"[{_clientId}] Login with stored credentials timed out");
Shutdown();
return false;
}
return await loginResultTask.Task;
}
catch (Exception ex)
{
Console.WriteLine($"[{_clientId}] Error logging in with stored credentials: {ex.Message}");
return false;
}
// finally
// {
// _manager.Unsubscribe(connectedHandler);
// _manager.Unsubscribe(loggedOnHandler);
// }
}
public async Task StartAuthenticationAsync()
{
if (_callbackTask != null)
{
// Authentication already in progress
if (_authSession != null)
{
// Just resend the current QR code URL to all subscribers
NotifySubscribers(_authSession.ChallengeURL);
}
return;
}
_cts = new CancellationTokenSource();
// Connect to Steam
Console.WriteLine($"[{_clientId}] Connecting to Steam...");
_steamClient.Connect();
// Start callback loop
_callbackTask = Task.Run(async () =>
{
while (!_cts.Token.IsCancellationRequested)
{
_manager.RunWaitCallbacks(TimeSpan.FromSeconds(1));
await Task.Delay(10);
}
}, _cts.Token);
}
private void NotifyEvent(ServerSentEvent evt)
{
OnEvent?.Invoke(evt);
// Also notify the legacy subscribers with just the URL if this is a URL event
if (evt.Type == "url" && evt.Data is string url)
{
NotifySubscribers(url);
}
}
private async void OnConnected(SteamClient.ConnectedCallback callback)
{
Console.WriteLine($"[{_clientId}] Connected to Steam");
try
{
// Start QR authentication session
_authSession = await _steamClient.Authentication.BeginAuthSessionViaQRAsync(new AuthSessionDetails());
// Handle QR code URL changes
_authSession.ChallengeURLChanged = () =>
{
Console.WriteLine($"[{_clientId}] QR challenge URL refreshed");
NotifyEvent(new ServerSentEvent("url", _authSession.ChallengeURL));
};
// Send initial QR code URL
NotifyEvent(new ServerSentEvent("url", _authSession.ChallengeURL));
// Start polling for authentication result
await Task.Run(async () =>
{
try
{
var pollResponse = await _authSession.PollingWaitForResultAsync();
Console.WriteLine($"[{_clientId}] Logging in as '{pollResponse.AccountName}'");
// Send login attempt event
NotifyEvent(new ServerSentEvent("login-attempt", new { username = pollResponse.AccountName }));
// Login to Steam
_steamUser.LogOn(new SteamUser.LogOnDetails
{
Username = pollResponse.AccountName,
AccessToken = pollResponse.RefreshToken,
});
}
catch (Exception ex)
{
Console.WriteLine($"[{_clientId}] Authentication polling error: {ex.Message}");
NotifyEvent(new ServerSentEvent("login-unsuccessful", new { error = ex.Message }));
}
});
}
catch (Exception ex)
{
Console.WriteLine($"[{_clientId}] Error starting authentication: {ex.Message}");
NotifyEvent(new ServerSentEvent("login-unsuccessful", new { error = ex.Message }));
}
}
private void OnDisconnected(SteamClient.DisconnectedCallback callback)
{
Console.WriteLine($"[{_clientId}] Disconnected from Steam");
_isAuthenticated = false;
UserInfo = null;
// Reconnect if not intentionally stopped
if (_callbackTask != null && !_cts.IsCancellationRequested)
{
Console.WriteLine($"[{_clientId}] Reconnecting...");
_steamClient.Connect();
}
}
private void OnLoggedOn(SteamUser.LoggedOnCallback callback)
{
if (callback.Result != EResult.OK)
{
Console.WriteLine($"[{_clientId}] Unable to log on to Steam: {callback.Result} / {callback.ExtendedResult}");
NotifyEvent(new ServerSentEvent("login-unsuccessful", new
{
error = $"Steam login failed: {callback.Result}",
extendedError = callback.ExtendedResult.ToString()
}));
return;
}
Console.WriteLine($"[{_clientId}] Successfully logged on as {callback.ClientSteamID}");
_isAuthenticated = true;
// Get the username from the authentication session
string accountName = _authSession?.PollingWaitForResultAsync().Result.AccountName ?? "Unknown";
string refreshToken = _authSession?.PollingWaitForResultAsync().Result.RefreshToken ?? "";
UserInfo = new SteamUserInfo
{
SteamId = callback.ClientSteamID.ToString(),
Username = accountName
};
// Send login success event
NotifyEvent(new ServerSentEvent("login-success", new
{
steamId = callback.ClientSteamID.ToString(),
username = accountName
}));
// Save credentials if callback is provided
if (_onCredentialsObtained != null && !string.IsNullOrEmpty(refreshToken))
{
_onCredentialsObtained(accountName, refreshToken);
}
}
private void OnLoggedOff(SteamUser.LoggedOffCallback callback)
{
Console.WriteLine($"[{_clientId}] Logged off of Steam: {callback.Result}");
_isAuthenticated = false;
UserInfo = null;
//Unnecessary but just in case the frontend wants to listen to this
NotifyEvent(new ServerSentEvent("logged-off", new
{
reason = callback.Result.ToString()
}));
}
public Action Subscribe(Action<ServerSentEvent> callback)
{
OnEvent += callback;
// If we already have a QR code URL, send it immediately
if (_authSession != null)
{
callback(new ServerSentEvent("url", _authSession.ChallengeURL));
}
return () => OnEvent -= callback;
}
// Keep the old Subscribe method for backward compatibility
public Action Subscribe(Action<string> callback)
{
lock (_subscribers)
{
_subscribers.Add(callback);
// If we already have a QR code URL, send it immediately
if (_authSession != null)
{
callback(_authSession.ChallengeURL);
}
}
return () =>
{
lock (_subscribers)
{
_subscribers.Remove(callback);
}
};
}
private void NotifySubscribers(string url)
{
lock (_subscribers)
{
foreach (var subscriber in _subscribers)
{
try
{
subscriber(url);
}
catch (Exception ex)
{
Console.WriteLine($"[{_clientId}] Error notifying subscriber: {ex.Message}");
}
}
}
}
public void Shutdown()
{
_cts?.Cancel();
_steamClient.Disconnect();
}
}
public class SteamUserInfo
{
public string SteamId { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,156 @@
using SteamKit2;
using SteamKit2.Authentication;
using Microsoft.EntityFrameworkCore;
using System.Collections.Concurrent;
// Steam Service
public class SteamService
{
private readonly ConcurrentDictionary<string, SteamClientHandler> _clientHandlers = new();
private readonly IServiceProvider _serviceProvider;
public SteamService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public Action SubscribeToEvents(string clientId, Action<ServerSentEvent> callback)
{
if (_clientHandlers.TryGetValue(clientId, out var handler))
{
return handler.Subscribe(callback);
}
return () => { }; // Empty unsubscribe function
}
public async Task StartAuthentication(string teamId, string userId)
{
var clientId = $"{teamId}:{userId}";
// Check if we already have stored credentials
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<SteamDbContext>();
var storedCredential = await dbContext.SteamUserCredentials
.FirstOrDefaultAsync(c => c.TeamId == teamId && c.UserId == userId);
var handler = _clientHandlers.GetOrAdd(clientId, id => new SteamClientHandler(id,
async (accountName, refreshToken) => await SaveCredentials(teamId, userId, accountName, refreshToken)));
if (storedCredential != null)
{
// We have stored credentials, try to use them
var success = await handler.LoginWithStoredCredentialsAsync(storedCredential.AccountName, storedCredential.RefreshToken);
// If login failed, start fresh authentication
if (!success)
{
await handler.StartAuthenticationAsync();
}
return;
}
// No stored credentials, start fresh authentication
await handler.StartAuthenticationAsync();
}
private async Task SaveCredentials(string teamId, string userId, string accountName, string refreshToken)
{
try
{
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<SteamDbContext>();
var existingCredential = await dbContext.SteamUserCredentials
.FirstOrDefaultAsync(c => c.TeamId == teamId && c.UserId == userId);
if (existingCredential != null)
{
// Update existing record
existingCredential.AccountName = accountName;
existingCredential.RefreshToken = refreshToken;
existingCredential.UpdatedAt = DateTime.UtcNow;
}
else
{
// Create new record
dbContext.SteamUserCredentials.Add(new SteamUserCredential
{
TeamId = teamId,
UserId = userId,
AccountName = accountName,
RefreshToken = refreshToken
});
}
await dbContext.SaveChangesAsync();
Console.WriteLine($"Saved Steam credentials for {teamId}:{userId}");
}
catch (Exception ex)
{
Console.WriteLine($"Error saving credentials: {ex.Message}");
}
}
public async Task<SteamUserInfo?> GetUserInfoFromStoredCredentials(string teamId, string userId)
{
var clientId = $"{teamId}:{userId}";
// Check if we have an active session
if (_clientHandlers.TryGetValue(clientId, out var activeHandler) && activeHandler.UserInfo != null)
{
return activeHandler.UserInfo;
}
// Try to get stored credentials
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<SteamDbContext>();
var storedCredential = await dbContext.SteamUserCredentials
.FirstOrDefaultAsync(c => c.TeamId == teamId && c.UserId == userId);
if (storedCredential == null)
{
return null; // No stored credentials
}
// Create a new handler and try to log in
var handler = new SteamClientHandler(clientId);
var success = await handler.LoginWithStoredCredentialsAsync(
storedCredential.AccountName,
storedCredential.RefreshToken);
if (success)
{
_clientHandlers.TryAdd(clientId, handler);
return handler.UserInfo;
}
// Login failed, credentials might be invalid
return null;
}
public Action Subscribe(string clientId, Action<string> callback)
{
if (_clientHandlers.TryGetValue(clientId, out var handler))
{
return handler.Subscribe(callback);
}
return () => { }; // Empty unsubscribe function
}
public void Unsubscribe(string clientId, Action unsubscribeAction)
{
unsubscribeAction();
}
public SteamUserInfo? GetUserInfo(string clientId)
{
if (_clientHandlers.TryGetValue(clientId, out var handler))
{
return handler.UserInfo;
}
return null;
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.3" />
<PackageReference Include="SteamKit2" Version="3.0.2" />
</ItemGroup>
</Project>

View File

@@ -9,12 +9,12 @@
<meta <meta
name="theme-color" name="theme-color"
media="(prefers-color-scheme: light)" media="(prefers-color-scheme: light)"
content="#f5f5f5" content="rgba(255,255,255,0.8)"
/> />
<meta <meta
name="theme-color" name="theme-color"
media="(prefers-color-scheme: dark)" media="(prefers-color-scheme: dark)"
content="#171717" content="rgb(19,21,23)"
/> />
<link <link
rel="apple-touch-icon" rel="apple-touch-icon"

View File

@@ -9,8 +9,9 @@
"typecheck": "tsc --noEmit --incremental" "typecheck": "tsc --noEmit --incremental"
}, },
"devDependencies": { "devDependencies": {
"@macaron-css/vite": "^1.5.1", "@macaron-css/vite": "1.5.1",
"@types/bun": "latest", "@types/bun": "latest",
"@types/qrcode": "^1.5.5",
"vite": "5.4.12", "vite": "5.4.12",
"vite-plugin-solid": "^2.11.2" "vite-plugin-solid": "^2.11.2"
}, },
@@ -21,14 +22,24 @@
"@fontsource-variable/geist-mono": "^5.0.1", "@fontsource-variable/geist-mono": "^5.0.1",
"@fontsource-variable/mona-sans": "^5.0.1", "@fontsource-variable/mona-sans": "^5.0.1",
"@fontsource/geist-sans": "^5.1.0", "@fontsource/geist-sans": "^5.1.0",
"@macaron-css/core": "^1.5.2", "@macaron-css/core": "1.5.1",
"@macaron-css/solid": "^1.5.3", "@macaron-css/solid": "1.5.3",
"@modular-forms/solid": "^0.25.1", "@modular-forms/solid": "^0.25.1",
"@nestri/core": "*", "@nestri/core": "*",
"@openauthjs/openauth": "0.4.3", "@nestri/functions": "*",
"@nestri/zero": "*",
"@openauthjs/openauth": "*",
"@openauthjs/solid": "0.0.0-20250311201457",
"@rocicorp/zero": "*",
"@solid-primitives/event-listener": "^2.4.0",
"@solid-primitives/storage": "^4.3.1", "@solid-primitives/storage": "^4.3.1",
"@solidjs/router": "^0.15.3", "@solidjs/router": "^0.15.3",
"body-scroll-lock-upgrade": "^1.1.0",
"eventsource": "^3.0.5",
"focus-trap": "^7.6.4",
"hono": "^4.7.4",
"modern-normalize": "^3.0.1", "modern-normalize": "^3.0.1",
"qrcode": "^1.5.4",
"solid-js": "^1.9.5", "solid-js": "^1.9.5",
"valibot": "^1.0.0-rc.3", "valibot": "^1.0.0-rc.3",
"zod": "^3.24.2" "zod": "^3.24.2"

View File

@@ -6,13 +6,18 @@ import '@fontsource/geist-sans/600.css';
import '@fontsource/geist-sans/700.css'; import '@fontsource/geist-sans/700.css';
import '@fontsource/geist-sans/800.css'; import '@fontsource/geist-sans/800.css';
import '@fontsource/geist-sans/900.css'; import '@fontsource/geist-sans/900.css';
import { Text } from '@nestri/www/ui/text';
import { styled } from "@macaron-css/solid"; import { styled } from "@macaron-css/solid";
import { useStorage } from './providers/account'; import { Screen as FullScreen } from '@nestri/www/ui/layout';
import { CreateTeamComponent } from './pages/new'; import { TeamRoute } from '@nestri/www/pages/team';
import { darkClass, lightClass, theme } from './ui/theme'; import { OpenAuthProvider } from "@openauthjs/solid";
import { AuthProvider, useAuth } from './providers/auth'; import { NotFound } from '@nestri/www/pages/not-found';
import { Navigate, Route, Router } from "@solidjs/router"; import { Navigate, Route, Router } from "@solidjs/router";
import { globalStyle, macaron$ } from "@macaron-css/core"; import { globalStyle, macaron$ } from "@macaron-css/core";
import { useStorage } from '@nestri/www/providers/account';
import { CreateTeamComponent } from '@nestri/www/pages/new';
import { darkClass, lightClass, theme } from '@nestri/www/ui/theme';
import { AccountProvider, useAccount } from '@nestri/www/providers/account';
import { Component, createSignal, Match, onCleanup, Switch } from 'solid-js'; import { Component, createSignal, Match, onCleanup, Switch } from 'solid-js';
const Root = styled("div", { const Root = styled("div", {
@@ -34,14 +39,19 @@ globalStyle("html", {
// Hardcode colors // Hardcode colors
"@media": { "@media": {
"(prefers-color-scheme: light)": { "(prefers-color-scheme: light)": {
backgroundColor: "#f5f5f5", backgroundColor: "rgba(255,255,255,0.8)",
}, },
"(prefers-color-scheme: dark)": { "(prefers-color-scheme: dark)": {
backgroundColor: "#1e1e1e", backgroundColor: "rgb(19,21,23)",
}, },
}, },
}); });
globalStyle("dialog:modal", {
maxHeight: "unset",
maxWidth: "unset"
})
globalStyle("h1, h2, h3, h4, h5, h6, p", { globalStyle("h1, h2, h3, h4, h5, h6, p", {
margin: 0, margin: 0,
}); });
@@ -82,44 +92,54 @@ export const App: Component = () => {
const storage = useStorage(); const storage = useStorage();
return ( return (
<Root class={theme() === "light" ? lightClass : darkClass} id="styled"> <OpenAuthProvider
<Router> issuer={import.meta.env.VITE_AUTH_URL}
<Route clientID="web"
path="*" >
component={(props) => ( <Root class={theme() === "light" ? lightClass : darkClass} id="styled">
<AuthProvider> <Router>
{props.children}
</AuthProvider>
// props.children
)}
>
<Route path="new" component={CreateTeamComponent} />
<Route <Route
path="/" path="*"
component={() => { component={(props) => (
const auth = useAuth(); <AccountProvider
return ( loadingUI={
<Switch> <FullScreen>
<Match when={auth.current.teams.length > 0}> <Text weight='semibold' spacing='xs' size="3xl" font="heading" >Confirming your identity&hellip;</Text>
<Navigate </FullScreen>
href={`/${( }>
auth.current.teams.find( {props.children}
(w) => w.id === storage.value.team, </AccountProvider>
) || auth.current.teams[0] )}
).slug >
}`} <Route path=":teamSlug">{TeamRoute}</Route>
/> <Route path="new" component={CreateTeamComponent} />
</Match> <Route
<Match when={true}> path="/"
<Navigate href={`/new`} /> component={() => {
</Match> const account = useAccount();
</Switch> return (
); <Switch>
}} <Match when={account.current.teams.length > 0}>
/> <Navigate
{/* <Route path="*" component={() => <NotFound />} /> */} href={`/${(
</Route> account.current.teams.find(
</Router> (w) => w.id === storage.value.team,
</Root> ) || account.current.teams[0]
).slug
}`}
/>
</Match>
<Match when={true}>
<Navigate href={`/new`} />
</Match>
</Switch>
);
}}
/>
<Route path="*" component={() => <NotFound />} />
</Route>
</Router>
</Root>
</OpenAuthProvider>
) )
} }

View File

@@ -1,4 +1,4 @@
import { ParentProps, Show, createContext, useContext } from "solid-js"; import { JSX, ParentProps, Show, createContext, useContext } from "solid-js";
export function createInitializedContext< export function createInitializedContext<
Name extends string, Name extends string,
@@ -12,10 +12,12 @@ export function createInitializedContext<
if (!context) throw new Error(`No ${name} context`); if (!context) throw new Error(`No ${name} context`);
return context; return context;
}, },
provider: (props: ParentProps) => { provider: (props: ParentProps & { loadingUI?: JSX.Element }) => {
const value = cb(); const value = cb();
return ( return (
<Show when={value.ready}> <Show
fallback={props.loadingUI}
when={value.ready}>
<ctx.Provider value={value} {...props}> <ctx.Provider value={value} {...props}>
{props.children} {props.children}
</ctx.Provider> </ctx.Provider>

View File

@@ -1,14 +1,18 @@
import * as v from "valibot" import * as v from "valibot"
import { styled } from "@macaron-css/solid"; import { Show } from "solid-js";
import { Text } from "@nestri/www/ui/text";
import { utility } from "@nestri/www/ui/utility";
import { theme } from "@nestri/www/ui/theme";
import { FormField, Input, Select } from "@nestri/www/ui/form";
import { Container, FullScreen } from "@nestri/www/ui/layout";
import { createForm, required, email, valiForm } from "@modular-forms/solid";
import { Button } from "@nestri/www/ui"; import { Button } from "@nestri/www/ui";
import { Text } from "@nestri/www/ui/text";
import { styled } from "@macaron-css/solid";
import { theme } from "@nestri/www/ui/theme";
import { useNavigate } from "@solidjs/router";
import { useOpenAuth } from "@openauthjs/solid";
import { utility } from "@nestri/www/ui/utility";
import { useAccount } from "../providers/account";
import { Container, FullScreen } from "@nestri/www/ui/layout";
import { FormField, Input, Select } from "@nestri/www/ui/form";
import { createForm, getValue, setError, valiForm } from "@modular-forms/solid";
// const nameRegex = /^[a-z]+$/ const nameRegex = /^[a-z0-9\-]+$/
const FieldList = styled("div", { const FieldList = styled("div", {
base: { base: {
@@ -33,19 +37,19 @@ const Plan = {
} as const; } as const;
const schema = v.object({ const schema = v.object({
plan: v.pipe( planType: v.pipe(
v.enum(Plan), v.enum(Plan, "Choose a valid plan"),
v.minLength(2,"Please choose a plan"),
), ),
display_name: v.pipe( name: v.pipe(
v.string(), v.string(),
v.maxLength(32, 'Please use 32 characters at maximum.'), v.minLength(2, 'Use 2 characters at minimum.'),
v.maxLength(32, 'Use 32 characters at maximum.'),
), ),
slug: v.pipe( slug: v.pipe(
v.string(), v.string(),
v.minLength(2, 'Please use 2 characters at minimum.'), v.regex(nameRegex, "Use a URL friendly name."),
// v.regex(nameRegex, "Use only small letters, no numbers or special characters"), v.minLength(2, 'Use 2 characters at minimum.'),
v.maxLength(48, 'Please use 48 characters at maximum.'), v.maxLength(48, 'Use 48 characters at maximum.'),
) )
}) })
@@ -82,11 +86,39 @@ const schema = v.object({
// } // }
// }) // })
const UrlParent = styled("div", {
base: {
display: "flex",
width: "100%",
}
})
const UrlTitle = styled("span", {
base: {
borderWidth: 1,
borderRight: 0,
display: "flex",
alignItems: "center",
borderStyle: "solid",
color: theme.color.gray.d900,
fontSize: theme.font.size.sm,
padding: `0 ${theme.space[3]}`,
height: theme.input.size.base,
borderColor: theme.color.gray.d400,
borderTopLeftRadius: theme.borderRadius,
borderBottomLeftRadius: theme.borderRadius,
}
})
export function CreateTeamComponent() { export function CreateTeamComponent() {
const [form, { Form, Field }] = createForm({ const [form, { Form, Field }] = createForm({
validate: valiForm(schema), validate: valiForm(schema),
}); });
const nav = useNavigate();
const auth = useOpenAuth();
const account = useAccount();
return ( return (
<FullScreen> <FullScreen>
<Container horizontal="center" style={{ width: "100%", padding: "1rem", }} space="1" > <Container horizontal="center" style={{ width: "100%", padding: "1rem", }} space="1" >
@@ -95,20 +127,41 @@ export function CreateTeamComponent() {
Create a Team Create a Team
</Text> </Text>
<Text style={{ color: theme.color.gray.d900 }} size="sm"> <Text style={{ color: theme.color.gray.d900 }} size="sm">
Choose something that your teammates will recognize Choose something that your team mates will recognize
</Text> </Text>
<Hr /> <Hr />
</Container> </Container>
<Form style={{ width: "100%", "max-width": "380px" }}> <Form style={{ width: "100%", "max-width": "380px" }}
onSubmit={async (data) => {
console.log("submitting");
const result = await fetch(
import.meta.env.VITE_API_URL + "/team",
{
method: "POST",
headers: {
authorization: `Bearer ${await auth.access()}`,
"content-type": "application/json",
},
body: JSON.stringify(data),
},
);
if (!result.ok) {
setError(form, "slug", "Team slug is already taken.");
return;
}
await account.refresh(account.current.email);
await new Promise(resolve => setTimeout(resolve, 1000));
nav(`/${data.slug}`);
}}
>
<FieldList> <FieldList>
<Field type="string" name="slug"> <Field type="string" name="name">
{(field, props) => ( {(field, props) => (
<FormField <FormField
label="Team Name" label="Team Name"
hint={ hint={
field.error field.error
&& field.error && field.error
// : "Needs to be lowercase, unique, and URL friendly."
} }
color={field.error ? "danger" : "primary"} color={field.error ? "danger" : "primary"}
> >
@@ -120,19 +173,47 @@ export function CreateTeamComponent() {
</FormField> </FormField>
)} )}
</Field> </Field>
<Field type="string" name="plan"> <Field type="string" name="slug">
{(field, props) => (
<FormField
label="Team Slug"
hint={
field.error
&& field.error
}
color={field.error ? "danger" : "primary"}
>
<UrlParent
data-type='url'
>
<UrlTitle>
nestri.io/
</UrlTitle>
<Input
{...props}
autofocus
placeholder={
getValue(form, "name")?.toString()
.split(" ").join("-")
.toLowerCase() || "janes-team"}
/>
</UrlParent>
</FormField>
)}
</Field>
<Field type="string" name="planType">
{(field, props) => ( {(field, props) => (
<FormField <FormField
label="Plan Type" label="Plan Type"
hint={ hint={
field.error field.error
&& field.error && field.error
// : "Needs to be lowercase, unique, and URL friendly."
} }
color={field.error ? "danger" : "primary"} color={field.error ? "danger" : "primary"}
> >
<Select <Select
{...props} {...props}
required
value={field.value} value={field.value}
badges={[ badges={[
{ label: "BYOG", color: "purple" }, { label: "BYOG", color: "purple" },
@@ -156,8 +237,10 @@ export function CreateTeamComponent() {
</div> </div>
</Summary> </Summary>
</Details> */} </Details> */}
<Button color="brand"> <Button color="brand" disabled={form.submitting} >
Continue <Show when={form.submitting} fallback="Create">
Creating&hellip;
</Show>
</Button> </Button>
</FieldList> </FieldList>
</Form> </Form>

View File

@@ -0,0 +1,70 @@
import { Show } from "solid-js";
import { A } from "@solidjs/router";
import { Text } from "@nestri/www/ui/text";
import { styled } from "@macaron-css/solid";
import { theme } from "@nestri/www/ui/theme";
import { Header } from "@nestri/www/pages/team/header";
import { FullScreen, Container } from "@nestri/www/ui/layout";
const NotAllowedDesc = styled("div", {
base: {
fontSize: theme.font.size.base,
color: theme.color.gray.d900,
},
});
const HomeLink = styled(A, {
base: {
fontSize: theme.font.size.base,
textUnderlineOffset: 1,
color: theme.color.blue.d900
},
});
interface ErrorScreenProps {
inset?: "none" | "header";
message?: string;
header?: boolean;
}
export function NotFound(props: ErrorScreenProps) {
return (
<>
<Show when={props.header}>
<Header />
</Show>
<FullScreen
inset={props.inset ? props.inset : props.header ? "header" : "none"}
>
<Container space="2.5" horizontal="center">
<Text weight="semibold" spacing="xs" size="3xl">{props.message || "Page not found"}</Text>
<HomeLink href="/">Go back home</HomeLink>
</Container>
</FullScreen>
</>
);
}
export function NotAllowed(props: ErrorScreenProps) {
return (
<>
<Show when={props.header}>
<Header />
</Show>
<FullScreen
inset={props.inset ? props.inset : props.header ? "header" : "none"}
>
<Container space="2.5" horizontal="center">
<Text weight="semibold" spacing="xs" size="3xl">Access not allowed</Text>
<NotAllowedDesc>
You don't have access to this page,&nbsp;
<HomeLink href="/">go back home</HomeLink>.
</NotAllowedDesc>
<NotAllowedDesc>
Public profiles are coming soon
</NotAllowedDesc>
</Container>
</FullScreen>
</>
);
}

View File

@@ -0,0 +1,322 @@
import { A } from "@solidjs/router";
import { Container } from "@nestri/www/ui";
import Avatar from "@nestri/www/ui/avatar";
import { styled } from "@macaron-css/solid";
import { theme } from "@nestri/www/ui/theme";
import { useAccount } from "@nestri/www/providers/account";
import { TeamContext } from "@nestri/www/providers/context";
import { Match, ParentProps, Show, Switch, useContext } from "solid-js";
const PageWrapper = styled("div", {
base: {
minHeight: "100dvh",
// paddingBottom: "4rem",
backgroundColor: theme.color.background.d200
}
})
const NestriLogo = styled("svg", {
base: {
height: 28,
width: 28,
}
})
const NestriLogoBig = styled("svg", {
base: {
height: 38,
width: 38,
}
})
const LineSvg = styled("svg", {
base: {
width: 26,
height: 26,
color: theme.color.grayAlpha.d300
}
})
const LogoName = styled("svg", {
base: {
height: 18,
color: theme.color.d1000.grayAlpha
}
})
const Link = styled(A, {
base: {
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 2
}
})
const TeamRoot = styled("div", {
base: {
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8
}
})
const LogoRoot = styled("div", {
base: {
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}
})
const TeamLabel = styled("span", {
base: {
letterSpacing: -0.5,
fontSize: theme.font.size.base,
fontFamily: theme.font.family.heading,
fontWeight: theme.font.weight.semibold,
color: theme.color.gray.d900
}
})
const Badge = styled("div", {
base: {
height: 20,
fontSize: 11,
lineHeight: 1,
color: "#FFF",
padding: "0 6px",
letterSpacing: 0.2,
borderRadius: 9999,
alignItems: "center",
display: "inline-flex",
whiteSpace: "pre-wrap",
justifyContent: "center",
fontFeatureSettings: `"tnum"`,
fontVariantNumeric: "tabular-nums",
}
})
const DropIcon = styled("svg", {
base: {
height: 14,
width: 14,
marginLeft: -4,
color: theme.color.grayAlpha.d800
}
})
const AvatarImg = styled("img", {
base: {
height: 32,
width: 32,
borderRadius: 9999
}
})
const RightRoot = styled("div", {
base: {
marginLeft: "auto",
display: "flex",
gap: theme.space["4"],
alignItems: "center",
justifyContent: "center",
}
})
const NavRoot = styled("div", {
base: {
display: "flex",
height: "100%",
alignItems: "center",
justifyContent: "center",
gap: theme.space["4"],
}
})
const NavLink = styled(A, {
base: {
color: "#FFF",
textDecoration: "none",
height: 32,
padding: "0 8px",
display: "flex",
justifyContent: "center",
alignItems: "center",
borderRadius: 8,
gap: theme.space["2"],
lineHeight: 1.5,
fontSize: theme.font.size.sm,
fontWeight: theme.font.weight.regular,
transition: "all 0.3s cubic-bezier(0.4,0,0.2,1)",
// ":hover": {
// color: theme.color.d1000.gray
// }
}
})
const NavWrapper = styled("div", {
base: {
// borderBottom: "1px solid white",
zIndex: 10,
position: "fixed",
// backdropFilter: "saturate(60%) blur(3px)",
height: theme.headerHeight.root,
transition: "all 0.3s cubic-bezier(0.4,0,0.2,1)",
width: "100%",
backgroundColor: "transparent"
}
})
const Background = styled("div", {
base: {
background: theme.color.headerGradient,
zIndex: 1,
height: 180,
width: "100%",
position: "fixed",
pointerEvents: "none"
}
})
const Nav = styled("nav", {
base: {
position: "relative",
padding: "0.75rem 1rem",
zIndex: 200,
width: "100%",
gap: "1.5rem",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}
})
export function Header(props: { whiteColor?: boolean } & ParentProps) {
const team = useContext(TeamContext)
const account = useAccount()
return (
<PageWrapper>
<NavWrapper style={{ color: props.whiteColor ? "#FFF" : theme.color.d1000.gray }} >
{/* <Background /> */}
<Nav>
<Container space="4" vertical="center">
<Show when={team}
fallback={
<Link href="/">
<NestriLogoBig
width="100%"
height="100%"
viewBox="0 0 12.8778 9.7377253"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg">
<path
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1" />
</NestriLogoBig>
<LogoName viewBox="0 0 498.05 70.508" xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" >
<g stroke-line-cap="round" fill-rule="evenodd" font-size="9pt" fill="currentColor">
<path
fill="currentColor"
pathLength="1"
d="M 261.23 41.65 L 212.402 41.65 Q 195.313 41.65 195.313 27.002 L 195.313 14.795 A 17.814 17.814 0 0 1 196.311 8.57 Q 199.443 0.146 212.402 0.146 L 283.203 0.146 L 283.203 14.844 L 217.236 14.844 Q 215.337 14.844 214.945 16.383 A 3.67 3.67 0 0 0 214.844 17.285 L 214.844 24.561 Q 214.844 27.002 217.236 27.002 L 266.113 27.002 Q 283.203 27.002 283.203 41.65 L 283.203 53.857 A 17.814 17.814 0 0 1 282.205 60.083 Q 279.073 68.506 266.113 68.506 L 195.313 68.506 L 195.313 53.809 L 261.23 53.809 A 3.515 3.515 0 0 0 262.197 53.688 Q 263.672 53.265 263.672 51.367 L 263.672 44.092 A 3.515 3.515 0 0 0 263.551 43.126 Q 263.128 41.65 261.23 41.65 Z M 185.547 53.906 L 185.547 68.506 L 114.746 68.506 Q 97.656 68.506 97.656 53.857 L 97.656 14.795 A 17.814 17.814 0 0 1 98.655 8.57 Q 101.787 0.146 114.746 0.146 L 168.457 0.146 Q 185.547 0.146 185.547 14.795 L 185.547 31.885 A 17.827 17.827 0 0 1 184.544 38.124 Q 181.621 45.972 170.174 46.538 A 36.906 36.906 0 0 1 168.457 46.582 L 117.188 46.582 L 117.236 51.465 Q 117.236 53.906 119.629 53.955 L 185.547 53.906 Z M 19.531 14.795 L 19.531 68.506 L 0 68.506 L 0 0.146 L 70.801 0.146 Q 87.891 0.146 87.891 14.795 L 87.891 68.506 L 68.359 68.506 L 68.359 17.236 Q 68.359 14.795 65.967 14.795 L 19.531 14.795 Z M 449.219 68.506 L 430.176 46.533 L 400.391 46.533 L 400.391 68.506 L 380.859 68.506 L 380.859 0.146 L 451.66 0.146 A 24.602 24.602 0 0 1 458.423 0.994 Q 466.007 3.166 468.021 10.907 A 25.178 25.178 0 0 1 468.75 17.236 L 468.75 31.885 A 18.217 18.217 0 0 1 467.887 37.73 Q 465.954 43.444 459.698 45.455 A 23.245 23.245 0 0 1 454.492 46.436 L 473.633 68.506 L 449.219 68.506 Z M 292.969 0 L 371.094 0.098 L 371.094 14.795 L 341.846 14.795 L 341.846 68.506 L 322.266 68.506 L 322.217 14.795 L 292.969 14.844 L 292.969 0 Z M 478.516 0.146 L 498.047 0.146 L 498.047 68.506 L 478.516 68.506 L 478.516 0.146 Z M 400.391 14.844 L 400.391 31.885 L 446.826 31.885 Q 448.726 31.885 449.117 30.345 A 3.67 3.67 0 0 0 449.219 29.443 L 449.219 17.285 Q 449.219 14.844 446.826 14.844 L 400.391 14.844 Z M 117.188 31.836 L 163.574 31.934 Q 165.528 31.895 165.918 30.355 A 3.514 3.514 0 0 0 166.016 29.492 L 166.016 17.236 Q 166.016 15.337 164.476 14.945 A 3.67 3.67 0 0 0 163.574 14.844 L 119.629 14.795 Q 117.188 14.795 117.188 17.188 L 117.188 31.836 Z" />
</g>
</LogoName>
</Link>
}
>
<LogoRoot>
<A href={`/${team!().slug}`} >
<NestriLogo
width={32}
height={32}
viewBox="0 0 12.8778 9.7377253"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg">
<path
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1" />
</NestriLogo>
</A>
<LineSvg
height="16"
stroke-linejoin="round"
viewBox="0 0 16 16"
width="16">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z" fill="currentColor"></path>
</LineSvg>
<TeamRoot>
<Avatar size={21} name={team!().slug} />
<TeamLabel style={{ color: props.whiteColor ? "#FFF" : theme.color.d1000.gray }}>{team!().name}</TeamLabel>
<Switch>
<Match when={team!().planType === "BYOG"}>
<Badge style={{ "background-color": theme.color.purple.d700 }}>
<span style={{ "line-height": 0 }} >BYOG</span>
</Badge>
</Match>
<Match when={team!().planType === "Hosted"}>
<Badge style={{ "background-color": theme.color.blue.d700 }}>
<span style={{ "line-height": 0 }}>Hosted</span>
</Badge>
</Match>
</Switch>
<DropIcon
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 256 256">
<path
fill="currentColor"
d="M72.61 83.06a8 8 0 0 1 1.73-8.72l48-48a8 8 0 0 1 11.32 0l48 48A8 8 0 0 1 176 88H80a8 8 0 0 1-7.39-4.94M176 168H80a8 8 0 0 0-5.66 13.66l48 48a8 8 0 0 0 11.32 0l48-48A8 8 0 0 0 176 168" />
</DropIcon>
</TeamRoot>
</LogoRoot>
</Show>
</Container>
<RightRoot>
<Show when={team}>
<NavRoot>
<NavLink href={`/${team!().slug}/machines`}>
{/* <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.5 17.5L22 22m-2-11a9 9 0 1 0-18 0a9 9 0 0 0 18 0" color="currentColor" />
</svg> */}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
<path fill="currentColor" d="M3.441 9.956a4.926 4.926 0 0 0 6.233 7.571l4.256 4.257a.773.773 0 0 0 1.169-1.007l-.075-.087l-4.217-4.218A4.927 4.927 0 0 0 3.44 9.956m13.213 6.545c-.225 1.287-.548 2.456-.952 3.454l.03.028l.124.14c.22.295.344.624.378.952a10.03 10.03 0 0 0 4.726-4.574zM12.25 16.5l2.284 2.287c.202-.6.381-1.268.53-1.992l.057-.294zm-2.936-5.45a3.38 3.38 0 1 1-4.78 4.779a3.38 3.38 0 0 1 4.78-4.78M15.45 10h-3.7a5.94 5.94 0 0 1 .892 5h2.71a26 26 0 0 0 .132-4.512zm1.507 0a28 28 0 0 1-.033 4.42l-.057.58h4.703a10.05 10.05 0 0 0 .258-5zm-2.095-7.593c.881 1.35 1.536 3.329 1.883 5.654l.062.44h4.59a10.03 10.03 0 0 0-6.109-5.958l-.304-.1zm-2.836-.405c-1.277 0-2.561 2.382-3.158 5.839c.465.16.912.38 1.331.658l5.088.001c-.54-3.809-1.905-6.498-3.261-6.498m-2.837.405A10.03 10.03 0 0 0 2.654 8.5h.995a5.92 5.92 0 0 1 3.743-.968c.322-1.858.846-3.47 1.527-4.68l.162-.275z" />
</svg>
{/* Machines */}
</NavLink>
<NavLink href={`/${team!().slug}/machines`}>
<svg style={{ "margin-bottom": "1px" }} xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 16 16">
<g fill="currentColor"><path d="M4 8a1.5 1.5 0 1 1 3 0a1.5 1.5 0 0 1-3 0m7.5-1.5a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3" />
<path d="M0 1.5A.5.5 0 0 1 .5 1h1a.5.5 0 0 1 .5.5V4h13.5a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5H2v2.5a.5.5 0 0 1-1 0V2H.5a.5.5 0 0 1-.5-.5m5.5 4a2.5 2.5 0 1 0 0 5a2.5 2.5 0 0 0 0-5M9 8a2.5 2.5 0 1 0 5 0a2.5 2.5 0 0 0-5 0" />
<path d="M3 12.5h3.5v1a.5.5 0 0 1-.5.5H3.5a.5.5 0 0 1-.5-.5zm4 1v-1h4v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5" />
</g>
</svg>
</NavLink>
</NavRoot>
</Show>
<div style={{ "margin-bottom": "2px" }} >
<Switch>
<Match when={account.current.avatarUrl} >
<AvatarImg src={account.current.avatarUrl} alt={`${account.current.name}'s avatar`} />
</Match>
<Match when={!account.current.avatarUrl}>
<Avatar size={32} name={`${account.current.name}#${account.current.discriminator}`} />
</Match>
</Switch>
</div>
</RightRoot>
</Nav>
</NavWrapper>
{props.children}
</PageWrapper>
)
}

View File

@@ -0,0 +1,414 @@
import { FullScreen, theme } from "@nestri/www/ui";
import { styled } from "@macaron-css/solid";
import { Header } from "@nestri/www/pages/team/header";
import { useSteam } from "@nestri/www/providers/steam";
import { Modal } from "@nestri/www/ui/modal";
import { createEffect, createSignal, onCleanup } from "solid-js";
import { Text } from "@nestri/www/ui/text"
import { QRCode } from "@nestri/www/ui/custom-qr";
import { globalStyle, keyframes } from "@macaron-css/core";
import { A } from "@solidjs/router";
const EmptyState = styled("div", {
base: {
padding: "0 40px",
display: "flex",
gap: 10,
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
margin: "auto"
}
})
const EmptyStateHeader = styled("h2", {
base: {
textAlign: "center",
fontSize: theme.font.size["2xl"],
fontFamily: theme.font.family.heading,
fontWeight: theme.font.weight.semibold,
letterSpacing: -0.5,
}
})
const EmptyStateSubHeader = styled("p", {
base: {
fontWeight: theme.font.weight.regular,
color: theme.color.gray.d900,
fontSize: theme.font.size["lg"],
textAlign: "center",
maxWidth: 380,
letterSpacing: -0.4,
lineHeight: 1.1,
}
})
const QRWrapper = styled("div", {
base: {
backgroundColor: theme.color.background.d100,
position: "relative",
marginBottom: 20,
textWrap: "balance",
border: `1px solid ${theme.color.gray.d400}`,
display: "flex",
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
borderRadius: 22,
padding: 20,
}
})
const SteamMobileLink = styled(A, {
base: {
textUnderlineOffset: 2,
textDecoration: "none",
color: theme.color.blue.d900,
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
gap: 1,
width: "max-content",
textTransform: "capitalize",
":hover": {
textDecoration: "underline"
}
}
})
const LogoContainer = styled("div", {
base: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
}
})
const LogoIcon = styled("svg", {
base: {
zIndex: 6,
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%,-50%)",
overflow: "hidden",
// width: "21%",
// height: "21%",
borderRadius: 17,
// ":before": {
// pointerEvents: "none",
// zIndex: 2,
// content: '',
// position: "absolute",
// inset: 0,
// borderRadius: "inherit",
// boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.02)",
// }
}
})
const LastPlayedWrapper = styled("div", {
base: {
position: "relative",
width: "100%",
justifyContent: "center",
minHeight: 700,
height: "50vw",
maxHeight: 800,
WebkitBoxPack: "center",
display: "flex",
flexDirection: "column",
":after": {
content: "",
pointerEvents: "none",
userSelect: "none",
background: `linear-gradient(to bottom,transparent,${theme.color.background.d200})`,
width: "100%",
left: 0,
position: "absolute",
bottom: -1,
zIndex: 3,
height: 320,
backdropFilter: "blur(2px)",
WebkitBackdropFilter: "blur(1px)",
WebkitMaskImage: `linear-gradient(to top,${theme.color.background.d200} 25%,transparent)`,
maskImage: `linear-gradient(to top,${theme.color.background.d200} 25%,transparent)`
}
}
})
const LastPlayedFader = styled("div", {
base: {
position: "absolute",
width: "100%",
height: "3rem",
backgroundColor: "rgba(0,0,0,.08)",
mixBlendMode: "multiply",
backdropFilter: "saturate(160%) blur(60px)",
WebkitBackdropFilter: "saturate(160%) blur(60px)",
maskImage: "linear-gradient(to top,rgba(0,0,0,.15) 0%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)",
// background: "linear-gradient(rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0.3) 50%, rgba(10, 0, 0, 0.15) 65%, rgba(0, 0, 0, 0.075) 75.5%, rgba(0, 0, 0, 0.035) 82.85%, rgba(0, 0, 0, 0.02) 88%, rgba(0, 0, 0, 0) 100%)",
opacity: 0.6,
// backdropFilter: "blur(16px)",
pointerEvents: "none",
zIndex: 1,
top: 0,
left: 0,
}
})
const BackgroundImage = styled("div", {
base: {
position: "fixed",
inset: 0,
backgroundColor: theme.color.background.d200,
backgroundSize: "cover",
zIndex: 0,
transitionDuration: "0.2s",
transitionTimingFunction: "cubic-bezier(0.4,0,0.2,1)",
transitionProperty: "opacity",
backgroundImage: "url(https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1203190/ss_97ea9b0b5a6adf3436b31d389cd18d3a647ee4bf.jpg)"
// backgroundImage: "url(https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/3373660/c4993923f605b608939536b5f2521913850b028a/ss_c4993923f605b608939536b5f2521913850b028a.jpg)"
}
})
const LogoBackgroundImage = styled("div", {
base: {
position: "fixed",
top: "2rem",
height: 240,
// width: 320,
aspectRatio: "16 / 9",
left: "50%",
transform: "translate(-50%,0%)",
backgroundSize: "cover",
zIndex: 1,
transitionDuration: "0.2s",
transitionTimingFunction: "cubic-bezier(0.4,0,0.2,1)",
transitionProperty: "opacity",
backgroundImage: "url(https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1203190/logo_2x.png)"
}
})
const Material = styled("div", {
base: {
backdropFilter: "saturate(160%) blur(60px)",
WebkitBackdropFilter: "saturate(160%) blur(60px)",
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
position: "absolute",
borderRadius: 6,
left: 0,
top: 0,
height: "100%",
width: "100%",
maskImage: "linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) 40.82%,rgba(0,0,0,.15) 50%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)",
WebkitMaskImage: "linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) 40.82%,rgba(0,0,0,.15) 50%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)"
}
})
const JoeColor = styled("div", {
base: {
backgroundColor: "rgba(0,0,0,.08)",
mixBlendMode: "multiply",
position: "absolute",
borderRadius: 6,
left: 0,
top: 0,
height: "100%",
width: "100%",
maskImage: "linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) 40.82%,rgba(0,0,0,.15) 50%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)",
WebkitMaskImage: "linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) 40.82%,rgba(0,0,0,.15) 50%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)"
}
})
const GamesContainer = styled("div", {
base: {
width: "100%",
display: "flex",
alignItems: "center",
flexDirection: "column",
zIndex: 3,
backgroundColor: theme.color.background.d200,
}
})
const GamesWrapper = styled("div", {
base: {
maxWidth: "70vw",
width: "100%",
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
margin: "0 auto",
display: "grid",
marginTop: -80,
columnGap: 12,
rowGap: 10
}
})
const GameImage = styled("img", {
base: {
width: "100%",
height: "100%",
aspectRatio: "460/215",
borderRadius: 10,
}
})
const GameSquareImage = styled("img", {
base: {
width: "100%",
height: "100%",
aspectRatio: "1/1",
borderRadius: 10,
transitionDuration: "0.2s",
transitionTimingFunction: "cubic-bezier(0.4,0,0.2,1)",
transitionProperty: "all",
cursor: "pointer",
border: `2px solid transparent`,
":hover": {
transform: "scale(1.05)",
outline: `2px solid ${theme.color.brand}`
}
}
})
const GameImageCapsule = styled("img", {
base: {
width: "100%",
height: "100%",
aspectRatio: "374/448",
borderRadius: 10,
}
})
const SteamLibrary = styled("div", {
base: {
borderTop: `1px solid ${theme.color.gray.d400}`,
padding: "20px 0",
margin: "20px auto",
width: "100%",
display: "grid",
// backgroundColor: "red",
maxWidth: "70vw",
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
columnGap: 12,
rowGap: 10,
}
})
const SteamLibraryTitle = styled("h3", {
base: {
textAlign: "left",
fontFamily: theme.font.family.heading,
fontWeight: theme.font.weight.medium,
fontSize: theme.font.size["2xl"],
letterSpacing: -0.7,
gridColumn: "1/-1",
marginBottom: 20,
}
})
export function HomeRoute() {
// const steam = useSteam();
// const [loginUrl, setLoginUrl] = createSignal<string | null>(null);
// const [loginStatus, setLoginStatus] = createSignal<string | null>("Not connected");
// const [userData, setUserData] = createSignal<{ username?: string, steamId?: string } | null>(null);
// createEffect(async () => {
// // Connect to the Steam login stream
// const steamConnection = await steam.client.login.connect();
// // Set up event listeners for different event types
// const urlUnsubscribe = steamConnection.addEventListener('url', (url) => {
// setLoginUrl(url);
// setLoginStatus('Scan QR code with Steam mobile app');
// });
// const loginAttemptUnsubscribe = steamConnection.addEventListener('login-attempt', (data) => {
// setLoginStatus(`Logging in as ${data.username}...`);
// });
// const loginSuccessUnsubscribe = steamConnection.addEventListener('login-success', (data) => {
// setUserData(data);
// setLoginStatus(`Successfully logged in as ${data.username}`);
// });
// const loginUnsuccessfulUnsubscribe = steamConnection.addEventListener('login-unsuccessful', (data) => {
// setLoginStatus(`Login failed: ${data.error}`);
// });
// const loggedOffUnsubscribe = steamConnection.addEventListener('logged-off', (data) => {
// setLoginStatus(`Logged out of Steam: ${data.reason}`);
// setUserData(null);
// });
// onCleanup(() => {
// urlUnsubscribe();
// loginAttemptUnsubscribe();
// loginSuccessUnsubscribe();
// loginUnsuccessfulUnsubscribe();
// loggedOffUnsubscribe();
// steamConnection.disconnect();
// });
// })
return (
<>
<Header whiteColor>
<FullScreen >
<EmptyState
style={{
"--nestri-qr-dot-color": theme.color.d1000.gray,
"--nestri-body-background": theme.color.gray.d100
}}
>
<QRWrapper>
<LogoContainer>
<LogoIcon
xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16">
<g fill="currentColor">
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" /><path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
</g>
</LogoIcon>
</LogoContainer>
<QRCode
uri={"https://github.com/family/connectkit/blob/9a3c16c781d8a60853eff0c4988e22926a3f91ce"}
size={180}
ecl="M"
clearArea={true}
/>
</QRWrapper>
<EmptyStateHeader>Sign in to your Steam account</EmptyStateHeader>
<EmptyStateSubHeader>Use your Steam Mobile App to sign in via QR code.&nbsp;<SteamMobileLink href="https://store.steampowered.com/mobile" target="_blank">Learn More<svg data-testid="geist-icon" height="20" stroke-linejoin="round" viewBox="0 0 16 16" width="20" style="color: currentcolor;"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 9.75V11.25C11.5 11.3881 11.3881 11.5 11.25 11.5H4.75C4.61193 11.5 4.5 11.3881 4.5 11.25L4.5 4.75C4.5 4.61193 4.61193 4.5 4.75 4.5H6.25H7V3H6.25H4.75C3.7835 3 3 3.7835 3 4.75V11.25C3 12.2165 3.7835 13 4.75 13H11.25C12.2165 13 13 12.2165 13 11.25V9.75V9H11.5V9.75ZM8.5 3H9.25H12.2495C12.6637 3 12.9995 3.33579 12.9995 3.75V6.75V7.5H11.4995V6.75V5.56066L8.53033 8.52978L8 9.06011L6.93934 7.99945L7.46967 7.46912L10.4388 4.5H9.25H8.5V3Z" fill="currentColor"></path></svg></SteamMobileLink></EmptyStateSubHeader>
</EmptyState>
{/* <LastPlayedWrapper>
<LastPlayedFader />
<LogoBackgroundImage />
<BackgroundImage />
<Material />
<JoeColor />
</LastPlayedWrapper> */}
{/* <GamesContainer>
<GamesWrapper>
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2024/05/15/acshadows-1715789601294.jpg" />
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2022/09/22/slime-rancher-2-button-02-1663890048548.jpg" />
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2023/05/19/cataclismo-button-1684532710313.jpg" />
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2024/03/27/marvelrivals-1711557092104.jpg" />
</GamesWrapper>
<SteamLibrary>
<SteamLibraryTitle>Games we think you will like</SteamLibraryTitle>
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2625420/hero_capsule.jpg?t=1742853642" />
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2486740/hero_capsule.jpg?t=1742596243" />
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/870780/hero_capsule.jpg?t=1737800535" />
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2050650/hero_capsule.jpg?t=1737800535" />
</SteamLibrary>
</GamesContainer> */}
</FullScreen>
</Header>
</>
)
}

View File

@@ -0,0 +1,69 @@
import { HomeRoute } from "./home";
import { useOpenAuth } from "@openauthjs/solid";
import { Route, useParams } from "@solidjs/router";
import { ApiProvider } from "@nestri/www/providers/api";
import { SteamRoute } from "@nestri/www/pages/team/steam";
import { ZeroProvider } from "@nestri/www/providers/zero";
import { TeamContext } from "@nestri/www/providers/context";
import { SteamProvider } from "@nestri/www/providers/steam";
import { createEffect, createMemo, Match, Switch } from "solid-js";
import { NotAllowed, NotFound } from "@nestri/www/pages/not-found";
import { useAccount, useStorage } from "@nestri/www/providers/account";
export const TeamRoute = (
<Route
component={(props) => {
const params = useParams();
const account = useAccount();
const storage = useStorage();
const openauth = useOpenAuth();
const team = createMemo(() =>
account.current.teams.find(
(item) => item.slug === params.teamSlug,
),
);
createEffect(() => {
const t = team();
if (!t) return;
storage.set("team", t.id);
});
createEffect(() => {
const teamSlug = params.teamSlug;
for (const item of Object.values(account.all)) {
for (const team of item.teams) {
if (team.slug === teamSlug && item.id !== openauth.subject!.id) {
openauth.switch(item.email);
}
}
}
})
return (
<Switch>
<Match when={!team()}>
TODO: Add a public page for (other) teams
<NotAllowed header />
</Match>
<Match when={team()}>
<TeamContext.Provider value={() => team()!}>
<ZeroProvider>
<ApiProvider>
<SteamProvider>
{props.children}
</SteamProvider>
</ApiProvider>
</ZeroProvider>
</TeamContext.Provider>
</Match>
</Switch>
)
}}
>
<Route path="" component={HomeRoute} />
<Route path="steam" component={SteamRoute} />
<Route path="*" component={() => <NotFound header />} />
</Route>
)

View File

@@ -0,0 +1,238 @@
import { Header } from "./header"
import { theme } from "@nestri/www/ui";
import { Text } from "@nestri/www/ui";
import { styled } from "@macaron-css/solid";
import { useSteam } from "@nestri/www/providers/steam";
import { createEffect, onCleanup } from "solid-js";
// FIXME: Remove this route, or move it to machines
// The idea has changed, let the user login to Steam from the / route
// Let the machines route remain different from the main page
// Why? It becomes much simpler for routing and onboarding, plus how often will you move to the machines route?
// Now it will be the home page's problem with making sure the user can download and install games on whatever machine they need/want
const Root = styled("div", {
base: {
display: "grid",
gridAutoRows: "1fr",
position: "relative",
gridTemplateRows: "0 auto",
backgroundColor: theme.color.background.d200,
minHeight: `calc(100vh - ${theme.headerHeight.root})`,
gridTemplateColumns: "minmax(24px,1fr) minmax(0,1000px) minmax(24px,1fr)"
},
});
const Section = styled("section", {
base: {
gridColumn: "1/-1",
}
})
const TitleHeader = styled("header", {
base: {
borderBottom: `1px solid ${theme.color.gray.d400}`,
color: theme.color.d1000.gray
}
})
const TitleWrapper = styled("div", {
base: {
width: "calc(1000px + calc(2 * 24px))",
paddingLeft: "24px",
display: "flex",
paddingRight: "24px",
marginLeft: "auto",
marginRight: "auto",
maxWidth: "100%"
}
})
const TitleContainer = styled("div", {
base: {
margin: "40px 0",
display: "flex",
flexDirection: "column",
gap: 16,
width: "100%",
minWidth: 0
}
})
const ButtonContainer = styled("div", {
base: {
display: "flex",
flexDirection: "row",
gap: 16,
margin: "40px 0",
}
})
const Title = styled("h1", {
base: {
lineHeight: "2.5rem",
fontWeight: theme.font.weight.semibold,
letterSpacing: "-0.069375rem",
fontSize: theme.font.size["4xl"],
textTransform: "capitalize"
}
})
const Description = styled("p", {
base: {
fontSize: theme.font.size.sm,
lineHeight: "1.25rem",
fontWeight: theme.font.weight.regular,
letterSpacing: "initial",
color: theme.color.gray.d900
}
})
const QRButton = styled("button", {
base: {
height: 40,
borderRadius: theme.borderRadius,
backgroundColor: theme.color.d1000.gray,
color: theme.color.gray.d100,
fontSize: theme.font.size.sm,
textWrap: "nowrap",
border: "1px solid transparent",
padding: `${theme.space[2]} ${theme.space[4]}`,
letterSpacing: 0.1,
lineHeight: "1.25rem",
fontFamily: theme.font.family.body,
fontWeight: theme.font.weight.medium,
cursor: "pointer",
transitionDelay: "0s, 0s",
transitionDuration: "0.2s, 0.2s",
transitionProperty: "background-color, border",
transitionTimingFunction: "ease-out, ease-out",
display: "inline-flex",
gap: theme.space[2],
alignItems: "center",
justifyContent: "center",
":disabled": {
pointerEvents: "none",
},
":hover": {
background: theme.color.hoverColor
}
}
})
const ButtonText = styled("span", {
base: {
textOverflow: "ellipsis",
whiteSpace: "nowrap",
overflow: "hidden",
}
})
const Body = styled("div", {
base: {
padding: "0 24px",
width: "calc(1000px + calc(2 * 24px))",
minWidth: "calc(100vh - 273px)",
margin: "24px auto"
}
})
const GamesContainer = styled("div", {
base: {
background: theme.color.background.d200,
padding: "32px 16px",
borderRadius: 5,
border: `1px solid ${theme.color.gray.d400}`,
display: "flex",
flexDirection: "column",
alignItems: "center",
height: "calc(100vh - 300px)",
}
})
const EmptyState = styled("div", {
base: {
height: "100%",
width: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: theme.space[8],
flexDirection: "column"
}
})
const SteamLogoContainer = styled("div", {
base: {
height: 60,
width: 60,
padding: 4,
borderRadius: 8,
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.color.background.d200,
border: `1px solid ${theme.color.gray.d400}`,
}
})
export function SteamRoute() {
const steam = useSteam();
createEffect(() => {
// steam.client.loginStream.connect();
// Clean up on component unmount
// onCleanup(() => {
// steam.client.loginStream.disconnect();
// });
});
return (
<>
<Header />
<Root>
<Section>
<TitleHeader>
<TitleWrapper>
<TitleContainer>
<Title>
Steam Library
</Title>
<Description>
{/* Read and write directly to databases and stores from your projects. */}
Install games directly from your Steam account to your Nestri Machine
</Description>
</TitleContainer>
<ButtonContainer>
<QRButton>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
<path fill="currentColor" d="M15.974 0C7.573 0 .682 6.479.031 14.714l8.573 3.547a4.5 4.5 0 0 1 2.552-.786c.083 0 .167.005.25.005l3.813-5.521v-.078c0-3.328 2.703-6.031 6.031-6.031s6.036 2.708 6.036 6.036a6.04 6.04 0 0 1-6.036 6.031h-.135l-5.438 3.88c0 .073.005.141.005.214c0 2.5-2.021 4.526-4.521 4.526c-2.177 0-4.021-1.563-4.443-3.635L.583 20.36c1.901 6.719 8.063 11.641 15.391 11.641c8.833 0 15.995-7.161 15.995-16s-7.161-16-15.995-16zm-5.922 24.281l-1.964-.813a3.4 3.4 0 0 0 1.755 1.667a3.404 3.404 0 0 0 4.443-1.833a3.38 3.38 0 0 0 .005-2.599a3.36 3.36 0 0 0-1.839-1.844a3.38 3.38 0 0 0-2.5-.042l2.026.839c1.276.536 1.88 2 1.349 3.276s-2 1.88-3.276 1.349zm15.219-12.406a4.025 4.025 0 0 0-4.016-4.021a4.02 4.02 0 1 0 0 8.042a4.02 4.02 0 0 0 4.016-4.021m-7.026-.005c0-1.672 1.349-3.021 3.016-3.021s3.026 1.349 3.026 3.021c0 1.667-1.359 3.021-3.026 3.021s-3.016-1.354-3.016-3.021" />
</svg>
<ButtonText>
Connect Steam
</ButtonText>
</QRButton>
</ButtonContainer>
</TitleWrapper>
</TitleHeader>
<Body>
<GamesContainer>
<EmptyState>
<SteamLogoContainer>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<path fill="currentColor" d="M15.974 0C7.573 0 .682 6.479.031 14.714l8.573 3.547a4.5 4.5 0 0 1 2.552-.786c.083 0 .167.005.25.005l3.813-5.521v-.078c0-3.328 2.703-6.031 6.031-6.031s6.036 2.708 6.036 6.036a6.04 6.04 0 0 1-6.036 6.031h-.135l-5.438 3.88c0 .073.005.141.005.214c0 2.5-2.021 4.526-4.521 4.526c-2.177 0-4.021-1.563-4.443-3.635L.583 20.36c1.901 6.719 8.063 11.641 15.391 11.641c8.833 0 15.995-7.161 15.995-16s-7.161-16-15.995-16zm-5.922 24.281l-1.964-.813a3.4 3.4 0 0 0 1.755 1.667a3.404 3.404 0 0 0 4.443-1.833a3.38 3.38 0 0 0 .005-2.599a3.36 3.36 0 0 0-1.839-1.844a3.38 3.38 0 0 0-2.5-.042l2.026.839c1.276.536 1.88 2 1.349 3.276s-2 1.88-3.276 1.349zm15.219-12.406a4.025 4.025 0 0 0-4.016-4.021a4.02 4.02 0 1 0 0 8.042a4.02 4.02 0 0 0 4.016-4.021m-7.026-.005c0-1.672 1.349-3.021 3.016-3.021s3.026 1.349 3.026 3.021c0 1.667-1.359 3.021-3.026 3.021s-3.016-1.354-3.016-3.021" />
</svg>
</SteamLogoContainer>
<Text align="center" style={{ "letter-spacing": "-0.3px" }} size="base" >
{/* After connecting your Steam account, your games will appear here */}
{/* URL: {steam.client.loginStream.loginUrl()} */}
</Text>
</EmptyState>
</GamesContainer>
</Body>
</Section>
</Root>
</>
)
}

View File

@@ -1,4 +1,4 @@
import { createStore } from "solid-js/store"; import { createStore, reconcile } from "solid-js/store";
import { makePersisted } from "@solid-primitives/storage"; import { makePersisted } from "@solid-primitives/storage";
import { ParentProps, createContext, useContext } from "solid-js"; import { ParentProps, createContext, useContext } from "solid-js";
@@ -10,7 +10,6 @@ function init() {
createStore({ createStore({
account: "", account: "",
team: "", team: "",
dummy: "",
}) })
); );
@@ -32,3 +31,73 @@ export function useStorage() {
} }
return ctx; return ctx;
} }
import { createEffect } from "solid-js";
import { useOpenAuth } from "@openauthjs/solid"
import { Team } from "@nestri/core/team/index";
import { createInitializedContext } from "../common/context";
type Storage = {
accounts: Record<string, {
id: string
email: string
avatarUrl?: string
discriminator: number
name: string;
polarCustomerID: string;
teams: Team.Info[];
}>
}
export const { use: useAccount, provider: AccountProvider } = createInitializedContext("AccountContext", () => {
const auth = useOpenAuth()
const [store, setStore] = makePersisted(
createStore<Storage>({
accounts: {},
}),
{
name: "nestri.account",
},
);
async function refresh(id: string) {
const access = await auth.access(id).catch(() => { })
if (!access) {
auth.authorize()
return
}
return await fetch(import.meta.env.VITE_API_URL + "/account", {
headers: {
authorization: `Bearer ${access}`,
},
})
.then(val => val.json())
.then(val => setStore("accounts", id, reconcile(val.data)))
}
createEffect((previous: string[]) => {
if (!Object.values(auth.all).length) {
auth.authorize()
return []
}
for (const item of Object.values(auth.all)) {
if (previous.includes(item.id)) continue
refresh(item.id)
}
return Object.keys(auth.all)
}, [] as string[])
return {
get all() {
return store.accounts
},
get current() {
return store.accounts[auth.subject!.id]
},
refresh,
get ready() {
if (!auth.subject) return false
return store.accounts[auth.subject.id] !== undefined
}
}
})

View File

@@ -0,0 +1,36 @@
import { hc } from "hono/client";
import { useTeam } from "./context";
import { useOpenAuth } from "@openauthjs/solid";
import { type app } from "@nestri/functions/api/index";
import { createInitializedContext } from "@nestri/www/common/context";
export const { use: useApi, provider: ApiProvider } = createInitializedContext(
"Api",
() => {
const team = useTeam();
const auth = useOpenAuth();
const client = hc<typeof app>(import.meta.env.VITE_API_URL, {
async fetch(...args: Parameters<typeof fetch>): Promise<Response> {
const [input, init] = args;
const request =
input instanceof Request ? input : new Request(input, init);
const headers = new Headers(request.headers);
headers.set("authorization", `Bearer ${await auth.access()}`);
headers.set("x-nestri-team", team().id);
return fetch(
new Request(request, {
...init,
headers,
}),
);
},
});
return {
client,
ready: true,
};
},
);

View File

@@ -1,226 +0,0 @@
import { type Team } from "@nestri/core/team/index";
import { makePersisted } from "@solid-primitives/storage";
import { useLocation, useNavigate } from "@solidjs/router";
import { createClient } from "@openauthjs/openauth/client";
import { createInitializedContext } from "../common/context";
import { createEffect, createMemo, onMount } from "solid-js";
import { createStore, produce, reconcile } from "solid-js/store";
interface AccountInfo {
id: string;
email: string;
name: string;
access: string;
refresh: string;
avatarUrl: string;
teams: Team.Info[];
discriminator: number;
polarCustomerID: string | null;
}
interface Storage {
accounts: Record<string, AccountInfo>;
current?: string;
}
//TODO: Fix bug where authenticator deletes auth state for no reason
export const client = createClient({
issuer: import.meta.env.VITE_AUTH_URL,
clientID: "web",
});
export const { use: useAuth, provider: AuthProvider } =
createInitializedContext("AuthContext", () => {
const [store, setStore] = makePersisted(
createStore<Storage>({
accounts: {},
}),
{
name: "radiant.auth",
},
);
const location = useLocation();
const params = createMemo(
() => new URLSearchParams(location.hash.substring(1)),
);
const accessToken = createMemo(() => params().get("access_token"));
const refreshToken = createMemo(() => params().get("refresh_token"));
createEffect(async () => {
// if (!result.current && Object.keys(store.accounts).length) {
// result.switch(Object.keys(store.accounts)[0])
// navigate("/")
// }
})
createEffect(async () => {
if (accessToken()) return;
if (Object.keys(store.accounts).length) return;
const redirect = await client.authorize(window.location.origin, "token");
window.location.href = redirect.url
});
createEffect(async () => {
const current = store.current;
const accounts = store.accounts;
if (!current) return;
const match = accounts[current];
if (match) return;
const keys = Object.keys(accounts);
if (keys.length) {
setStore("current", keys[0]);
navigate("/");
return
}
const redirect = await client.authorize(window.location.origin, "token");
window.location.href = redirect.url
});
async function refresh() {
for (const account of [...Object.values(store.accounts)]) {
if (!account.refresh) continue;
const result = await client.refresh(account.refresh, {
access: account.access,
})
if (result.err) {
console.log("error", result.err)
if ("id" in account)
setStore(produce((state) => {
delete state.accounts[account.id];
}))
continue
};
const tokens = result.tokens || {
access: account.access,
refresh: account.refresh,
}
fetch(import.meta.env.VITE_API_URL + "/account", {
headers: {
authorization: `Bearer ${tokens.access}`,
},
}).then(async (response) => {
await new Promise((resolve) => setTimeout(resolve, 10000));
if (response.ok) {
const result = await response.json();
const info = await result.data;
setStore(
"accounts",
info.id,
reconcile({
...info,
...tokens,
}),
);
}
if (!response.ok)
console.log("error from account", response.json())
setStore(
produce((state) => {
delete state.accounts[account.id];
}),
);
})
}
}
onMount(async () => {
if (refreshToken() && accessToken()) {
const result = await fetch(import.meta.env.VITE_API_URL + "/account", {
headers: {
authorization: `Bearer ${accessToken()}`,
},
}).catch(() => { })
if (result?.ok) {
const response = await result.json();
const info = await response.data;
setStore(
"accounts",
info.id,
reconcile({
...info,
access: accessToken(),
refresh: refreshToken(),
}),
);
setStore("current", info.id);
}
window.location.hash = "";
}
await refresh();
})
const navigate = useNavigate();
// const bar = useCommandBar()
// bar.register("auth", async () => {
// return [
// {
// category: "Account",
// title: "Logout",
// icon: IconLogout,
// run: async (bar) => {
// result.logout();
// setStore("current", undefined);
// navigate("/");
// bar.hide()
// },
// },
// {
// category: "Add Account",
// title: "Add Account",
// icon: IconUserAdd,
// run: async () => {
// const redir = await client.authorize(window.location.origin, "token");
// window.location.href = redir.url
// bar.hide()
// },
// },
// ...result.all()
// .filter((item) => item.id !== result.current.id)
// .map((item) => ({
// category: "Account",
// title: "Switch to " + item.email,
// icon: IconUser,
// run: async () => {
// result.switch(item.id);
// navigate("/");
// bar.hide()
// },
// })),
// ]
// })
const result = {
get current() {
return store.accounts[store.current!]!;
},
switch(accountID: string) {
setStore("current", accountID);
},
all() {
return Object.values(store.accounts);
},
refresh,
logout() {
setStore(
produce((state) => {
if (!state.current) return;
delete state.accounts[state.current];
state.current = Object.keys(state.accounts)[0];
}),
);
},
get ready() {
return Boolean(!accessToken() && store.current);
},
};
return result;
});

View File

@@ -0,0 +1,10 @@
import { Team } from "@nestri/core/team/index";
import { Accessor, createContext, useContext } from "solid-js";
export const TeamContext = createContext<Accessor<Team.Info>>();
export function useTeam() {
const context = useContext(TeamContext);
if (!context) throw new Error("No team context");
return context;
}

View File

@@ -0,0 +1,223 @@
import { useTeam } from "./context";
import { EventSource } from 'eventsource'
import { useOpenAuth } from "@openauthjs/solid";
import { createSignal, onCleanup } from "solid-js";
import { createInitializedContext } from "../common/context";
// Type definitions for the events
interface SteamEventTypes {
'url': string;
'login-attempt': { username: string };
'login-success': { username: string; steamId: string };
'login-unsuccessful': { error: string };
'logged-off': { reason: string };
}
// Type for the connection
type SteamConnection = {
addEventListener: <T extends keyof SteamEventTypes>(
event: T,
callback: (data: SteamEventTypes[T]) => void
) => () => void;
removeEventListener: <T extends keyof SteamEventTypes>(
event: T,
callback: (data: SteamEventTypes[T]) => void
) => void;
disconnect: () => void;
isConnected: () => boolean;
}
interface SteamContext {
ready: boolean;
client: {
// Regular API endpoints
whoami: () => Promise<any>;
games: () => Promise<any>;
// SSE connection for login
login: {
connect: () => SteamConnection;
};
};
}
// Create the initialized context
export const { use: useSteam, provider: SteamProvider } = createInitializedContext(
"Steam",
() => {
const team = useTeam();
const auth = useOpenAuth();
// Create the HTTP client for regular endpoints
const client = {
// Regular HTTP endpoints
whoami: async () => {
const token = await auth.access();
const response = await fetch(`${import.meta.env.VITE_STEAM_URL}/whoami`, {
headers: {
'Authorization': `Bearer ${token}`,
'x-nestri-team': team().id
}
});
return response.json();
},
games: async () => {
const token = await auth.access();
const response = await fetch(`${import.meta.env.VITE_STEAM_URL}/games`, {
headers: {
'Authorization': `Bearer ${token}`,
'x-nestri-team': team().id
}
});
return response.json();
},
// SSE connection factory for login
login: {
connect: async (): Promise<SteamConnection> => {
let eventSource: EventSource | null = null;
const [isConnected, setIsConnected] = createSignal(false);
// Store event listeners
const listeners: Record<string, Array<(data: any) => void>> = {
'url': [],
'login-attempt': [],
'login-success': [],
'login-unsuccessful': [],
'logged-off': []
};
// Method to add event listeners
const addEventListener = <T extends keyof SteamEventTypes>(
event: T,
callback: (data: SteamEventTypes[T]) => void
) => {
if (!listeners[event]) {
listeners[event] = [];
}
listeners[event].push(callback as any);
// Return a function to remove this specific listener
return () => {
removeEventListener(event, callback);
};
};
// Method to remove event listeners
const removeEventListener = <T extends keyof SteamEventTypes>(
event: T,
callback: (data: SteamEventTypes[T]) => void
) => {
if (listeners[event]) {
const index = listeners[event].indexOf(callback as any);
if (index !== -1) {
listeners[event].splice(index, 1);
}
}
};
// Initialize connection
const initConnection = async () => {
if (eventSource) {
eventSource.close();
}
try {
const token = await auth.access();
eventSource = new EventSource(`${import.meta.env.VITE_STEAM_URL}/login`, {
fetch: (input, init) =>
fetch(input, {
...init,
headers: {
...init?.headers,
'Authorization': `Bearer ${token}`,
'x-nestri-team': team().id
},
}),
});
eventSource.onopen = () => {
console.log('Connected to Steam login stream');
setIsConnected(true);
};
// Set up event handlers for all specific events
['url', 'login-attempt', 'login-success', 'login-unsuccessful', 'logged-off'].forEach((eventType) => {
eventSource!.addEventListener(eventType, (event) => {
try {
const data = JSON.parse(event.data);
console.log(`Received ${eventType} event:`, data);
// Notify all registered listeners for this event type
if (listeners[eventType]) {
listeners[eventType].forEach(callback => {
callback(data);
});
}
} catch (error) {
console.error(`Error parsing ${eventType} event data:`, error);
}
});
});
// Handle generic messages (fallback)
eventSource.onmessage = (event) => {
console.log('Received generic message:', event.data);
};
eventSource.onerror = (error) => {
console.error('Steam login stream error:', error);
setIsConnected(false);
// Attempt to reconnect after a delay
setTimeout(initConnection, 5000);
};
} catch (error) {
console.error('Failed to connect to Steam login stream:', error);
setIsConnected(false);
}
};
// Disconnection function
const disconnect = () => {
if (eventSource) {
eventSource.close();
eventSource = null;
setIsConnected(false);
console.log('Disconnected from Steam login stream');
// Clear all listeners
Object.keys(listeners).forEach(key => {
listeners[key] = [];
});
}
};
// Start the connection immediately
await initConnection();
// Create the connection interface
const connection: SteamConnection = {
addEventListener,
removeEventListener,
disconnect,
isConnected: () => isConnected()
};
// Clean up on context destruction
onCleanup(() => {
disconnect();
});
return connection;
}
}
};
return {
client,
ready: true
};
}
);

View File

@@ -0,0 +1,39 @@
import { useTeam } from "./context"
import { createEffect } from "solid-js"
import { schema } from "@nestri/zero/schema"
import { useQuery } from "@rocicorp/zero/solid"
import { useOpenAuth } from "@openauthjs/solid"
import { Query, Schema, Zero } from "@rocicorp/zero"
import { useAccount } from "@nestri/www/providers/account"
import { createInitializedContext } from "@nestri/www/common/context"
export const { use: useZero, provider: ZeroProvider } =
createInitializedContext("ZeroContext", () => {
const auth = useOpenAuth()
const account = useAccount()
const team = useTeam()
const zero = new Zero({
schema: schema,
auth: () => auth.access(),
userID: account.current.email,
storageKey: team().id,
server: import.meta.env.VITE_ZERO_URL,
})
return {
mutate: zero.mutate,
query: zero.query,
client: zero,
ready: true,
};
});
export function usePersistentQuery<TSchema extends Schema, TTable extends keyof TSchema['tables'] & string, TReturn>(querySignal: () => Query<TSchema, TTable, TReturn>) {
const team = useTeam()
//@ts-ignore
const q = () => querySignal().where("team_id", "=", team().id).where("time_deleted", "IS", null)
createEffect(() => {
q().preload()
})
return useQuery<TSchema, TTable, TReturn>(q)
}

View File

@@ -4,8 +4,10 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_URL: string readonly VITE_API_URL: string
readonly VITE_AUTH_URL: string
readonly VITE_STAGE: string readonly VITE_STAGE: string
readonly VITE_AUTH_URL: string
readonly VITE_ZERO_URL: string
readonly VITE_STEAM_URL: string
} }
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv readonly env: ImportMetaEnv

View File

@@ -0,0 +1,97 @@
const DEFAULT_COLORS = ['#6A5ACD', '#E63525', '#20B2AA', '#E87D58'];
const getModulo = (value: number, divisor: number, useEvenCheck?: number) => {
const remainder = value % divisor;
if (useEvenCheck && Math.floor(value / Math.pow(10, useEvenCheck) % 10) % 2 === 0) {
return -remainder;
}
return remainder;
};
const generateColors = (name: string, colors = DEFAULT_COLORS) => {
const hashCode = name.split('').reduce((acc, char) => {
acc = ((acc << 5) - acc) + char.charCodeAt(0);
return acc & acc;
}, 0);
const hash = Math.abs(hashCode);
const numColors = colors.length;
return Array.from({ length: 3 }, (_, index) => ({
color: colors[(hash + index) % numColors],
translateX: getModulo(hash * (index + 1), 4, 1),
translateY: getModulo(hash * (index + 1), 4, 2),
scale: 1.2 + getModulo(hash * (index + 1), 2) / 10,
rotate: getModulo(hash * (index + 1), 360, 1)
}));
};
type Props = {
name: string;
size?: number;
class?: string;
colors?: string[]
}
export default function Avatar({ class: className, name, size = 80, colors = DEFAULT_COLORS }: Props) {
const colorData = generateColors(name, colors);
const blurValue = Math.max(1, Math.min(7, size / 10));
return (
<svg
viewBox="0 0 80 80"
fill="none"
role="img"
class={className}
preserveAspectRatio="xMidYMid meet"
aria-describedby={name}
width={size}
height={size}
>
<title id={name}>{`Fallback avatar for ${name}`}</title>
<mask
id="mask__marble"
maskUnits="userSpaceOnUse"
x={0}
y={0}
width={80}
height={80}
>
<rect width={80} height={80} rx={160} fill="#FFFFFF" />
</mask>
<g mask="url(#mask__marble)">
<rect width={80} height={80} rx={160} fill={colorData[0].color} />
<path
filter="url(#prefix__filter0_f)"
d="M32.414 59.35L50.376 70.5H72.5v-71H33.728L26.5 13.381l19.057 27.08L32.414 59.35z"
fill={colorData[1].color}
transform={
`translate(${colorData[1].translateX} ${colorData[1].translateY})
rotate(${colorData[1].rotate} 40 40)
scale(${colorData[1].scale})`}
/>
<path
filter="url(#prefix__filter0_f)"
style={{ "mix-blend-mode": "overlay" }}
d="M22.216 24L0 46.75l14.108 38.129L78 86l-3.081-59.276-22.378 4.005 12.972 20.186-23.35 27.395L22.215 24z"
fill={colorData[2].color}
transform={
`translate(${colorData[2].translateX} ${colorData[2].translateY})
rotate(${colorData[2].rotate} 40 40)
scale(${colorData[2].scale})`}
/>
</g>
<defs>
<filter
id="prefix__filter0_f"
filterUnits="userSpaceOnUse"
color-interpolation-filters="s-rGB"
>
<feFlood flood-opacity={0} result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation={blurValue} result="effect1_foregroundBlur" />
</filter>
</defs>
</svg>
)
}

View File

@@ -11,6 +11,7 @@ export const Button = styled("button", {
lineHeight: "normal", lineHeight: "normal",
fontFamily: theme.font.family.heading, fontFamily: theme.font.family.heading,
textAlign: "center", textAlign: "center",
cursor: "pointer",
transitionDelay: "0s, 0s", transitionDelay: "0s, 0s",
transitionDuration: "0.2s, 0.2s", transitionDuration: "0.2s, 0.2s",
transitionProperty: "background-color, border", transitionProperty: "background-color, border",

View File

@@ -0,0 +1,160 @@
import QRCodeUtil from 'qrcode';
import { createMemo, JSXElement } from "solid-js"
const generateMatrix = (
value: string,
errorCorrectionLevel: QRCodeUtil.QRCodeErrorCorrectionLevel
) => {
const arr = Array.prototype.slice.call(
QRCodeUtil.create(value, { errorCorrectionLevel }).modules.data,
0
);
const sqrt = Math.sqrt(arr.length);
return arr.reduce(
(rows, key, index) =>
(index % sqrt === 0
? rows.push([key])
: rows[rows.length - 1].push(key)) && rows,
[]
);
};
type Props = {
ecl?: QRCodeUtil.QRCodeErrorCorrectionLevel;
size?: number;
uri: string;
clearArea?: boolean;
image?: HTMLImageElement;
imageBackground?: string;
};
export function QRCode({
ecl = 'M',
size: sizeProp = 200,
uri,
clearArea = false,
image,
imageBackground = 'transparent',
}: Props) {
const logoSize = clearArea ? 32 : 0;
const size = sizeProp - 10 * 2;
const dots = createMemo(() => {
const dots: JSXElement[] = [];
const matrix = generateMatrix(uri, ecl);
const cellSize = size / matrix.length;
let qrList = [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
];
qrList.forEach(({ x, y }) => {
const x1 = (matrix.length - 7) * cellSize * x;
const y1 = (matrix.length - 7) * cellSize * y;
for (let i = 0; i < 3; i++) {
dots.push(
<rect
id={`${i}-${x}-${y}`}
fill={
i % 2 !== 0
? 'var(--nestri-qr-background, var(--nestri-body-background))'
: 'var(--nestri-qr-dot-color)'
}
rx={(i - 2) * -5 + (i === 0 ? 2 : 3)}
ry={(i - 2) * -5 + (i === 0 ? 2 : 3)}
width={cellSize * (7 - i * 2)}
height={cellSize * (7 - i * 2)}
x={x1 + cellSize * i}
y={y1 + cellSize * i}
/>
);
}
});
if (image) {
const x1 = (matrix.length - 7) * cellSize * 1;
const y1 = (matrix.length - 7) * cellSize * 1;
dots.push(
<>
<rect
fill={imageBackground}
rx={(0 - 2) * -5 + 2}
ry={(0 - 2) * -5 + 2}
width={cellSize * (7 - 0 * 2)}
height={cellSize * (7 - 0 * 2)}
x={x1 + cellSize * 0}
y={y1 + cellSize * 0}
/>
<foreignObject
width={cellSize * (7 - 0 * 2)}
height={cellSize * (7 - 0 * 2)}
x={x1 + cellSize * 0}
y={y1 + cellSize * 0}
>
<div style={{ "border-radius": `${(0 - 2) * -5 + 2}px`, overflow: 'hidden' }}>
{image}
</div>
</foreignObject>
</>
);
}
const clearArenaSize = Math.floor((logoSize + 25) / cellSize);
const matrixMiddleStart = matrix.length / 2 - clearArenaSize / 2;
const matrixMiddleEnd = matrix.length / 2 + clearArenaSize / 2 - 1;
matrix.forEach((row: QRCodeUtil.QRCode[], i: number) => {
row.forEach((_: any, j: number) => {
if (matrix[i][j]) {
// Do not render dots under position squares
if (
!(
(i < 7 && j < 7) ||
(i > matrix.length - 8 && j < 7) ||
(i < 7 && j > matrix.length - 8)
)
) {
//if (image && i > matrix.length - 9 && j > matrix.length - 9) return;
if (
image ||
!(
i > matrixMiddleStart &&
i < matrixMiddleEnd &&
j > matrixMiddleStart &&
j < matrixMiddleEnd
)
) {
dots.push(
<circle
id={`circle-${i}-${j}`}
cx={i * cellSize + cellSize / 2}
cy={j * cellSize + cellSize / 2}
fill="var(--nestri-qr-dot-color)"
r={cellSize / 3}
/>
);
}
}
}
});
});
return dots;
}, [ecl, size, uri]);
return (
<svg
height={size}
width={size}
viewBox={`0 0 ${size} ${size}`}
style={{
width: `${size}px`,
height: `${size}px`,
}}
>
<rect fill="transparent" height={size} width={size} />
{dots()}
</svg>
);
}

View File

@@ -1,15 +1,16 @@
import { theme } from "./theme"; import { theme } from "./theme";
import { utility } from "./utility";
import { Container } from "./layout";
import { styled } from "@macaron-css/solid" import { styled } from "@macaron-css/solid"
import { CSSProperties } from "@macaron-css/core"; import { CSSProperties } from "@macaron-css/core";
import { ComponentProps, createMemo, For, JSX, Show, splitProps } from "solid-js"; import { ComponentProps, For, JSX, Show, splitProps } from "solid-js";
import { Container } from "./layout";
import { utility } from "./utility";
// FIXME: Make sure the focus ring goes to red when the input is invalid // FIXME: Make sure the focus ring goes to red when the input is invalid
export const inputStyles: CSSProperties = { export const inputStyles: CSSProperties = {
lineHeight: theme.font.lineHeight, lineHeight: theme.font.lineHeight,
appearance: "none", appearance: "none",
width: "100%",
fontSize: theme.font.size.sm, fontSize: theme.font.size.sm,
borderRadius: theme.borderRadius, borderRadius: theme.borderRadius,
padding: `0 ${theme.space[3]}`, padding: `0 ${theme.space[3]}`,
@@ -57,12 +58,7 @@ export const Root = styled("label", {
color: theme.color.gray.d900 color: theme.color.gray.d900
}, },
danger: { danger: {
color: theme.color.red.d900, color: theme.color.gray.d900,
// selectors: {
// "&:has(input)": {
// ...inputDangerFocusStyles
// }
// }
}, },
}, },
}, },
@@ -88,6 +84,12 @@ export const Input = styled("input", {
"::placeholder": { "::placeholder": {
color: theme.color.gray.d800 color: theme.color.gray.d800
}, },
selectors: {
"[data-type='url'] &": {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}
}
// ":invalid":{ // ":invalid":{
// ...inputDangerFocusStyles // ...inputDangerFocusStyles
// }, // },
@@ -121,11 +123,8 @@ export const Input = styled("input", {
export const InputRadio = styled("input", { export const InputRadio = styled("input", {
base: { base: {
padding: 0, padding: 0,
// borderRadius: 0,
WebkitAppearance: "none", WebkitAppearance: "none",
appearance: "none", appearance: "none",
/* For iOS < 15 to remove gradient background */
backgroundColor: theme.color.background.d100,
/* Not removed via appearance */ /* Not removed via appearance */
margin: 0, margin: 0,
font: "inherit", font: "inherit",
@@ -179,6 +178,7 @@ const InputLabel = styled("label", {
borderColor: theme.color.gray.d400, borderColor: theme.color.gray.d400,
color: theme.color.gray.d800, color: theme.color.gray.d800,
backgroundColor: theme.color.background.d100, backgroundColor: theme.color.background.d100,
transition: "all 0.3s cubic-bezier(0.4,0,0.2,1)",
position: "relative", position: "relative",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@@ -197,7 +197,8 @@ const InputLabel = styled("label", {
borderBottomLeftRadius: theme.borderRadius, borderBottomLeftRadius: theme.borderRadius,
}, },
":hover": { ":hover": {
backgroundColor: theme.color.background.d200, backgroundColor: theme.color.grayAlpha.d200,
color: theme.color.d1000.gray
}, },
selectors: { selectors: {
"&:has(input:checked)": { "&:has(input:checked)": {
@@ -243,9 +244,9 @@ export function FormField(props: FormFieldProps) {
} }
type SelectProps = { type SelectProps = {
ref: (element: HTMLInputElement) => void;
name: string; name: string;
value: any; value: any;
ref: (element: HTMLInputElement) => void;
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>; onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
onChange: JSX.EventHandler<HTMLInputElement, Event>; onChange: JSX.EventHandler<HTMLInputElement, Event>;
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>; onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
@@ -276,7 +277,6 @@ const Badge = styled("div", {
padding: "0 6px", padding: "0 6px",
fontSize: theme.font.size.xs fontSize: theme.font.size.xs
} }
}) })
export function Select(props: SelectProps) { export function Select(props: SelectProps) {

View File

@@ -3,18 +3,41 @@ import { styled } from "@macaron-css/solid";
export const FullScreen = styled("div", { export const FullScreen = styled("div", {
base: { base: {
inset: 0,
display: "flex", display: "flex",
position: "fixed", flexDirection: "column",
alignItems: "center", alignItems: "center",
justifyContent: "center", textAlign: "center",
backgroundColor: theme.color.background.d200, width: "100%",
justifyContent: "center"
}, },
variants: { variants: {
inset: { inset: {
none: {}, none: {},
header: { header: {
top: theme.headerHeight.root, paddingTop: `calc(1px + ${theme.headerHeight.root})`,
minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
},
},
},
})
export const Screen = styled("div", {
base: {
display: "flex",
position: "fixed",
inset: 0,
flexDirection: "column",
alignItems: "center",
textAlign: "center",
width: "100%",
justifyContent: "center"
},
variants: {
inset: {
none: {},
header: {
paddingTop: `calc(1px + ${theme.headerHeight.root})`,
minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
}, },
}, },
}, },

View File

@@ -0,0 +1,4 @@
export { HModalRoot as Root } from './modal-root';
export { HModalPanel as Panel } from './modal-panel';
export { HModalTrigger as Trigger } from './modal-trigger';
export * as Modal from "."

View File

@@ -0,0 +1,20 @@
import { Accessor, createContext, Setter, useContext } from "solid-js";
export const ModalContext = createContext<ModalContext>();
export function useModal() {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error("No modal context");
return ctx;
}
export type ModalContext = {
// core state
localId: string;
show: Accessor<boolean>;
setShow: Setter<boolean>;
onShow?: () => void;
onClose?: () => void;
closeOnBackdropClick?: boolean;
alert?: boolean;
};

View File

@@ -0,0 +1,117 @@
import { useModal } from './use-modal';
import { useModal as useModalContext } from './modal-context';
import { Accessor, ComponentProps, createEffect, createSignal, onCleanup } from 'solid-js';
export type ModalProps = Omit<ComponentProps<'dialog'>, 'open'> & {
onShow?: () => void;
onClose?: () => void;
onKeyDown?: () => void;
'bind:show': Accessor<boolean>;
closeOnBackdropClick?: boolean;
alert?: boolean;
};
export const HModalPanel = (props: ComponentProps<'dialog'>) => {
const {
activateFocusTrap,
closeModal,
deactivateFocusTrap,
showModal,
trapFocus,
wasModalBackdropClicked,
} = useModal();
const context = useModalContext();
const [panelRef, setPanelRef] = createSignal<HTMLDialogElement>();
let focusTrapRef: any = null;
createEffect(async () => {
const dialog = panelRef();
if (!dialog) return;
if (context.show()) {
// Handle iOS scroll position issue
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
let originalRAF;
if (isIOS) {
originalRAF = window.requestAnimationFrame;
window.requestAnimationFrame = () => 42;
}
await showModal(dialog);
if (isIOS && originalRAF) {
window.requestAnimationFrame = originalRAF;
}
// Setup focus trap after showing modal
focusTrapRef = await trapFocus(dialog);
activateFocusTrap(focusTrapRef);
// Trigger show callback
context.onShow?.();
} else {
await closeModal(dialog);
// Trigger close callback
context.onClose?.();
}
});
onCleanup(() => {
if (focusTrapRef) {
deactivateFocusTrap(focusTrapRef);
}
});
const handleBackdropClick = async (e: MouseEvent) => {
if (context.alert === true || context.closeOnBackdropClick === false) {
return;
}
// Only close if the backdrop itself was clicked (not content)
if (e.target instanceof HTMLDialogElement && await wasModalBackdropClicked(panelRef(), e)) {
context.setShow(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
// Prevent spacebar/enter from triggering if dialog itself is focused
if (e.target instanceof HTMLDialogElement && [' ', 'Enter'].includes(e.key)) {
e.preventDefault();
}
// Handle escape key to close modal
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
context.setShow(false);
}
// Allow other keydown handlers to run
// props.onKeyDown?.(e);
};
return (
<dialog
{...props}
id={`${context.localId}-root`}
aria-labelledby={`${context.localId}-title`}
aria-describedby={`${context.localId}-description`}
data-state={context.show() ? 'open' : 'closed'}
data-open={context.show() ? '' : undefined}
data-closed={!context.show() ? '' : undefined}
role={context.alert === true ? 'alertdialog' : 'dialog'}
onKeyDown={handleKeyDown}
ref={setPanelRef}
onClick={(e) => {
e.stopPropagation();
handleBackdropClick(e);
}}
>
{props.children}
</dialog>
);
};

View File

@@ -0,0 +1,34 @@
import { ModalContext } from './modal-context';
import { Accessor, ComponentProps, createSignal, createUniqueId, splitProps } from 'solid-js';
type ModalRootProps = {
onShow?: () => void;
onClose?: () => void;
'bind:show'?: Accessor<boolean>;
closeOnBackdropClick?: boolean;
alert?: boolean;
} & ComponentProps<'div'>;
export const HModalRoot = (props: ModalRootProps) => {
const localId = createUniqueId();
const [modalProps, divProps] = splitProps(props, [
'bind:show',
'closeOnBackdropClick',
'onShow',
'onClose',
'alert',
]);
const [defaultShowSig, setDefaultShowSig] = createSignal<boolean>(false);
const show = props["bind:show"] ?? defaultShowSig;
return (
<ModalContext.Provider value={{ ...modalProps, setShow: setDefaultShowSig, show, localId }} >
<div {...divProps}>
{props.children}
</div>
</ModalContext.Provider>
);
};

View File

@@ -0,0 +1,24 @@
import { useModal } from './modal-context';
import { ComponentProps } from 'solid-js';
export const HModalTrigger = (props: ComponentProps<"button">) => {
const modal = useModal();
const handleClick = () => {
modal.setShow((prev) => !prev);
};
return (
<button
aria-haspopup="dialog"
aria-label="Open Theme Customization Panel"
aria-expanded={modal.show()}
data-open={modal.show() ? '' : undefined}
data-closed={!modal.show() ? '' : undefined}
onClick={[handleClick, props.onClick]}
{...props}
>
{props.children}
</button>
);
};

View File

@@ -0,0 +1,131 @@
import { FocusTrap, createFocusTrap } from 'focus-trap';
export type WidthState = {
width: number | null;
};
import { enableBodyScroll, disableBodyScroll } from 'body-scroll-lock-upgrade';
export function useModal() {
/**
* Listens for animation/transition events in order to
* remove Animation-CSS-Classes after animation/transition ended.
*/
const supportClosingAnimation = (modal: HTMLDialogElement) => {
modal.dataset.closing = '';
modal.classList.add('modal-closing');
const { animationDuration, transitionDuration } = getComputedStyle(modal);
if (animationDuration !== '0s') {
modal.addEventListener(
'animationend',
(e) => {
if (e.target === modal) {
delete modal.dataset.closing;
modal.classList.remove('modal-closing');
enableBodyScroll(modal);
modal.close();
}
},
{ once: true },
);
} else if (transitionDuration !== '0s') {
modal.addEventListener(
'transitionend',
(e) => {
if (e.target === modal) {
delete modal.dataset.closing;
modal.classList.remove('modal-closing');
enableBodyScroll(modal);
modal.close();
}
},
{ once: true },
);
} else if (animationDuration === '0s' && transitionDuration === '0s') {
delete modal.dataset.closing;
modal.classList.remove('modal-closing');
enableBodyScroll(modal);
modal.close();
}
};
/**
* Traps the focus of the given Modal
* @returns FocusTrap
*/
const trapFocus = (modal: HTMLDialogElement): FocusTrap => {
return createFocusTrap(modal, { escapeDeactivates: false });
};
const activateFocusTrap = (focusTrap: FocusTrap | null) => {
try {
focusTrap?.activate();
} catch {
// Activating the focus trap throws if no tabbable elements are inside the container.
// If this is the case we are fine with not activating the focus trap.
// That's why we ignore the thrown error.
}
};
const deactivateFocusTrap = (focusTrap: FocusTrap | null) => {
focusTrap?.deactivate();
focusTrap = null;
};
/**
* Shows the given Modal.
* Applies a CSS-Class to animate the modal-showing.
* Calls the given callback that is executed after the Modal has been opened.
*/
const showModal = async (modal: HTMLDialogElement) => {
disableBodyScroll(modal, { reserveScrollBarGap: true });
modal.showModal();
};
/**
* Closes the given Modal.
* Applies a CSS-Class to animate the Modal-closing.
* Calls the given callback that is executed after the Modal has been closed.
*/
const closeModal = async (modal: HTMLDialogElement) => {
await supportClosingAnimation(modal);
};
/**
* Determines if the backdrop of the Modal has been clicked.
*/
const wasModalBackdropClicked = (modal: HTMLDialogElement | undefined, clickEvent: MouseEvent): boolean => {
if (!modal) {
return false;
}
const rect = modal.getBoundingClientRect();
const wasBackdropClicked =
rect.left > clickEvent.clientX ||
rect.right < clickEvent.clientX ||
rect.top > clickEvent.clientY ||
rect.bottom < clickEvent.clientY;
/**
* If the inside focusable elements are not prevented, such as a button it will also fire a click event.
*
* Hitting the enter or space keys on a button inside of the dialog for example, will fire a "pointer" event. In reality, it fires our onClick$ handler because we have not prevented the default behavior.
*
* This is why we check if the pointerId is -1.
**/
return (clickEvent as PointerEvent).pointerId === -1 ? false : wasBackdropClicked;
};
return {
trapFocus,
activateFocusTrap,
deactivateFocusTrap,
showModal,
closeModal,
wasModalBackdropClicked,
supportClosingAnimation,
};
}

View File

@@ -89,7 +89,7 @@ const font = {
mono_2xl: "1.375rem", mono_2xl: "1.375rem",
"2xl": "1.5rem", "2xl": "1.5rem",
"3xl": "1.875rem", "3xl": "1.875rem",
"4xl": "2.25rem", "4xl": "2rem",
"5xl": "3rem", "5xl": "3rem",
"6xl": "3.75rem", "6xl": "3.75rem",
"7xl": "4.5rem", "7xl": "4.5rem",
@@ -218,13 +218,16 @@ const light = (() => {
const brand = "#FF4F01" const brand = "#FF4F01"
const background = { const background = {
d100: '#f5f5f5', d100: 'rgba(255,255,255,0.8)',
d200: 'oklch(from #f5f5f5 calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c h)' d200: '#f4f5f6',
}; };
const headerGradient = "linear-gradient(rgba(66, 144, 243, 0.2) 0%, rgba(206, 127, 243, 0.1) 52.58%, rgba(248, 236, 215, 0) 100%)"
const contrastFg = '#ffffff'; const contrastFg = '#ffffff';
const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(0,0,0,0.16)`; const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(0,0,0,0.16)`;
const focusColor = blue.d700 const focusColor = blue.d700
const hoverColor = "hsl(0,0%,22%)"
const text = { const text = {
primary: { primary: {
@@ -257,7 +260,9 @@ const light = (() => {
focusColor, focusColor,
d1000, d1000,
brand, brand,
text text,
headerGradient,
hoverColor
}; };
})() })()
@@ -380,13 +385,15 @@ const dark = (() => {
const brand = "#FF4F01" const brand = "#FF4F01"
const background = { const background = {
d200: '#171717', d100: "rgba(255,255,255,0.04)",
d100: "oklch(from #171717 calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c h)" d200: 'rgb(19,21,23)',
}; };
const contrastFg = '#ffffff'; const contrastFg = '#ffffff';
const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(255,255,255,0.24)`; const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(255,255,255,0.24)`;
const focusColor = blue.d900 const focusColor = blue.d900
const hoverColor = "hsl(0,0%,80%)"
const headerGradient = "linear-gradient(rgba(66, 144, 243, 0.2) 0%, rgba(239, 148, 225, 0.1) 50%, rgba(191, 124, 7, 0) 100%)"
const text = { const text = {
primary: { primary: {
@@ -419,7 +426,9 @@ const dark = (() => {
focusColor, focusColor,
d1000, d1000,
text, text,
brand brand,
headerGradient,
hoverColor
}; };
})() })()

1
packages/zero/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
sync-replica*

View File

@@ -0,0 +1 @@
UPDATE zero.permissions SET permissions = '{"tables":{"member":{"row":{"select":[["allow",{"type":"correlatedSubquery","related":{"system":"permissions","correlation":{"parentField":["team_id"],"childField":["team_id"]},"subquery":{"table":"member","alias":"zsubq_members","where":{"type":"simple","left":{"type":"column","name":"email"},"right":{"type":"static","anchor":"authData","field":"sub"},"op":"="},"orderBy":[["team_id","asc"],["id","asc"]]}},"op":"EXISTS"}]],"update":{}}},"team":{"row":{"select":[["allow",{"type":"correlatedSubquery","related":{"system":"permissions","correlation":{"parentField":["id"],"childField":["team_id"]},"subquery":{"table":"member","alias":"zsubq_members","where":{"type":"simple","left":{"type":"column","name":"email"},"right":{"type":"static","anchor":"authData","field":"sub"},"op":"="},"orderBy":[["team_id","asc"],["id","asc"]]}},"op":"EXISTS"}]],"update":{}}}}}';

View File

@@ -0,0 +1,12 @@
{
"name": "@nestri/zero",
"version": "0.0.0",
"type": "module",
"dependencies": {
"@rocicorp/zero": "*",
"@nestri/core": "*"
},
"scripts": {
"dev": "zero-deploy-permissions --output-format=sql --output-file=.permissions.sql && zero-deploy-permissions && zero-cache"
}
}

Some files were not shown because too many files have changed in this diff Show More