├── .prettierignore ├── packages ├── db-fauna │ ├── resources │ │ ├── collections │ │ │ ├── Flags.fql │ │ │ ├── Tokens.fql │ │ │ ├── Users.fql │ │ │ └── Projects.fql │ │ ├── roles │ │ │ ├── CreateUserUDF.fql │ │ │ ├── LoginUserUDF.fql │ │ │ ├── CreateProjectUDF.fql │ │ │ ├── GetProjectByIdUDF.fql │ │ │ ├── CreateFlagUDF.fql │ │ │ ├── CreateTokenUDF.fql │ │ │ ├── GetFlagByIdUDF.fql │ │ │ ├── GetTokenByIdUDF.fql │ │ │ ├── GetProjectsByUserIdUDF.fql │ │ │ ├── DeleteFlagByIdUDF.fql │ │ │ ├── SetFlagEnabledUDF.fql │ │ │ ├── DeleteTokenByIdUDF.fql │ │ │ ├── GetFlagsByProjectIdUDF.fql │ │ │ ├── GetTokensByProjectIdUDF.fql │ │ │ ├── GetFlagsByProjectIdWithTokenUDF.fql │ │ │ ├── DeleteProjectByIdUDF.fql │ │ │ └── Worker.fql │ │ ├── indices │ │ │ ├── FlagsByProjectId.fql │ │ │ ├── ProjectsByUserId.fql │ │ │ ├── TokensByProjectId.fql │ │ │ ├── UserByEmail.fql │ │ │ └── TokenBySecret.fql │ │ └── functions │ │ │ ├── LoginUser.fql │ │ │ ├── CreateProject.fql │ │ │ ├── GetProjectsByUserId.fql │ │ │ ├── CreateUser.fql │ │ │ ├── GetProjectById.fql │ │ │ ├── GetFlagById.fql │ │ │ ├── DeleteFlagById.fql │ │ │ ├── GetFlagsByProjectId.fql │ │ │ ├── GetTokenById.fql │ │ │ ├── DeleteTokenById.fql │ │ │ ├── GetTokensByProjectId.fql │ │ │ ├── CreateFlag.fql │ │ │ ├── CreateToken.fql │ │ │ ├── SetFlagEnabled.fql │ │ │ ├── GetFlagsByProjectIdWithToken.fql │ │ │ └── DeleteProjectById.fql │ ├── .eslintrc.js │ ├── migrations │ │ ├── 2022-06-01T00_52_42.540Z │ │ │ ├── create-collection-Flags.fql │ │ │ ├── create-collection-Users.fql │ │ │ ├── create-collection-Tokens.fql │ │ │ ├── create-collection-Projects.fql │ │ │ ├── create-role-CreateUserUDF.fql │ │ │ ├── create-role-LoginUserUDF.fql │ │ │ ├── create-index-FlagsByProjectId.fql │ │ │ ├── create-index-ProjectsByUserId.fql │ │ │ ├── create-index-TokensByProjectId.fql │ │ │ ├── create-role-CreateProjectUDF.fql │ │ │ ├── create-role-GetProjectByIdUDF.fql │ │ │ ├── create-index-UserByEmail.fql │ │ │ ├── create-function-LoginUser.fql │ │ │ ├── create-function-GetProjectsByUserId.fql │ │ │ ├── create-function-CreateProject.fql │ │ │ ├── create-role-CreateFlagUDF.fql │ │ │ ├── create-role-CreateTokenUDF.fql │ │ │ ├── create-role-GetFlagByIdUDF.fql │ │ │ ├── create-role-GetTokenByIdUDF.fql │ │ │ ├── create-role-GetProjectsByUserIdUDF.fql │ │ │ ├── create-function-CreateUser.fql │ │ │ ├── create-function-GetProjectById.fql │ │ │ ├── create-role-DeleteFlagByIdUDF.fql │ │ │ ├── create-role-SetFlagEnabledUDF.fql │ │ │ ├── create-role-DeleteTokenByIdUDF.fql │ │ │ ├── create-function-GetFlagsByProjectId.fql │ │ │ ├── create-function-GetTokensByProjectId.fql │ │ │ ├── create-function-DeleteFlagById.fql │ │ │ ├── create-function-GetFlagById.fql │ │ │ ├── create-function-DeleteTokenById.fql │ │ │ ├── create-function-GetTokenById.fql │ │ │ ├── create-function-CreateFlag.fql │ │ │ ├── create-function-CreateToken.fql │ │ │ ├── create-role-GetFlagsByProjectIdUDF.fql │ │ │ ├── create-role-GetTokensByProjectIdUDF.fql │ │ │ ├── create-role-DeleteProjectByIdUDF.fql │ │ │ ├── create-function-SetFlagEnabled.fql │ │ │ ├── create-function-DeleteProjectById.fql │ │ │ └── create-role-Worker.fql │ │ ├── 2022-06-01T01_59_05.588Z │ │ │ ├── create-index-TokenBySecret.fql │ │ │ ├── create-function-GetFlagsByProjectIdWithToken.fql │ │ │ ├── create-role-GetFlagsByProjectIdWithTokenUDF.fql │ │ │ └── update-role-Worker.fql │ │ ├── 2022-06-01T02_08_15.618Z │ │ │ └── update-function-GetFlagsByProjectId.fql │ │ ├── 2022-06-01T02_00_48.383Z │ │ │ └── update-function-GetFlagsByProjectIdWithToken.fql │ │ ├── 2022-06-01T02_04_19.909Z │ │ │ └── update-function-GetFlagsByProjectIdWithToken.fql │ │ ├── 2022-06-01T02_05_37.002Z │ │ │ └── update-function-GetFlagsByProjectIdWithToken.fql │ │ ├── 2022-06-01T02_06_10.361Z │ │ │ └── update-function-GetFlagsByProjectIdWithToken.fql │ │ └── 2022-06-01T01_05_10.587Z │ │ │ ├── update-role-DeleteProjectByIdUDF.fql │ │ │ └── update-function-DeleteProjectById.fql │ ├── .fauna-migrate.js │ ├── tsconfig.json │ ├── package.json │ └── index.ts ├── db │ ├── .eslintrc.js │ ├── package.json │ ├── tsconfig.json │ └── index.ts ├── logger │ ├── .eslintrc.js │ ├── index.ts │ ├── package.json │ └── tsconfig.json ├── worker │ ├── .eslintrc.js │ ├── types │ │ └── wrangler-env.d.ts │ ├── wrangler.toml │ ├── wrangler.example.toml │ ├── package.json │ ├── tsconfig.json │ └── entry.worker.ts └── remix-app │ ├── .eslintrc.js │ ├── public │ └── favicon.ico │ ├── types │ ├── build.d.ts │ └── remix-env.d.ts │ ├── app │ ├── entry.client.tsx │ ├── routes │ │ ├── logout.ts │ │ ├── docs._layout.index.tsx │ │ ├── dashboard._layout.tsx │ │ ├── dashboard._layout.profile.tsx │ │ ├── docs._layout.$.tsx │ │ ├── _public.index.tsx │ │ ├── api.v1.flags.$projectId.ts │ │ ├── dashboard.project.$projectId.delete.tsx │ │ ├── dashboard.project.$projectId.flag.$flagId.delete.tsx │ │ ├── dashboard.project.$projectId.token.$tokenId.delete.tsx │ │ ├── _public.tsx │ │ ├── docs._layout.tsx │ │ ├── dashboard._layout.projects.new.tsx │ │ ├── dashboard.project.$projectId.tsx │ │ ├── dashboard.project.$projectId.new.flag.tsx │ │ ├── dashboard._layout.index.tsx │ │ ├── _public.login.tsx │ │ ├── _public.signup.tsx │ │ ├── dashboard.project.$projectId.index.tsx │ │ └── dashboard.project.$projectId.tokens.tsx │ ├── types.ts │ ├── flags.tsx │ ├── entry.server.tsx │ ├── utils.ts │ └── root.tsx │ ├── remix.config.js │ ├── tsconfig.json │ └── package.json ├── config ├── cloudflare-env │ ├── .eslintrc.js │ ├── index.d.ts │ ├── package.json │ └── tsconfig.json ├── tsconfig │ ├── README.md │ ├── package.json │ └── base.json └── eslint-config-custom │ ├── index.js │ └── package.json ├── docker-compose.yml ├── .eslintrc.js ├── docs ├── index.md └── api.md ├── patches ├── faunadb+4.5.4.patch └── @remix-run+server-runtime+0.0.0-experimental-9784dd06.patch ├── .gitignore ├── .vscode └── settings.json ├── package.json ├── .github └── workflows │ ├── deploy.yml │ └── ci.yml ├── turbo.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/collections/Flags.fql: -------------------------------------------------------------------------------- 1 | CreateCollection({ 2 | name: "Flags" 3 | }) 4 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/collections/Tokens.fql: -------------------------------------------------------------------------------- 1 | CreateCollection({ 2 | name: "Tokens" 3 | }) 4 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/collections/Users.fql: -------------------------------------------------------------------------------- 1 | CreateCollection({ 2 | name: "Users" 3 | }) 4 | -------------------------------------------------------------------------------- /packages/db/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/logger/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/worker/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/db-fauna/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/collections/Projects.fql: -------------------------------------------------------------------------------- 1 | CreateCollection({ 2 | name: "Projects" 3 | }) 4 | -------------------------------------------------------------------------------- /packages/logger/index.ts: -------------------------------------------------------------------------------- 1 | export interface Logger { 2 | captureException(error: unknown): void; 3 | } 4 | -------------------------------------------------------------------------------- /packages/remix-app/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /config/cloudflare-env/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-collection-Flags.fql: -------------------------------------------------------------------------------- 1 | CreateCollection({ 2 | "name": "Flags" 3 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-collection-Users.fql: -------------------------------------------------------------------------------- 1 | CreateCollection({ 2 | "name": "Users" 3 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-collection-Tokens.fql: -------------------------------------------------------------------------------- 1 | CreateCollection({ 2 | "name": "Tokens" 3 | }) -------------------------------------------------------------------------------- /packages/remix-app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-ebey/remix-flags/HEAD/packages/remix-app/public/favicon.ico -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-collection-Projects.fql: -------------------------------------------------------------------------------- 1 | CreateCollection({ 2 | "name": "Projects" 3 | }) -------------------------------------------------------------------------------- /config/tsconfig/README.md: -------------------------------------------------------------------------------- 1 | # `tsconfig` 2 | 3 | These are base shared `tsconfig.json`s from which all other `tsconfig.json`'s inherit from. 4 | -------------------------------------------------------------------------------- /packages/remix-app/types/build.d.ts: -------------------------------------------------------------------------------- 1 | export * from "@remix-run/dev/server-build"; 2 | export type { AppLoadContext } from "../app/types"; 3 | -------------------------------------------------------------------------------- /config/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "base.json", 7 | "remix.json" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /packages/remix-app/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrateRoot } from "react-dom/client"; 2 | import { RemixBrowser } from "@remix-run/react"; 3 | 4 | hydrateRoot(document, ); 5 | -------------------------------------------------------------------------------- /config/cloudflare-env/index.d.ts: -------------------------------------------------------------------------------- 1 | interface Env { 2 | __STATIC_CONTENT: KVNamespace; 3 | 4 | FAUNA_SECRET: string; 5 | FAUNA_URL?: string; 6 | SENTRY_DSN?: string; 7 | SESSION_SECRET: string; 8 | } 9 | -------------------------------------------------------------------------------- /packages/remix-app/types/remix-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | -------------------------------------------------------------------------------- /packages/worker/types/wrangler-env.d.ts: -------------------------------------------------------------------------------- 1 | declare var process: { 2 | env: { NODE_ENV: "development" | "production" }; 3 | }; 4 | 5 | declare module "__STATIC_CONTENT_MANIFEST" { 6 | const manifestJSON: string; 7 | export default manifestJSON; 8 | } 9 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | db: 5 | image: fauna/faunadb:latest 6 | volumes: 7 | - dbdata:/var/log/faunadb 8 | restart: always 9 | ports: 10 | - 8443:8443 11 | 12 | volumes: 13 | dbdata: 14 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-CreateUserUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "CreateUserUDF", 3 | "privileges": [{ 4 | "resource": Collection("Users"), 5 | "actions": { 6 | "create": true 7 | } 8 | }] 9 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-LoginUserUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "LoginUserUDF", 3 | "privileges": [{ 4 | "resource": Index("UserByEmail"), 5 | "actions": { 6 | "read": true 7 | } 8 | }] 9 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/CreateUserUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "CreateUserUDF", 3 | privileges: [ 4 | { 5 | resource: Collection("Users"), 6 | actions: { 7 | create: true 8 | } 9 | } 10 | ] 11 | }) 12 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/LoginUserUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "LoginUserUDF", 3 | privileges: [ 4 | { 5 | resource: Index("UserByEmail"), 6 | actions: { 7 | read: true 8 | } 9 | } 10 | ] 11 | }) 12 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/indices/FlagsByProjectId.fql: -------------------------------------------------------------------------------- 1 | CreateIndex({ 2 | name: "FlagsByProjectId", 3 | serialized: true, 4 | source: Collection("Flags"), 5 | terms: [ 6 | { 7 | field: ["data", "projectId"] 8 | } 9 | ] 10 | }) 11 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/indices/ProjectsByUserId.fql: -------------------------------------------------------------------------------- 1 | CreateIndex({ 2 | name: "ProjectsByUserId", 3 | serialized: true, 4 | source: Collection("Projects"), 5 | terms: [ 6 | { 7 | field: ["data", "userId"] 8 | } 9 | ] 10 | }) 11 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/indices/TokensByProjectId.fql: -------------------------------------------------------------------------------- 1 | CreateIndex({ 2 | name: "TokensByProjectId", 3 | serialized: true, 4 | source: Collection("Tokens"), 5 | terms: [ 6 | { 7 | field: ["data", "projectId"] 8 | } 9 | ] 10 | }) 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | // This tells ESLint to load the config from the package `eslint-config-custom` 4 | extends: ["custom"], 5 | settings: { 6 | next: { 7 | rootDir: ["apps/*/"], 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/db-fauna/.fauna-migrate.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | directories: { 3 | root: ".", 4 | resources: "resources", 5 | migrations: "migrations", 6 | children: "dbs", 7 | temp: "temp", 8 | }, 9 | collection: "migrations", 10 | }; 11 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-index-FlagsByProjectId.fql: -------------------------------------------------------------------------------- 1 | CreateIndex({ 2 | "name": "FlagsByProjectId", 3 | "serialized": true, 4 | "source": Collection("Flags"), 5 | "terms": [{ 6 | "field": ["data", "projectId"] 7 | }] 8 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-index-ProjectsByUserId.fql: -------------------------------------------------------------------------------- 1 | CreateIndex({ 2 | "name": "ProjectsByUserId", 3 | "serialized": true, 4 | "source": Collection("Projects"), 5 | "terms": [{ 6 | "field": ["data", "userId"] 7 | }] 8 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-index-TokensByProjectId.fql: -------------------------------------------------------------------------------- 1 | CreateIndex({ 2 | "name": "TokensByProjectId", 3 | "serialized": true, 4 | "source": Collection("Tokens"), 5 | "terms": [{ 6 | "field": ["data", "projectId"] 7 | }] 8 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-CreateProjectUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "CreateProjectUDF", 3 | "privileges": [{ 4 | "resource": Collection("Projects"), 5 | "actions": { 6 | "create": true 7 | } 8 | }] 9 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-GetProjectByIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "GetProjectByIdUDF", 3 | "privileges": [{ 4 | "resource": Collection("Projects"), 5 | "actions": { 6 | "read": true 7 | } 8 | }] 9 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/indices/UserByEmail.fql: -------------------------------------------------------------------------------- 1 | CreateIndex({ 2 | name: "UserByEmail", 3 | unique: true, 4 | serialized: true, 5 | source: Collection("Users"), 6 | terms: [ 7 | { 8 | field: ["data", "email"] 9 | } 10 | ] 11 | }) 12 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/CreateProjectUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "CreateProjectUDF", 3 | privileges: [ 4 | { 5 | resource: Collection("Projects"), 6 | actions: { 7 | create: true 8 | } 9 | } 10 | ] 11 | }) 12 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/GetProjectByIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "GetProjectByIdUDF", 3 | privileges: [ 4 | { 5 | resource: Collection("Projects"), 6 | actions: { 7 | read: true 8 | } 9 | } 10 | ] 11 | }) 12 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-index-UserByEmail.fql: -------------------------------------------------------------------------------- 1 | CreateIndex({ 2 | "name": "UserByEmail", 3 | "unique": true, 4 | "serialized": true, 5 | "source": Collection("Users"), 6 | "terms": [{ 7 | "field": ["data", "email"] 8 | }] 9 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/indices/TokenBySecret.fql: -------------------------------------------------------------------------------- 1 | CreateIndex({ 2 | name: "TokenBySecret", 3 | unique: true, 4 | serialized: true, 5 | source: Collection("Tokens"), 6 | terms: [ 7 | { 8 | field: ["data", "secret"] 9 | } 10 | ] 11 | }) 12 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T01_59_05.588Z/create-index-TokenBySecret.fql: -------------------------------------------------------------------------------- 1 | CreateIndex({ 2 | "name": "TokenBySecret", 3 | "unique": true, 4 | "serialized": true, 5 | "source": Collection("Tokens"), 6 | "terms": [{ 7 | "field": ["data", "secret"] 8 | }] 9 | }) -------------------------------------------------------------------------------- /packages/worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | compatibility_date = "2022-05-31" 2 | compatibility_flags = ["streams_enable_constructors"] 3 | name = "remix-flags" 4 | 5 | main = "entry.worker.ts" 6 | 7 | account_id = "574fdb1eae7e80782a805c4b92f6b626" 8 | 9 | [site] 10 | bucket = "../remix-app/public" 11 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-LoginUser.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "LoginUser", 3 | "body": Query(Lambda(["email", "password"], Login(Match(Index("UserByEmail"), Var("email")), { 4 | "password": Var("password") 5 | }))), 6 | "role": Role("LoginUserUDF") 7 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/LoginUser.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "LoginUser", 3 | body: Query(Lambda( 4 | ["email", "password"], 5 | Login( 6 | Match(Index("UserByEmail"), Var("email")), 7 | { password: Var("password")} 8 | ) 9 | )), 10 | role: Role("LoginUserUDF") 11 | }) 12 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-GetProjectsByUserId.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "GetProjectsByUserId", 3 | "body": Query(Lambda(["userId"], Map(Paginate(Match(Index("ProjectsByUserId"), Var("userId"))), Lambda(["project"], Get(Var("project")))))), 4 | "role": Role("GetProjectsByUserIdUDF") 5 | }) -------------------------------------------------------------------------------- /config/eslint-config-custom/index.js: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint/conf/eslint-all")} */ 2 | let config = { 3 | extends: ["@remix-run/eslint-config", "prettier"], 4 | ignorePatterns: ["node_modules", "build"], 5 | settings: { 6 | files: ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"], 7 | }, 8 | }; 9 | 10 | module.exports = config; 11 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/logout.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "remix"; 2 | import type { ActionFunction, LoaderFunction } from "~/types"; 3 | 4 | export let action: ActionFunction = ({ context: { session } }) => { 5 | session.unset("userId"); 6 | return redirect("/"); 7 | }; 8 | 9 | export let loader: LoaderFunction = () => redirect("/"); 10 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-CreateProject.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "CreateProject", 3 | "body": Query(Lambda(["name", "userId"], Create(Collection("Projects"), { 4 | "data": { 5 | "name": Var("name"), 6 | "userId": Var("userId") 7 | } 8 | }))), 9 | "role": Role("CreateProjectUDF") 10 | }) -------------------------------------------------------------------------------- /packages/remix-app/app/routes/docs._layout.index.tsx: -------------------------------------------------------------------------------- 1 | import { useOutletContext } from "@remix-run/react"; 2 | 3 | import type { LoaderData as OutletContext } from "./docs._layout"; 4 | 5 | export default function DocsHome() { 6 | let { html } = useOutletContext() as OutletContext; 7 | 8 | return
; 9 | } 10 | -------------------------------------------------------------------------------- /config/eslint-config-custom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-config-custom", 3 | "version": "0.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "@remix-run/eslint-config": "0.0.0-experimental-9784dd06", 8 | "eslint-config-prettier": "^8.5.0" 9 | }, 10 | "publishConfig": { 11 | "access": "public" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-CreateFlagUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "CreateFlagUDF", 3 | "privileges": [{ 4 | "resource": Function("GetProjectById"), 5 | "actions": { 6 | "call": true 7 | } 8 | }, { 9 | "resource": Collection("Flags"), 10 | "actions": { 11 | "create": true 12 | } 13 | }] 14 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-CreateTokenUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "CreateTokenUDF", 3 | "privileges": [{ 4 | "resource": Function("GetProjectById"), 5 | "actions": { 6 | "call": true 7 | } 8 | }, { 9 | "resource": Collection("Tokens"), 10 | "actions": { 11 | "create": true 12 | } 13 | }] 14 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-GetFlagByIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "GetFlagByIdUDF", 3 | "privileges": [{ 4 | "resource": Function("GetProjectById"), 5 | "actions": { 6 | "call": true 7 | } 8 | }, { 9 | "resource": Collection("Flags"), 10 | "actions": { 11 | "read": true 12 | } 13 | }] 14 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-GetTokenByIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "GetTokenByIdUDF", 3 | "privileges": [{ 4 | "resource": Function("GetProjectById"), 5 | "actions": { 6 | "call": true 7 | } 8 | }, { 9 | "resource": Collection("Tokens"), 10 | "actions": { 11 | "read": true 12 | } 13 | }] 14 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/CreateFlagUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "CreateFlagUDF", 3 | privileges: [ 4 | { 5 | resource: Function("GetProjectById"), 6 | actions: { 7 | call: true 8 | } 9 | }, 10 | { 11 | resource: Collection("Flags"), 12 | actions: { 13 | create: true 14 | } 15 | } 16 | ] 17 | }) 18 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/CreateTokenUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "CreateTokenUDF", 3 | privileges: [ 4 | { 5 | resource: Function("GetProjectById"), 6 | actions: { 7 | call: true 8 | } 9 | }, 10 | { 11 | resource: Collection("Tokens"), 12 | actions: { 13 | create: true 14 | } 15 | } 16 | ] 17 | }) 18 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/GetFlagByIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "GetFlagByIdUDF", 3 | privileges: [ 4 | { 5 | resource: Function("GetProjectById"), 6 | actions: { 7 | call: true 8 | } 9 | }, 10 | { 11 | resource: Collection("Flags"), 12 | actions: { 13 | read: true 14 | } 15 | } 16 | ] 17 | }) 18 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/GetTokenByIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "GetTokenByIdUDF", 3 | privileges: [ 4 | { 5 | resource: Function("GetProjectById"), 6 | actions: { 7 | call: true 8 | } 9 | }, 10 | { 11 | resource: Collection("Tokens"), 12 | actions: { 13 | read: true 14 | } 15 | } 16 | ] 17 | }) 18 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-GetProjectsByUserIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "GetProjectsByUserIdUDF", 3 | "privileges": [{ 4 | "resource": Index("ProjectsByUserId"), 5 | "actions": { 6 | "read": true 7 | } 8 | }, { 9 | "resource": Collection("Projects"), 10 | "actions": { 11 | "read": true 12 | } 13 | }] 14 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-CreateUser.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "CreateUser", 3 | "body": Query(Lambda(["email", "password"], Create(Collection("Users"), { 4 | "credentials": { 5 | "password": Var("password") 6 | }, 7 | "data": { 8 | "email": Var("email") 9 | } 10 | }))), 11 | "role": Role("CreateUserUDF") 12 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/GetProjectsByUserIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "GetProjectsByUserIdUDF", 3 | privileges: [ 4 | { 5 | resource: Index("ProjectsByUserId"), 6 | actions: { 7 | read: true 8 | } 9 | }, 10 | { 11 | resource: Collection("Projects"), 12 | actions: { 13 | read: true 14 | } 15 | } 16 | ] 17 | }) 18 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/CreateProject.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "CreateProject", 3 | body: Query(Lambda( 4 | ["name", "userId"], 5 | Create( 6 | Collection('Projects'), 7 | { 8 | data: { 9 | name: Var("name"), 10 | userId: Var("userId") 11 | } 12 | } 13 | ) 14 | )), 15 | role: Role("CreateProjectUDF") 16 | }) 17 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/GetProjectsByUserId.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "GetProjectsByUserId", 3 | body: Query(Lambda( 4 | ["userId"], 5 | Map( 6 | Paginate(Match(Index("ProjectsByUserId"), Var("userId"))), 7 | Lambda( 8 | ["project"], 9 | Get(Var("project")) 10 | ) 11 | ) 12 | )), 13 | role: Role("GetProjectsByUserIdUDF") 14 | }) 15 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "remix-flags | Docs" 3 | description: "Everything you need for successful feature flagging" 4 | navigation: 5 | - label: "Docs Home" 6 | to: "/docs" 7 | - label: "API" 8 | to: "/docs/api" 9 | --- 10 | 11 | # remix-flags 12 | 13 | Everything you need for successful feature flagging 14 | 15 | ## API 16 | 17 | The API is documented in at [/docs/api](/docs/api). 18 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-GetProjectById.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "GetProjectById", 3 | "body": Query(Lambda(["projectId", "userId"], Let([{ 4 | "project": Get(Ref(Collection("Projects"), Var("projectId"))) 5 | }], If(Equals(Var("userId"), Select(["data", "userId"], Var("project"))), Var("project"), false)))), 6 | "role": Role("GetProjectByIdUDF") 7 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-DeleteFlagByIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "DeleteFlagByIdUDF", 3 | "privileges": [{ 4 | "resource": Function("GetProjectById"), 5 | "actions": { 6 | "call": true 7 | } 8 | }, { 9 | "resource": Collection("Flags"), 10 | "actions": { 11 | "read": true, 12 | "delete": true 13 | } 14 | }] 15 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-SetFlagEnabledUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "SetFlagEnabledUDF", 3 | "privileges": [{ 4 | "resource": Function("GetProjectById"), 5 | "actions": { 6 | "call": true 7 | } 8 | }, { 9 | "resource": Collection("Flags"), 10 | "actions": { 11 | "read": true, 12 | "write": true 13 | } 14 | }] 15 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-DeleteTokenByIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "DeleteTokenByIdUDF", 3 | "privileges": [{ 4 | "resource": Function("GetProjectById"), 5 | "actions": { 6 | "call": true 7 | } 8 | }, { 9 | "resource": Collection("Tokens"), 10 | "actions": { 11 | "read": true, 12 | "delete": true 13 | } 14 | }] 15 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/DeleteFlagByIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "DeleteFlagByIdUDF", 3 | privileges: [ 4 | { 5 | resource: Function("GetProjectById"), 6 | actions: { 7 | call: true 8 | } 9 | }, 10 | { 11 | resource: Collection("Flags"), 12 | actions: { 13 | read: true, 14 | delete: true 15 | } 16 | } 17 | ] 18 | }) 19 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/SetFlagEnabledUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "SetFlagEnabledUDF", 3 | privileges: [ 4 | { 5 | resource: Function("GetProjectById"), 6 | actions: { 7 | call: true 8 | } 9 | }, 10 | { 11 | resource: Collection("Flags"), 12 | actions: { 13 | read: true, 14 | write: true 15 | } 16 | } 17 | ] 18 | }) 19 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/CreateUser.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "CreateUser", 3 | body: Query(Lambda( 4 | ["email", "password"], 5 | Create( 6 | Collection('Users'), 7 | { 8 | credentials: { password: Var("password") }, 9 | data: { 10 | email: Var("email") 11 | } 12 | } 13 | ) 14 | )), 15 | role: Role("CreateUserUDF") 16 | }) 17 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/DeleteTokenByIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "DeleteTokenByIdUDF", 3 | privileges: [ 4 | { 5 | resource: Function("GetProjectById"), 6 | actions: { 7 | call: true 8 | } 9 | }, 10 | { 11 | resource: Collection("Tokens"), 12 | actions: { 13 | read: true, 14 | delete: true 15 | } 16 | } 17 | ] 18 | }) 19 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-GetFlagsByProjectId.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "GetFlagsByProjectId", 3 | "body": Query(Lambda(["projectId", "userId"], If(Equals(Call("GetProjectById", [Var("projectId"), Var("userId")]), false), [], Map(Paginate(Match(Index("FlagsByProjectId"), Var("projectId"))), Lambda(["flag"], Get(Var("flag"))))))), 4 | "role": Role("GetFlagsByProjectIdUDF") 5 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-GetTokensByProjectId.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "GetTokensByProjectId", 3 | "body": Query(Lambda(["projectId", "userId"], If(Equals(Call("GetProjectById", [Var("projectId"), Var("userId")]), false), [], Map(Paginate(Match(Index("TokensByProjectId"), Var("projectId"))), Lambda(["token"], Get(Var("token"))))))), 4 | "role": Role("GetTokensByProjectIdUDF") 5 | }) -------------------------------------------------------------------------------- /packages/remix-app/remix.config.js: -------------------------------------------------------------------------------- 1 | let { flatRoutes } = require("remix-flat-routes"); 2 | 3 | /** @type {import("@remix-run/dev").AppConfig} */ 4 | let config = { 5 | serverBuildTarget: "cloudflare-workers", 6 | devServerBroadcastDelay: 1000, 7 | ignoredRouteFiles: ["**/*"], 8 | routes: async (defineRoutes) => { 9 | return flatRoutes("routes", defineRoutes); 10 | }, 11 | }; 12 | 13 | module.exports = config; 14 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T02_08_15.618Z/update-function-GetFlagsByProjectId.fql: -------------------------------------------------------------------------------- 1 | Update(Function("GetFlagsByProjectId"), { 2 | "body": Query(Lambda(["projectId", "userId"], If(Equals(Call("GetProjectById", [Var("projectId"), Var("userId")]), false), false, Map(Paginate(Match(Index("FlagsByProjectId"), Var("projectId"))), Lambda(["flag"], Get(Var("flag"))))))), 3 | "role": Role("GetFlagsByProjectIdUDF"), 4 | "data": null 5 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T01_59_05.588Z/create-function-GetFlagsByProjectIdWithToken.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "GetFlagsByProjectIdWithToken", 3 | "body": Query(Lambda(["projectId", "secret"], Let([{ 4 | "token": Get(Match(Index("TokenBySecret"), Var("secret"))) 5 | }], If(Equals(Select(["data", "projectId"], Var("token")), Var("projectId")), Var("token"), false)))), 6 | "role": Role("GetFlagsByProjectIdWithTokenUDF") 7 | }) -------------------------------------------------------------------------------- /config/cloudflare-env/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cloudflare-env", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "types": "index.d.ts", 6 | "scripts": { 7 | "lint": "eslint .", 8 | "typecheck": "tsc -b" 9 | }, 10 | "devDependencies": { 11 | "@cloudflare/workers-types": "^3.10.0", 12 | "eslint": "^8.15.0", 13 | "eslint-config-custom": "*", 14 | "tsconfig": "*", 15 | "typescript": "^4.6.4" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-DeleteFlagById.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "DeleteFlagById", 3 | "body": Query(Lambda(["flagId", "userId"], Let([{ 4 | "flag": Get(Ref(Collection("Flags"), Var("flagId"))) 5 | }], If(Equals(Call("GetProjectById", [Select(["data", "projectId"], Var("flag")), Var("userId")]), false), false, Delete(Ref(Collection("Flags"), Var("flagId"))))))), 6 | "role": Role("DeleteFlagByIdUDF") 7 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-GetFlagById.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "GetFlagById", 3 | "body": Query(Lambda(["flagId", "userId"], Let([{ 4 | "flag": Get(Ref(Collection("Flags"), Var("flagId"))) 5 | }, { 6 | "project": Call("GetProjectById", [Select(["data", "projectId"], Var("flag")), Var("userId")]) 7 | }], If(Equals(Var("project"), false), false, Var("flag"))))), 8 | "role": Role("GetFlagByIdUDF") 9 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-DeleteTokenById.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "DeleteTokenById", 3 | "body": Query(Lambda(["tokenId", "userId"], Let([{ 4 | "token": Get(Ref(Collection("Tokens"), Var("tokenId"))) 5 | }], If(Equals(Call("GetProjectById", [Select(["data", "projectId"], Var("token")), Var("userId")]), false), false, Delete(Ref(Collection("Tokens"), Var("tokenId"))))))), 6 | "role": Role("DeleteTokenByIdUDF") 7 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-GetTokenById.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "GetTokenById", 3 | "body": Query(Lambda(["tokenId", "userId"], Let([{ 4 | "token": Get(Ref(Collection("Tokens"), Var("tokenId"))) 5 | }, { 6 | "project": Call("GetProjectById", [Select(["data", "projectId"], Var("token")), Var("userId")]) 7 | }], If(Equals(Var("project"), false), false, Var("token"))))), 8 | "role": Role("GetTokenByIdUDF") 9 | }) -------------------------------------------------------------------------------- /packages/worker/wrangler.example.toml: -------------------------------------------------------------------------------- 1 | compatibility_date = "2022-05-31" 2 | compatibility_flags = ["streams_enable_constructors"] 3 | 4 | main = "entry.worker.ts" 5 | 6 | workers_dev = true 7 | 8 | [site] 9 | bucket = "../remix-app/public" 10 | 11 | [env.dev.vars] 12 | FAUNA_SECRET = "secret" 13 | # If you are using a DB hosted on fauna, you can omit FAUNA_URL 14 | FAUNA_URL = "http://localhost:8443" 15 | SENTRY_DSN="" 16 | SESSION_SECRET = "should-be-secure-in-prod" 17 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/GetProjectById.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "GetProjectById", 3 | body: Query(Lambda( 4 | ["projectId", "userId"], 5 | Let( 6 | { 7 | project: Get(Ref(Collection("Projects"), Var("projectId"))) 8 | }, 9 | If( 10 | Equals(Var("userId"), Select(["data", "userId"], Var("project"))), 11 | Var("project"), 12 | false 13 | ) 14 | ) 15 | )), 16 | role: Role("GetProjectByIdUDF") 17 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-CreateFlag.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "CreateFlag", 3 | "body": Query(Lambda(["name", "enabled", "projectId", "userId"], If(Equals(Call("GetProjectById", [Var("projectId"), Var("userId")]), false), false, Create(Collection("Flags"), { 4 | "data": { 5 | "name": Var("name"), 6 | "enabled": Var("enabled"), 7 | "projectId": Var("projectId") 8 | } 9 | })))), 10 | "role": Role("CreateFlagUDF") 11 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-CreateToken.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "CreateToken", 3 | "body": Query(Lambda(["name", "secret", "projectId", "userId"], If(Equals(Call("GetProjectById", [Var("projectId"), Var("userId")]), false), false, Create(Collection("Tokens"), { 4 | "data": { 5 | "name": Var("name"), 6 | "secret": Var("secret"), 7 | "projectId": Var("projectId") 8 | } 9 | })))), 10 | "role": Role("CreateTokenUDF") 11 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-GetFlagsByProjectIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "GetFlagsByProjectIdUDF", 3 | "privileges": [{ 4 | "resource": Function("GetProjectById"), 5 | "actions": { 6 | "call": true 7 | } 8 | }, { 9 | "resource": Index("FlagsByProjectId"), 10 | "actions": { 11 | "read": true 12 | } 13 | }, { 14 | "resource": Collection("Flags"), 15 | "actions": { 16 | "read": true 17 | } 18 | }] 19 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-GetTokensByProjectIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "GetTokensByProjectIdUDF", 3 | "privileges": [{ 4 | "resource": Function("GetProjectById"), 5 | "actions": { 6 | "call": true 7 | } 8 | }, { 9 | "resource": Index("TokensByProjectId"), 10 | "actions": { 11 | "read": true 12 | } 13 | }, { 14 | "resource": Collection("Tokens"), 15 | "actions": { 16 | "read": true 17 | } 18 | }] 19 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/GetFlagsByProjectIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "GetFlagsByProjectIdUDF", 3 | privileges: [ 4 | { 5 | resource: Function("GetProjectById"), 6 | actions: { 7 | call: true 8 | } 9 | }, 10 | { 11 | resource: Index("FlagsByProjectId"), 12 | actions: { 13 | read: true 14 | } 15 | }, 16 | { 17 | resource: Collection("Flags"), 18 | actions: { 19 | read: true 20 | } 21 | } 22 | ] 23 | }) 24 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/GetTokensByProjectIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "GetTokensByProjectIdUDF", 3 | privileges: [ 4 | { 5 | resource: Function("GetProjectById"), 6 | actions: { 7 | call: true 8 | } 9 | }, 10 | { 11 | resource: Index("TokensByProjectId"), 12 | actions: { 13 | read: true 14 | } 15 | }, 16 | { 17 | resource: Collection("Tokens"), 18 | actions: { 19 | read: true 20 | } 21 | } 22 | ] 23 | }) 24 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-DeleteProjectByIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "DeleteProjectByIdUDF", 3 | "privileges": [{ 4 | "resource": Function("GetFlagsByProjectId"), 5 | "actions": { 6 | "call": true 7 | } 8 | }, { 9 | "resource": Collection("Flags"), 10 | "actions": { 11 | "delete": true 12 | } 13 | }, { 14 | "resource": Collection("Projects"), 15 | "actions": { 16 | "read": true, 17 | "delete": true 18 | } 19 | }] 20 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T02_00_48.383Z/update-function-GetFlagsByProjectIdWithToken.fql: -------------------------------------------------------------------------------- 1 | Update(Function("GetFlagsByProjectIdWithToken"), { 2 | "body": Query(Lambda(["projectId", "secret"], Let([{ 3 | "token": Get(Match(Index("TokenBySecret"), Var("secret"))) 4 | }], If(Equals(Select(["data", "projectId"], Var("token")), Var("projectId")), Map(Paginate(Match(Index("FlagsByProjectId"), Var("projectId"))), Lambda(["flag"], Get(Var("flag")))), [])))), 5 | "role": Role("GetFlagsByProjectIdWithTokenUDF"), 6 | "data": null 7 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T02_04_19.909Z/update-function-GetFlagsByProjectIdWithToken.fql: -------------------------------------------------------------------------------- 1 | Update(Function("GetFlagsByProjectIdWithToken"), { 2 | "body": Query(Lambda(["projectId", "secret"], Let([{ 3 | "token": Get(Match(Index("TokenBySecret"), Var("secret"))) 4 | }], If(Equals(Select(["data", "projectId"], Var("token")), Var("projectId")), Map(Paginate(Match(Index("FlagsByProjectId"), Var("projectId"))), Lambda(["flag"], Get(Var("flag")))), false)))), 5 | "role": Role("GetFlagsByProjectIdWithTokenUDF"), 6 | "data": null 7 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T02_05_37.002Z/update-function-GetFlagsByProjectIdWithToken.fql: -------------------------------------------------------------------------------- 1 | Update(Function("GetFlagsByProjectIdWithToken"), { 2 | "body": Query(Lambda(["projectId", "secret"], Let([{ 3 | "token": Get(Match(Index("TokenBySecret"), Var("secret"))) 4 | }], If(Equals(Select(["data", "projectId"], Var("token")), Var("projectId")), Map(Paginate(Match(Index("FlagsByProjectId"), Var("projectId"))), Lambda(["flag"], Get(Var("flag")))), [])))), 5 | "role": Role("GetFlagsByProjectIdWithTokenUDF"), 6 | "data": null 7 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T02_06_10.361Z/update-function-GetFlagsByProjectIdWithToken.fql: -------------------------------------------------------------------------------- 1 | Update(Function("GetFlagsByProjectIdWithToken"), { 2 | "body": Query(Lambda(["projectId", "secret"], Let([{ 3 | "token": Get(Match(Index("TokenBySecret"), Var("secret"))) 4 | }], If(Equals(Select(["data", "projectId"], Var("token")), Var("projectId")), Map(Paginate(Match(Index("FlagsByProjectId"), Var("projectId"))), Lambda(["flag"], Get(Var("flag")))), false)))), 5 | "role": Role("GetFlagsByProjectIdWithTokenUDF"), 6 | "data": null 7 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/GetFlagById.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "GetFlagById", 3 | body: Query(Lambda( 4 | ["flagId", "userId"], 5 | Let( 6 | { 7 | flag: Get(Ref(Collection("Flags"), Var("flagId"))), 8 | project: Call("GetProjectById", [Select(["data", "projectId"], Var("flag")), Var("userId")]) 9 | }, 10 | If( 11 | Equals(Var("project"), false), 12 | false, 13 | Var("flag") 14 | ) 15 | ) 16 | )), 17 | role: Role("GetFlagByIdUDF") 18 | }) 19 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/DeleteFlagById.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "DeleteFlagById", 3 | body: Query(Lambda( 4 | ["flagId", "userId"], 5 | Let( 6 | { 7 | flag: Get(Ref(Collection("Flags"), Var("flagId"))) 8 | }, 9 | If( 10 | Equals(Call("GetProjectById", [Select(["data", "projectId"], Var("flag")), Var("userId")]), false), 11 | false, 12 | Delete(Ref(Collection("Flags"), Var("flagId"))) 13 | ) 14 | ) 15 | )), 16 | role: Role("DeleteFlagByIdUDF"), 17 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/GetFlagsByProjectId.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "GetFlagsByProjectId", 3 | body: Query(Lambda( 4 | ["projectId", "userId"], 5 | If( 6 | Equals(Call("GetProjectById", [Var("projectId"), Var("userId")]), false), 7 | false, 8 | Map( 9 | Paginate(Match(Index("FlagsByProjectId"), Var("projectId"))), 10 | Lambda( 11 | ["flag"], 12 | Get(Var("flag")) 13 | ) 14 | ) 15 | ) 16 | )), 17 | role: Role("GetFlagsByProjectIdUDF") 18 | }) 19 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/GetTokenById.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "GetTokenById", 3 | body: Query(Lambda( 4 | ["tokenId", "userId"], 5 | Let( 6 | { 7 | token: Get(Ref(Collection("Tokens"), Var("tokenId"))), 8 | project: Call("GetProjectById", [Select(["data", "projectId"], Var("token")), Var("userId")]) 9 | }, 10 | If( 11 | Equals(Var("project"), false), 12 | false, 13 | Var("token") 14 | ) 15 | ) 16 | )), 17 | role: Role("GetTokenByIdUDF") 18 | }) 19 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-SetFlagEnabled.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "SetFlagEnabled", 3 | "body": Query(Lambda(["flagId", "enabled", "userId"], Let([{ 4 | "flag": Get(Ref(Collection("Flags"), Var("flagId"))) 5 | }], If(Equals(Call("GetProjectById", [Select(["data", "projectId"], Var("flag")), Var("userId")]), false), false, Update(Ref(Collection("Flags"), Var("flagId")), { 6 | "data": { 7 | "enabled": Var("enabled") 8 | } 9 | }))))), 10 | "role": Role("SetFlagEnabledUDF") 11 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/DeleteTokenById.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "DeleteTokenById", 3 | body: Query(Lambda( 4 | ["tokenId", "userId"], 5 | Let( 6 | { 7 | token: Get(Ref(Collection("Tokens"), Var("tokenId"))) 8 | }, 9 | If( 10 | Equals(Call("GetProjectById", [Select(["data", "projectId"], Var("token")), Var("userId")]), false), 11 | false, 12 | Delete(Ref(Collection("Tokens"), Var("tokenId"))) 13 | ) 14 | ) 15 | )), 16 | role: Role("DeleteTokenByIdUDF"), 17 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/GetTokensByProjectId.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "GetTokensByProjectId", 3 | body: Query(Lambda( 4 | ["projectId", "userId"], 5 | If( 6 | Equals(Call("GetProjectById", [Var("projectId"), Var("userId")]), false), 7 | [], 8 | Map( 9 | Paginate(Match(Index("TokensByProjectId"), Var("projectId"))), 10 | Lambda( 11 | ["token"], 12 | Get(Var("token")) 13 | ) 14 | ) 15 | ) 16 | )), 17 | role: Role("GetTokensByProjectIdUDF") 18 | }) 19 | -------------------------------------------------------------------------------- /packages/db/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "db", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "main": "index.ts", 6 | "types": "index.ts", 7 | "scripts": { 8 | "lint": "eslint .", 9 | "typecheck": "tsc -b" 10 | }, 11 | "dependencies": { 12 | "faunadb": "^4.5.4" 13 | }, 14 | "devDependencies": { 15 | "@cloudflare/workers-types": "^3.10.0", 16 | "@fauna-labs/fauna-schema-migrate": "^2.2.1", 17 | "eslint": "^8.15.0", 18 | "eslint-config-custom": "*", 19 | "tsconfig": "*", 20 | "typescript": "^4.6.4" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/logger/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logger", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "main": "index.ts", 6 | "types": "index.ts", 7 | "scripts": { 8 | "lint": "eslint .", 9 | "typecheck": "tsc -b" 10 | }, 11 | "dependencies": { 12 | "faunadb": "^4.5.4" 13 | }, 14 | "devDependencies": { 15 | "@cloudflare/workers-types": "^3.10.0", 16 | "@fauna-labs/fauna-schema-migrate": "^2.2.1", 17 | "eslint": "^8.15.0", 18 | "eslint-config-custom": "*", 19 | "tsconfig": "*", 20 | "typescript": "^4.6.4" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /patches/faunadb+4.5.4.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/faunadb/package.json b/node_modules/faunadb/package.json 2 | index a63a0e4..3cd1b88 100644 3 | --- a/node_modules/faunadb/package.json 4 | +++ b/node_modules/faunadb/package.json 5 | @@ -22,6 +22,10 @@ 6 | "tools/printReleaseNotes.js" 7 | ], 8 | "main": "index.js", 9 | + "exports": { 10 | + "require": "./index.js", 11 | + "default": "./dist/faunadb.js" 12 | + }, 13 | "scripts": { 14 | "doc": "jsdoc -c ./jsdoc.json", 15 | "browserify": "browserify index.js --standalone faunadb -o dist/faunadb.js", -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/CreateFlag.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "CreateFlag", 3 | body: Query(Lambda( 4 | ["name", "enabled", "projectId", "userId"], 5 | If( 6 | Equals(Call("GetProjectById", [Var("projectId"), Var("userId")]), false), 7 | false, 8 | Create( 9 | Collection('Flags'), 10 | { 11 | data: { 12 | name: Var("name"), 13 | enabled: Var("enabled"), 14 | projectId: Var("projectId") 15 | } 16 | } 17 | ) 18 | ) 19 | )), 20 | role: Role("CreateFlagUDF") 21 | }) 22 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/CreateToken.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "CreateToken", 3 | body: Query(Lambda( 4 | ["name", "secret", "projectId", "userId"], 5 | If( 6 | Equals(Call("GetProjectById", [Var("projectId"), Var("userId")]), false), 7 | false, 8 | Create( 9 | Collection('Tokens'), 10 | { 11 | data: { 12 | name: Var("name"), 13 | secret: Var("secret"), 14 | projectId: Var("projectId") 15 | } 16 | } 17 | ) 18 | ) 19 | )), 20 | role: Role("CreateTokenUDF") 21 | }) 22 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-function-DeleteProjectById.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | "name": "DeleteProjectById", 3 | "body": Query(Lambda(["projectId", "userId"], Let([{ 4 | "project": Get(Ref(Collection("Projects"), Var("projectId"))) 5 | }, { 6 | "flags": Call("GetFlagsByProjectId", [Var("projectId"), Var("userId")]) 7 | }], If(Equals(Var("userId"), Select(["data", "userId"], Var("project"))), Do(Map(Var("flags"), Lambda(["flag"], Delete(Select(["ref"], Var("flag"))))), Delete(Ref(Collection("Projects"), Var("projectId")))), false)))), 8 | "role": Role("DeleteProjectByIdUDF") 9 | }) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # testing 9 | coverage 10 | 11 | # misc 12 | .DS_Store 13 | *.pem 14 | 15 | # debug 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | .pnpm-debug.log* 20 | 21 | # local env files 22 | .env.local 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | 27 | # turbo 28 | .turbo 29 | 30 | # remix 31 | .cache 32 | build 33 | 34 | # wrangler 35 | wrangler.dev.toml 36 | 37 | # typescript 38 | tsconfig.tsbuildinfo 39 | -------------------------------------------------------------------------------- /config/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "inlineSources": false, 11 | "isolatedModules": true, 12 | "moduleResolution": "node", 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "preserveWatchOutput": true, 16 | "skipLibCheck": true, 17 | "strict": true 18 | }, 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T01_59_05.588Z/create-role-GetFlagsByProjectIdWithTokenUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "GetFlagsByProjectIdWithTokenUDF", 3 | "privileges": [{ 4 | "resource": Index("TokenBySecret"), 5 | "actions": { 6 | "read": true 7 | } 8 | }, { 9 | "resource": Index("FlagsByProjectId"), 10 | "actions": { 11 | "read": true 12 | } 13 | }, { 14 | "resource": Collection("Tokens"), 15 | "actions": { 16 | "read": true 17 | } 18 | }, { 19 | "resource": Collection("Flags"), 20 | "actions": { 21 | "read": true 22 | } 23 | }] 24 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/SetFlagEnabled.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "SetFlagEnabled", 3 | body: Query(Lambda( 4 | ["flagId", "enabled", "userId"], 5 | Let( 6 | { 7 | flag: Get(Ref(Collection("Flags"), Var("flagId"))) 8 | }, 9 | If( 10 | Equals(Call("GetProjectById", [Select(["data", "projectId"], Var("flag")), Var("userId")]), false), 11 | false, 12 | Update(Ref(Collection("Flags"), Var("flagId")), { 13 | data: { 14 | enabled: Var("enabled") 15 | } 16 | }) 17 | ) 18 | ) 19 | )), 20 | role: Role("SetFlagEnabledUDF"), 21 | }) -------------------------------------------------------------------------------- /packages/db/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/base.json", 4 | "compilerOptions": { 5 | "target": "ES2019", 6 | "types": ["@cloudflare/workers-types"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "moduleResolution": "node" 18 | }, 19 | "include": ["**/*.ts"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/GetFlagsByProjectIdWithTokenUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "GetFlagsByProjectIdWithTokenUDF", 3 | privileges: [ 4 | { 5 | resource: Index("TokenBySecret"), 6 | actions: { 7 | read: true 8 | } 9 | }, 10 | { 11 | resource: Index("FlagsByProjectId"), 12 | actions: { 13 | read: true 14 | } 15 | }, 16 | { 17 | resource: Collection("Tokens"), 18 | actions: { 19 | read: true 20 | } 21 | }, 22 | { 23 | resource: Collection("Flags"), 24 | actions: { 25 | read: true 26 | } 27 | } 28 | ] 29 | }) 30 | -------------------------------------------------------------------------------- /packages/logger/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/base.json", 4 | "compilerOptions": { 5 | "target": "ES2019", 6 | "types": ["@cloudflare/workers-types"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "moduleResolution": "node" 18 | }, 19 | "include": ["**/*.ts"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /config/cloudflare-env/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/base.json", 4 | "compilerOptions": { 5 | "target": "ES2019", 6 | "types": ["@cloudflare/workers-types"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "moduleResolution": "node" 18 | }, 19 | "include": ["**/*.ts"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/db-fauna/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/base.json", 4 | "compilerOptions": { 5 | "target": "ES2019", 6 | "types": ["@cloudflare/workers-types"], 7 | "allowJs": true, 8 | "skipLibCheck": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "moduleResolution": "node" 18 | }, 19 | "include": ["**/*.ts"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "explorer.fileNesting.enabled": true, 3 | "explorer.fileNesting.patterns": { 4 | "*.ts": "${capture}.js", 5 | "*.js": "${capture}.js.map, ${capture}.min.js, ${capture}.d.ts", 6 | "*.jsx": "${capture}.js", 7 | "*.tsx": "${capture}.ts", 8 | "tsconfig.json": "tsconfig.*.json", 9 | "package.json": "package-lock.json, remix.config.js, tsconfig.json, .eslintrc.js, .gitignore, .prettierignore, turbo.json, tsconfig.tsbuildinfo" 10 | }, 11 | "search.useGlobalIgnoreFiles": true, 12 | "files.exclude": { 13 | "**/.cache": true, 14 | "**/.turbo": true, 15 | "**/build": true, 16 | "**/node_modules": true 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/GetFlagsByProjectIdWithToken.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "GetFlagsByProjectIdWithToken", 3 | body: Query(Lambda( 4 | ["projectId", "secret"], 5 | Let( 6 | { 7 | token: Get(Match(Index("TokenBySecret"), Var("secret"))) 8 | }, 9 | If( 10 | Equals(Select(["data", "projectId"], Var("token")), Var("projectId")), 11 | Map( 12 | Paginate(Match(Index("FlagsByProjectId"), Var("projectId"))), 13 | Lambda( 14 | ["flag"], 15 | Get(Var("flag")) 16 | ) 17 | ), 18 | false 19 | ) 20 | ) 21 | )), 22 | role: Role("GetFlagsByProjectIdWithTokenUDF") 23 | }) 24 | -------------------------------------------------------------------------------- /packages/db-fauna/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "db-fauna", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "main": "index.ts", 6 | "types": "index.ts", 7 | "sideEffects": false, 8 | "scripts": { 9 | "lint": "eslint .", 10 | "typecheck": "tsc -b", 11 | "migrate": "fauna-schema-migrate apply" 12 | }, 13 | "dependencies": { 14 | "db": "*", 15 | "faunadb": "4.5.4", 16 | "logger": "*", 17 | "nanoid": "^3.3.4" 18 | }, 19 | "devDependencies": { 20 | "@cloudflare/workers-types": "^3.10.0", 21 | "@fauna-labs/fauna-schema-migrate": "^2.2.1", 22 | "eslint": "^8.15.0", 23 | "eslint-config-custom": "*", 24 | "tsconfig": "*", 25 | "typescript": "^4.6.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/remix-app/app/types.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from "remix"; 2 | import type { DataFunctionArgs as RemixDataFunctionArgs } from "remix"; 3 | 4 | import type { Db } from "db"; 5 | import type { Logger } from "logger"; 6 | 7 | export interface AppLoadContext { 8 | cache: Cache; 9 | db: Db; 10 | env: Env; 11 | logger: Logger; 12 | session: Session; 13 | } 14 | 15 | export interface DataFunctionArgs 16 | extends Omit { 17 | context: AppLoadContext; 18 | } 19 | 20 | export interface ActionFunction { 21 | (args: DataFunctionArgs): null | Response | Promise; 22 | } 23 | 24 | export interface LoaderFunction { 25 | (args: DataFunctionArgs): null | Response | Promise; 26 | } 27 | -------------------------------------------------------------------------------- /packages/worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worker", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "wrangler dev --config wrangler.dev.toml --env dev --local", 7 | "lint": "eslint .", 8 | "typecheck": "tsc -b" 9 | }, 10 | "dependencies": { 11 | "@cloudflare/kv-asset-handler": "^0.2.0", 12 | "@remix-run/cloudflare": "0.0.0-experimental-9784dd06", 13 | "db-fauna": "*", 14 | "remix-app": "*", 15 | "toucan-js": "^2.6.0" 16 | }, 17 | "devDependencies": { 18 | "@cloudflare/workers-types": "^3.11.0", 19 | "cloudflare-env": "*", 20 | "eslint": "^8.17.0", 21 | "eslint-config-custom": "*", 22 | "tsconfig": "*", 23 | "typescript": "^4.7.3", 24 | "wrangler": "^2.0.14" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T01_05_10.587Z/update-role-DeleteProjectByIdUDF.fql: -------------------------------------------------------------------------------- 1 | Update(Role("DeleteProjectByIdUDF"), { 2 | "privileges": [{ 3 | "resource": Function("GetFlagsByProjectId"), 4 | "actions": { 5 | "call": true 6 | } 7 | }, { 8 | "resource": Function("GetTokensByProjectId"), 9 | "actions": { 10 | "call": true 11 | } 12 | }, { 13 | "resource": Collection("Flags"), 14 | "actions": { 15 | "delete": true 16 | } 17 | }, { 18 | "resource": Collection("Tokens"), 19 | "actions": { 20 | "delete": true 21 | } 22 | }, { 23 | "resource": Collection("Projects"), 24 | "actions": { 25 | "read": true, 26 | "delete": true 27 | } 28 | }], 29 | "data": null, 30 | "membership": null 31 | }) -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T01_05_10.587Z/update-function-DeleteProjectById.fql: -------------------------------------------------------------------------------- 1 | Update(Function("DeleteProjectById"), { 2 | "body": Query(Lambda(["projectId", "userId"], Let([{ 3 | "project": Get(Ref(Collection("Projects"), Var("projectId"))) 4 | }, { 5 | "flags": Call("GetFlagsByProjectId", [Var("projectId"), Var("userId")]) 6 | }, { 7 | "tokens": Call("GetTokensByProjectId", [Var("projectId"), Var("userId")]) 8 | }], If(Equals(Var("userId"), Select(["data", "userId"], Var("project"))), Do(Map(Var("flags"), Lambda(["flag"], Delete(Select(["ref"], Var("flag"))))), Map(Var("tokens"), Lambda(["token"], Delete(Select(["ref"], Var("token"))))), Delete(Ref(Collection("Projects"), Var("projectId")))), false)))), 9 | "role": Role("DeleteProjectByIdUDF"), 10 | "data": null 11 | }) -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/DeleteProjectByIdUDF.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "DeleteProjectByIdUDF", 3 | privileges: [ 4 | { 5 | resource: Function("GetFlagsByProjectId"), 6 | actions: { 7 | call: true 8 | } 9 | }, 10 | { 11 | resource: Function("GetTokensByProjectId"), 12 | actions: { 13 | call: true 14 | } 15 | }, 16 | { 17 | resource: Collection("Flags"), 18 | actions: { 19 | delete: true 20 | } 21 | }, 22 | { 23 | resource: Collection("Tokens"), 24 | actions: { 25 | delete: true 26 | } 27 | }, 28 | { 29 | resource: Collection("Projects"), 30 | actions: { 31 | read: true, 32 | delete: true 33 | } 34 | } 35 | ] 36 | }) 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-flags", 3 | "version": "0.0.0", 4 | "private": true, 5 | "workspaces": [ 6 | "config/*", 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "postinstall": "patch-package", 11 | "build": "npx --yes turbo run build", 12 | "clean": "npx --yes turbo run clean", 13 | "dev": "npx --yes turbo run dev --parallel", 14 | "format": "prettier --write \"**/*.{ts,tsx,js,jsx,md}\"", 15 | "lint": "npx --yes turbo run lint", 16 | "typecheck": "npx --yes turbo run typecheck", 17 | "migrate": "npx --yes turbo run migrate" 18 | }, 19 | "devDependencies": { 20 | "patch-package": "^6.4.7", 21 | "prettier": "^2.6.2", 22 | "typescript": "^4.6.4" 23 | }, 24 | "packageManager": "npm@8.5.5", 25 | "engines": { 26 | "node": ">=14.0.0", 27 | "npm": ">=7.0.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/worker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/base.json", 4 | "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], 5 | "exclude": ["node_modules", "build", "public/build"], 6 | "compilerOptions": { 7 | "target": "ES2019", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "types": ["@cloudflare/workers-types", "cloudflare-env"], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noEmit": true, 15 | "incremental": true, 16 | "esModuleInterop": true, 17 | "module": "esnext", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "react-jsx", 21 | "baseUrl": ".", 22 | "paths": { 23 | "~/*": ["./app/*"] 24 | }, 25 | "moduleResolution": "node" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/remix-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "tsconfig/base.json", 4 | "include": ["types/remix-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], 5 | "exclude": ["node_modules", "build", "public/build"], 6 | "compilerOptions": { 7 | "target": "ES2019", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "types": ["@cloudflare/workers-types", "cloudflare-env"], 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "noEmit": true, 15 | "incremental": true, 16 | "esModuleInterop": true, 17 | "module": "esnext", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "react-jsx", 21 | "baseUrl": ".", 22 | "paths": { 23 | "~/*": ["./app/*"] 24 | }, 25 | "moduleResolution": "node" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/dashboard._layout.tsx: -------------------------------------------------------------------------------- 1 | import { NavLink, Outlet } from "@remix-run/react"; 2 | 3 | export default function Projects() { 4 | return ( 5 |
6 |
7 | 22 |
23 |
24 |
25 | 26 |
27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /packages/remix-app/app/flags.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import type { PropsWithChildren } from "react"; 3 | 4 | import type { Deferrable } from "./utils"; 5 | import { InlineDeferred } from "./utils"; 6 | 7 | let flagsContext = createContext | undefined>( 8 | undefined 9 | ); 10 | 11 | export function useFlag(flag: string) { 12 | let flags = useContext(flagsContext); 13 | if (!flags) return false; 14 | return !!flags[flag]; 15 | } 16 | 17 | export function Flags({ 18 | children, 19 | flags, 20 | }: PropsWithChildren<{ flags: Deferrable> }>) { 21 | let parentFlags = useContext(flagsContext); 22 | return ( 23 | 24 | {(value) => ( 25 | 26 | {children} 27 | 28 | )} 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "remix-flags | API" 3 | description: "API docs for remix-flags" 4 | --- 5 | 6 | # API 7 | 8 | ## Authentication 9 | 10 | Go to your [dashboard](/dashboard), select a project, then `API Tokens` to create a new API token. 11 | 12 | You can use this token in two ways: 13 | 14 | Via the `token` query parameter: 15 | 16 | ```bash 17 | curl -X GET https://remix-flags.com/api/v1/flags/?token= 18 | ``` 19 | 20 | Or the `Authorization` header: 21 | 22 | ```bash 23 | curl -X GET https://remix-flags.com/api/v1/flags/ \ 24 | -H "Authorization: " 25 | ``` 26 | 27 | ## Endpoints 28 | 29 | ### `/api/v1/flags/` 30 | 31 | Returns a map of all flags for the given project. 32 | 33 | Request: 34 | 35 | ```bash 36 | curl -X GET https://remix-flags.com/api/v1/flags/?token= 37 | ``` 38 | 39 | Response: 40 | 41 | ```json 42 | { 43 | "data": { 44 | "firstFlag": true, 45 | "secondFlag": false 46 | } 47 | } 48 | ``` 49 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: 🕊 Deploy 2 | on: 3 | push: 4 | branches: ["main"] 5 | 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | name: 🕊 Deploy 10 | env: 11 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 12 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }} 13 | steps: 14 | - name: 🛑 Cancel Previous Runs 15 | uses: styfle/cancel-workflow-action@0.9.1 16 | 17 | - name: ⬇️ Checkout repo 18 | uses: actions/checkout@v3 19 | 20 | - name: 📥 Install deps 21 | uses: bahmutov/npm-install@v1 22 | 23 | - name: 📦 Build 24 | run: npm run build 25 | 26 | - name: 🛫 Migrate database 27 | run: npm run migrate 28 | env: 29 | FAUNA_ADMIN_KEY: ${{ secrets.FAUNA_ADMIN_KEY }} 30 | 31 | - name: 🚀 Publish 32 | uses: cloudflare/wrangler-action@2.0.0 33 | with: 34 | apiToken: ${{ secrets.CF_API_TOKEN }} 35 | workingDirectory: "packages/worker" 36 | command: publish 37 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/dashboard._layout.profile.tsx: -------------------------------------------------------------------------------- 1 | import { json } from "remix"; 2 | import { useLoaderData } from "@remix-run/react"; 3 | 4 | import type { LoaderFunction } from "~/types"; 5 | import { requireUserId } from "~/utils"; 6 | 7 | type LoaderData = { 8 | user: { id: string; email: string }; 9 | }; 10 | 11 | export let loader: LoaderFunction = async ({ context: { db, session } }) => { 12 | let userId = requireUserId(session, "/dashboard/profile"); 13 | 14 | let user = await db.getUserById(userId); 15 | 16 | if (!user) throw json(null, { status: 404, statusText: "Not Found" }); 17 | 18 | return json({ user }); 19 | }; 20 | 21 | export default function ProfileDashboard() { 22 | let { user } = useLoaderData() as LoaderData; 23 | 24 | return ( 25 |
26 |

Profile

27 |
28 |
Email
29 |
{user.email}
30 |
ID
31 |
{user.id}
32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/functions/DeleteProjectById.fql: -------------------------------------------------------------------------------- 1 | CreateFunction({ 2 | name: "DeleteProjectById", 3 | body: Query(Lambda( 4 | ["projectId", "userId"], 5 | Let( 6 | { 7 | project: Get(Ref(Collection("Projects"), Var("projectId"))), 8 | flags: Call("GetFlagsByProjectId", [Var("projectId"), Var("userId")]), 9 | tokens: Call("GetTokensByProjectId", [Var("projectId"), Var("userId")]) 10 | }, 11 | If( 12 | Equals(Var("userId"), Select(["data", "userId"], Var("project"))), 13 | Do( 14 | Map( 15 | Var("flags"), 16 | Lambda( 17 | ["flag"], 18 | Delete(Select(["ref"], Var("flag"))) 19 | ) 20 | ), 21 | Map( 22 | Var("tokens"), 23 | Lambda( 24 | ["token"], 25 | Delete(Select(["ref"], Var("token"))) 26 | ) 27 | ), 28 | Delete(Ref(Collection("Projects"), Var("projectId"))) 29 | ), 30 | false 31 | ) 32 | ) 33 | )), 34 | role: Role("DeleteProjectByIdUDF") 35 | }) -------------------------------------------------------------------------------- /packages/remix-app/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from "remix"; 2 | import { RemixServer } from "@remix-run/react"; 3 | import { renderToReadableStream } from "react-dom/server"; 4 | import isbot from "isbot"; 5 | 6 | import type { AppLoadContext } from "./types"; 7 | 8 | export default async function handleRequest( 9 | request: Request, 10 | responseStatusCode: number, 11 | responseHeaders: Headers, 12 | remixContext: EntryContext, 13 | { logger }: AppLoadContext 14 | ) { 15 | let body = await renderToReadableStream( 16 | , 17 | { 18 | onError(error) { 19 | responseStatusCode = 500; 20 | logger.captureException(error); 21 | }, 22 | } 23 | ); 24 | 25 | if (isbot(request.headers.get("User-Agent"))) { 26 | await body.allReady; 27 | } 28 | 29 | responseHeaders.set("Content-Type", "text/html; charset=utf-8"); 30 | responseHeaders.set("Content-Encoding", "chunked"); 31 | 32 | return new Response(body, { 33 | status: responseStatusCode, 34 | headers: responseHeaders, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/docs._layout.$.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "remix"; 2 | import { json } from "remix"; 3 | import { useLoaderData } from "@remix-run/react"; 4 | 5 | import type { LoaderFunction } from "~/types"; 6 | 7 | export let meta: MetaFunction = (args) => { 8 | let { title, description } = (args.data as LoaderData) || {}; 9 | 10 | return { title, description }; 11 | }; 12 | 13 | type LoaderData = { 14 | title: string; 15 | description: string; 16 | html: string; 17 | }; 18 | 19 | export let loader: LoaderFunction = async ({ params }) => { 20 | let docResponse = await fetch( 21 | `https://github-md.com/jacob-ebey/remix-flags/main/docs/${params["*"]}.md` 22 | ); 23 | let doc = (await docResponse.json()) as { 24 | attributes: { 25 | title: string; 26 | description: string; 27 | }; 28 | html: string; 29 | }; 30 | 31 | return json({ 32 | title: doc.attributes.title, 33 | description: doc.attributes.description, 34 | html: doc.html, 35 | }); 36 | }; 37 | 38 | export default function DocsCatchAll() { 39 | let { html } = useLoaderData() as LoaderData; 40 | 41 | return
; 42 | } 43 | -------------------------------------------------------------------------------- /packages/remix-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-app", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "main": "build/index.js", 6 | "types": "types/build.d.ts", 7 | "sideEffects": false, 8 | "scripts": { 9 | "build": "remix build", 10 | "clean": "rimraf ./.cache ./build ./public/build", 11 | "dev": "remix watch", 12 | "lint": "eslint .", 13 | "typecheck": "tsc -b" 14 | }, 15 | "dependencies": { 16 | "remix": "npm:@remix-run/cloudflare@0.0.0-experimental-9784dd06", 17 | "@remix-run/react": "0.0.0-experimental-9784dd06", 18 | "db": "*", 19 | "db-fauna": "*", 20 | "isbot": "^3.5.0", 21 | "logger": "*", 22 | "nprogress": "^0.2.0", 23 | "react": "next", 24 | "react-dom": "next" 25 | }, 26 | "devDependencies": { 27 | "@cloudflare/workers-types": "^3.10.0", 28 | "@remix-run/dev": "0.0.0-experimental-9784dd06", 29 | "@types/nprogress": "^0.2.0", 30 | "@types/react": "^18.0.9", 31 | "@types/react-dom": "^18.0.4", 32 | "cloudflare-env": "*", 33 | "eslint": "^8.15.0", 34 | "eslint-config-custom": "*", 35 | "remix-flat-routes": "^0.4.0", 36 | "rimraf": "^3.0.2", 37 | "tsconfig": "*", 38 | "typescript": "^4.6.4" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "pipeline": { 3 | "build": { 4 | "dependsOn": ["^build"], 5 | "inputs": [ 6 | "package.json", 7 | "remix.config.js", 8 | "tsconfig.json", 9 | "app/**/*.js", 10 | "app/**/*.jsx", 11 | "app/**/*.tsx", 12 | "app/**/*.ts", 13 | "app/**/*.css", 14 | "migrations/**/*.fql", 15 | "resources/**/*.fql", 16 | ".fauna-migrate.js" 17 | ], 18 | "outputs": ["build/**", "public/build/**", ".cache/**"] 19 | }, 20 | "clean": { 21 | "cache": false 22 | }, 23 | "dev": { 24 | "cache": false 25 | }, 26 | "lint": { 27 | "inputs": [ 28 | "package.json", 29 | ".eslintrc.js", 30 | "**/*.jsx", 31 | "**/*.js", 32 | "**/*.tsx", 33 | "**/*.ts", 34 | "**/*.css", 35 | "**/*.css", 36 | "**/*.md" 37 | ], 38 | "outputs": [] 39 | }, 40 | "typecheck": { 41 | "inputs": [ 42 | "package.json", 43 | "tsconfig.json", 44 | "**/*.jsx", 45 | "**/*.js", 46 | "**/*.tsx", 47 | "**/*.ts" 48 | ], 49 | "outputs": ["tsconfig.tsbuildinfo"] 50 | }, 51 | "migrate": { 52 | "cache": false 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/_public.index.tsx: -------------------------------------------------------------------------------- 1 | import { json } from "remix"; 2 | import { Link, useLoaderData } from "@remix-run/react"; 3 | 4 | import type { LoaderFunction } from "~/types"; 5 | import { getUserId } from "~/utils"; 6 | 7 | type LoaderData = { 8 | loggedIn: boolean; 9 | }; 10 | 11 | export let loader: LoaderFunction = ({ context: { session } }) => { 12 | return json({ 13 | loggedIn: !!getUserId(session), 14 | }); 15 | }; 16 | 17 | export default function Index() { 18 | let { loggedIn } = useLoaderData() as LoaderData; 19 | 20 | return ( 21 |
22 |
23 |

remix-flags

24 |

25 | Eventually* Everything you'll need for successful 26 | feature flagging 27 |

28 | {loggedIn ? ( 29 |

30 | 31 | Go to your Dashboard 32 | 33 |

34 | ) : ( 35 |

36 | 37 | Signup 38 | 39 | {" or "} 40 | 41 | Login 42 | 43 |

44 | )} 45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /packages/remix-app/app/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentProps } from "react"; 2 | import { createElement } from "react"; 3 | import type { Session } from "remix"; 4 | import { redirect } from "remix"; 5 | import { Deferred, useDeferred } from "@remix-run/react"; 6 | 7 | // @ts-ignore 8 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 9 | export interface Deferrable {} 10 | export type ResolvedDeferrable = Data extends Deferrable 11 | ? Awaited 12 | : Awaited; 13 | 14 | export function getUserId(session: Session): string | null { 15 | return session.get("userId") || null; 16 | } 17 | 18 | export function requireUserId( 19 | session: Session, 20 | redirectTo: string = "/" 21 | ): string { 22 | let userId = getUserId(session); 23 | if (!userId) throw redirect(`/login?redirect=${redirectTo}`); 24 | 25 | return userId; 26 | } 27 | 28 | export function InlineDeferred({ 29 | children, 30 | data, 31 | fallback, 32 | error, 33 | }: { 34 | data: Data; 35 | children: (value: ResolvedDeferrable) => any; 36 | } & Omit, "children" | "data">) { 37 | function DeferredWrapper() { 38 | return children(useDeferred() as ResolvedDeferrable); 39 | } 40 | return createElement(Deferred, { 41 | data, 42 | fallback, 43 | error, 44 | children: createElement(DeferredWrapper), 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: 🧪 CI 2 | on: 3 | push: 4 | branches: ["main"] 5 | pull_request: 6 | types: [opened, synchronize] 7 | 8 | jobs: 9 | lint: 10 | name: ⬣ ESLint 11 | runs-on: ubuntu-latest 12 | env: 13 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 14 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }} 15 | steps: 16 | - name: 🛑 Cancel Previous Runs 17 | uses: styfle/cancel-workflow-action@0.9.1 18 | 19 | - name: ⬇️ Checkout repo 20 | uses: actions/checkout@v3 21 | 22 | - name: ⎔ Setup node 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: 16.7.0 26 | 27 | - name: 📥 Download deps 28 | uses: bahmutov/npm-install@v1 29 | 30 | - name: 🔬 Lint 31 | run: npm run lint 32 | 33 | typecheck: 34 | name: ʦ Typecheck 35 | runs-on: ubuntu-latest 36 | env: 37 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 38 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }} 39 | steps: 40 | - name: 🛑 Cancel Previous Runs 41 | uses: styfle/cancel-workflow-action@0.9.1 42 | 43 | - name: ⬇️ Checkout repo 44 | uses: actions/checkout@v3 45 | 46 | - name: ⎔ Setup node 47 | uses: actions/setup-node@v3 48 | with: 49 | node-version: 16.7.0 50 | 51 | - name: 📥 Install turbo 52 | run: npm install -g turbo 53 | 54 | - name: 📥 Download deps 55 | uses: bahmutov/npm-install@v1 56 | 57 | - name: 🔎 Type check 58 | run: npm run typecheck 59 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/api.v1.flags.$projectId.ts: -------------------------------------------------------------------------------- 1 | import { json } from "remix"; 2 | import type { LoaderFunction } from "~/types"; 3 | 4 | export let loader: LoaderFunction = async ({ 5 | context: { cache, db, logger }, 6 | params: { projectId }, 7 | request, 8 | }) => { 9 | try { 10 | if (!projectId) { 11 | return json({ error: "No projectId in URL" }, { status: 400 }); 12 | } 13 | 14 | let url = new URL(request.url); 15 | let token = 16 | request.headers.get("Authorization") || url.searchParams.get("token"); 17 | 18 | if (!token) { 19 | return json({ error: "No token in request" }, { status: 401 }); 20 | } 21 | 22 | url.hash = ""; 23 | url.search = ""; 24 | let cacheRequest = new Request(url.href); 25 | let cached = await cache.match(cacheRequest); 26 | if (cached) { 27 | return cached; 28 | } 29 | 30 | let flags = await db.getFlagsByProjectIdWithToken({ projectId, token }); 31 | 32 | if (!flags) throw json(null, { status: 404, statusText: "Not Found" }); 33 | 34 | let data = flags.reduce( 35 | (acc, flag) => ({ 36 | ...acc, 37 | [flag.name]: flag.enabled, 38 | }), 39 | {} 40 | ); 41 | 42 | await cache.put( 43 | cacheRequest, 44 | json( 45 | { data }, 46 | { 47 | headers: { 48 | "Cache-Control": "public, max-age=5", 49 | }, 50 | } 51 | ) 52 | ); 53 | 54 | return json( 55 | { data }, 56 | { 57 | headers: { 58 | "Cache-Control": "public, max-age=5", 59 | }, 60 | } 61 | ); 62 | } catch (error) { 63 | logger.captureException(error); 64 | return json({ error: "An unexpected error occurred" }, { status: 500 }); 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/dashboard.project.$projectId.delete.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect } from "remix"; 2 | import { Form, Link, useOutletContext } from "@remix-run/react"; 3 | 4 | import type { ActionFunction, LoaderFunction } from "~/types"; 5 | import { requireUserId } from "~/utils"; 6 | 7 | import type { OutletContext } from "./dashboard.project.$projectId"; 8 | 9 | export let handle = { 10 | breadcrumb: { text: "Delete" }, 11 | }; 12 | 13 | export let action: ActionFunction = async ({ 14 | context: { db, session }, 15 | params: { projectId }, 16 | }) => { 17 | if (!projectId) return redirect("/dashboard"); 18 | 19 | let userId = requireUserId(session, `/dashboard/project/delete/${projectId}`); 20 | 21 | await db.deleteProjectById({ userId, projectId }); 22 | 23 | return redirect("/dashboard"); 24 | }; 25 | 26 | export let loader: LoaderFunction = async ({ 27 | context: { session }, 28 | params: { projectId }, 29 | }) => { 30 | if (!projectId) throw json(null, { status: 404, statusText: "Not Found" }); 31 | requireUserId(session, `/dashboard/project/${projectId}/new/flag`); 32 | 33 | return json(null); 34 | }; 35 | 36 | export default function DeleteProjectDashboard() { 37 | let project = useOutletContext() as OutletContext; 38 | 39 | return ( 40 |
41 |

Danger Zone

42 |

43 | Are you sure you want to delete the project{" "} 44 | {project.name}? 45 |

46 |
47 |

48 | 51 | {" or "} 52 | 53 | No 54 | 55 |

56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/dashboard.project.$projectId.flag.$flagId.delete.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect } from "remix"; 2 | import { Form, Link, useLoaderData } from "@remix-run/react"; 3 | 4 | import type { ActionFunction, LoaderFunction } from "~/types"; 5 | import { requireUserId } from "~/utils"; 6 | 7 | export let handle = { 8 | breadcrumb: { text: "Delete Flag" }, 9 | }; 10 | 11 | export let action: ActionFunction = async ({ 12 | context: { db, session }, 13 | params: { projectId, flagId }, 14 | }) => { 15 | if (!flagId || !projectId) return redirect("/dashboard"); 16 | 17 | let userId = requireUserId( 18 | session, 19 | `/dashboard/project/${projectId}/flag/${flagId}/delete` 20 | ); 21 | 22 | await db.deleteFlagById({ flagId, userId }); 23 | 24 | return redirect(`/dashboard/project/${projectId}`); 25 | }; 26 | 27 | type LoaderData = { 28 | flag: { name: string }; 29 | }; 30 | 31 | export let loader: LoaderFunction = async ({ 32 | context: { db, session }, 33 | params: { projectId, flagId }, 34 | }) => { 35 | if (!flagId || !projectId) 36 | throw json(null, { status: 404, statusText: "Not Found" }); 37 | let userId = requireUserId( 38 | session, 39 | `/dashboard/project/${projectId}/flag/${flagId}/delete` 40 | ); 41 | 42 | let flag = await db.getFlagById({ flagId, userId }); 43 | 44 | if (!flag) throw json(null, { status: 404, statusText: "Not Found" }); 45 | 46 | return json({ flag }); 47 | }; 48 | 49 | export default function DeleteFlagDashboard() { 50 | let { flag } = useLoaderData() as LoaderData; 51 | 52 | return ( 53 |
54 |

Danger Zone

55 |

56 | Are you sure you want to delete the flag {flag.name}? 57 |

58 | 59 |

60 | 63 | {" or "} 64 | 65 | No 66 | 67 |

68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T00_52_42.540Z/create-role-Worker.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | "name": "Worker", 3 | "privileges": [{ 4 | "resource": Function("CreateFlag"), 5 | "actions": { 6 | "call": true 7 | } 8 | }, { 9 | "resource": Function("CreateProject"), 10 | "actions": { 11 | "call": true 12 | } 13 | }, { 14 | "resource": Function("CreateUser"), 15 | "actions": { 16 | "call": true 17 | } 18 | }, { 19 | "resource": Function("DeleteFlagById"), 20 | "actions": { 21 | "call": true 22 | } 23 | }, { 24 | "resource": Function("SetFlagEnabled"), 25 | "actions": { 26 | "call": true 27 | } 28 | }, { 29 | "resource": Function("DeleteProjectById"), 30 | "actions": { 31 | "call": true 32 | } 33 | }, { 34 | "resource": Function("GetFlagById"), 35 | "actions": { 36 | "call": true 37 | } 38 | }, { 39 | "resource": Function("DeleteTokenById"), 40 | "actions": { 41 | "call": true 42 | } 43 | }, { 44 | "resource": Function("GetProjectById"), 45 | "actions": { 46 | "call": true 47 | } 48 | }, { 49 | "resource": Function("GetFlagsByProjectId"), 50 | "actions": { 51 | "call": true 52 | } 53 | }, { 54 | "resource": Function("GetTokenById"), 55 | "actions": { 56 | "call": true 57 | } 58 | }, { 59 | "resource": Function("GetTokensByProjectId"), 60 | "actions": { 61 | "call": true 62 | } 63 | }, { 64 | "resource": Function("CreateToken"), 65 | "actions": { 66 | "call": true 67 | } 68 | }, { 69 | "resource": Function("GetProjectsByUserId"), 70 | "actions": { 71 | "call": true 72 | } 73 | }, { 74 | "resource": Function("LoginUser"), 75 | "actions": { 76 | "call": true 77 | } 78 | }, { 79 | "resource": Collection("Projects"), 80 | "actions": { 81 | "read": true, 82 | "write": true, 83 | "delete": true 84 | } 85 | }, { 86 | "resource": Collection("Users"), 87 | "actions": { 88 | "read": true, 89 | "write": true 90 | } 91 | }] 92 | }) -------------------------------------------------------------------------------- /packages/remix-app/app/routes/dashboard.project.$projectId.token.$tokenId.delete.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect } from "remix"; 2 | import { Form, Link, useLoaderData, useParams } from "@remix-run/react"; 3 | 4 | import type { ActionFunction, LoaderFunction } from "~/types"; 5 | import { requireUserId } from "~/utils"; 6 | 7 | export let handle = { 8 | breadcrumb: { text: "Delete Token" }, 9 | }; 10 | 11 | export let action: ActionFunction = async ({ 12 | context: { db, session }, 13 | params: { projectId, tokenId }, 14 | }) => { 15 | if (!tokenId || !projectId) return redirect("/dashboard"); 16 | 17 | let userId = requireUserId( 18 | session, 19 | `/dashboard/project/${projectId}/tokens/${tokenId}/delete` 20 | ); 21 | 22 | await db.deleteTokenById({ tokenId, userId }); 23 | 24 | return redirect(`/dashboard/project/${projectId}/tokens`); 25 | }; 26 | 27 | type LoaderData = { 28 | token: { name: string }; 29 | }; 30 | 31 | export let loader: LoaderFunction = async ({ 32 | context: { db, session }, 33 | params: { projectId, tokenId }, 34 | }) => { 35 | if (!tokenId || !projectId) 36 | throw json(null, { status: 404, statusText: "Not Found" }); 37 | let userId = requireUserId( 38 | session, 39 | `/dashboard/project/${projectId}/tokens/${tokenId}/delete` 40 | ); 41 | 42 | let token = await db.getTokenById({ tokenId, userId }); 43 | 44 | if (!token) throw json(null, { status: 404, statusText: "Not Found" }); 45 | 46 | return json({ token }); 47 | }; 48 | 49 | export default function DeleteFlagDashboard() { 50 | let { token } = useLoaderData() as LoaderData; 51 | let { projectId } = useParams(); 52 | 53 | return ( 54 |
55 |

Danger Zone

56 |

57 | Are you sure you want to delete the token {token.name}? 58 |

59 | 60 |

61 | 64 | {" or "} 65 | 69 | No 70 | 71 |

72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /packages/db/index.ts: -------------------------------------------------------------------------------- 1 | export interface Project { 2 | id: string; 3 | name: string; 4 | } 5 | 6 | export interface Flag { 7 | id: string; 8 | name: string; 9 | enabled: boolean; 10 | } 11 | 12 | export interface Token { 13 | id: string; 14 | name: string; 15 | } 16 | 17 | export interface User { 18 | id: string; 19 | email: string; 20 | } 21 | 22 | export interface Db { 23 | // User functions 24 | getUserById(userId: string): Promise; 25 | login(args: { email: string; password: string }): Promise; 26 | signup(args: { email: string; password: string }): Promise; 27 | 28 | // Project functions 29 | createProject(args: { userId: string; name: string }): Promise; 30 | deleteProjectById(args: { 31 | projectId: string; 32 | userId: string; 33 | }): Promise; 34 | getProjectById(args: { 35 | userId: string; 36 | projectId: string; 37 | }): Promise; 38 | getProjectsByUserId(userId: string): Promise; 39 | 40 | // Flag functions 41 | createFlag(args: { 42 | name: string; 43 | enabled: boolean; 44 | projectId: string; 45 | userId: string; 46 | }): Promise; 47 | deleteFlagById(args: { 48 | flagId: string; 49 | userId: string; 50 | }): Promise; 51 | getFlagsByProjectId(args: { 52 | projectId: string; 53 | userId: string; 54 | }): Promise; 55 | getFlagsByProjectIdWithToken(args: { 56 | projectId: string; 57 | token: string; 58 | }): Promise; 59 | getFlagById(args: { flagId: string; userId: string }): Promise; 60 | setFlagEnabled(args: { 61 | flagId: string; 62 | enabled: boolean; 63 | userId: string; 64 | }): Promise; 65 | 66 | // Token functions 67 | getTokenById(args: { 68 | tokenId: string; 69 | userId: string; 70 | }): Promise; 71 | deleteTokenById(args: { 72 | tokenId: string; 73 | userId: string; 74 | }): Promise; 75 | getTokensByProjectId(args: { 76 | projectId: string; 77 | userId: string; 78 | }): Promise; 79 | createToken(args: { 80 | name: string; 81 | projectId: string; 82 | userId: string; 83 | }): Promise; 84 | } 85 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/_public.tsx: -------------------------------------------------------------------------------- 1 | import { json } from "remix"; 2 | import { Form, Link, Outlet, useLoaderData } from "@remix-run/react"; 3 | 4 | import type { LoaderFunction } from "~/types"; 5 | 6 | type LoaderData = { 7 | loggedIn: boolean; 8 | }; 9 | 10 | export let loader: LoaderFunction = ({ context: { session } }) => { 11 | return json({ 12 | loggedIn: !!session.get("userId"), 13 | }); 14 | }; 15 | 16 | export default function PublicLayout() { 17 | let { loggedIn } = useLoaderData() as LoaderData; 18 | 19 | return ( 20 |
21 |
22 |
23 | 24 |
25 |
26 |
27 | 57 | 71 |
72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /packages/db-fauna/migrations/2022-06-01T01_59_05.588Z/update-role-Worker.fql: -------------------------------------------------------------------------------- 1 | Update(Role("Worker"), { 2 | "privileges": [{ 3 | "resource": Function("CreateFlag"), 4 | "actions": { 5 | "call": true 6 | } 7 | }, { 8 | "resource": Function("CreateProject"), 9 | "actions": { 10 | "call": true 11 | } 12 | }, { 13 | "resource": Function("CreateUser"), 14 | "actions": { 15 | "call": true 16 | } 17 | }, { 18 | "resource": Function("DeleteFlagById"), 19 | "actions": { 20 | "call": true 21 | } 22 | }, { 23 | "resource": Function("SetFlagEnabled"), 24 | "actions": { 25 | "call": true 26 | } 27 | }, { 28 | "resource": Function("DeleteProjectById"), 29 | "actions": { 30 | "call": true 31 | } 32 | }, { 33 | "resource": Function("GetFlagById"), 34 | "actions": { 35 | "call": true 36 | } 37 | }, { 38 | "resource": Function("DeleteTokenById"), 39 | "actions": { 40 | "call": true 41 | } 42 | }, { 43 | "resource": Function("GetProjectById"), 44 | "actions": { 45 | "call": true 46 | } 47 | }, { 48 | "resource": Function("GetFlagsByProjectId"), 49 | "actions": { 50 | "call": true 51 | } 52 | }, { 53 | "resource": Function("GetFlagsByProjectIdWithToken"), 54 | "actions": { 55 | "call": true 56 | } 57 | }, { 58 | "resource": Function("GetTokenById"), 59 | "actions": { 60 | "call": true 61 | } 62 | }, { 63 | "resource": Function("GetTokensByProjectId"), 64 | "actions": { 65 | "call": true 66 | } 67 | }, { 68 | "resource": Function("CreateToken"), 69 | "actions": { 70 | "call": true 71 | } 72 | }, { 73 | "resource": Function("GetProjectsByUserId"), 74 | "actions": { 75 | "call": true 76 | } 77 | }, { 78 | "resource": Function("LoginUser"), 79 | "actions": { 80 | "call": true 81 | } 82 | }, { 83 | "resource": Collection("Projects"), 84 | "actions": { 85 | "read": true, 86 | "write": true, 87 | "delete": true 88 | } 89 | }, { 90 | "resource": Collection("Users"), 91 | "actions": { 92 | "read": true, 93 | "write": true 94 | } 95 | }], 96 | "data": null, 97 | "membership": null 98 | }) -------------------------------------------------------------------------------- /packages/remix-app/app/routes/docs._layout.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "remix"; 2 | import { json } from "remix"; 3 | import { NavLink, Outlet, useLoaderData } from "@remix-run/react"; 4 | 5 | export let meta: MetaFunction = (args) => { 6 | let { title, description } = (args.data as LoaderData) || {}; 7 | 8 | return { title, description }; 9 | }; 10 | 11 | export type LoaderData = { 12 | title: string; 13 | description: string; 14 | html: string; 15 | navigation: { label: string; to: string }[]; 16 | }; 17 | 18 | export let loader = async () => { 19 | let docResponse = await fetch( 20 | "https://github-md.com/jacob-ebey/remix-flags/main/docs/index.md" 21 | ); 22 | let doc = (await docResponse.json()) as { 23 | attributes: { 24 | title: string; 25 | description: string; 26 | navigation: { label: string; to: string }[]; 27 | }; 28 | html: string; 29 | }; 30 | 31 | return json({ 32 | title: doc.attributes.title, 33 | description: doc.attributes.description, 34 | html: doc.html, 35 | navigation: doc.attributes.navigation, 36 | }); 37 | }; 38 | 39 | export default function DocsLayout() { 40 | let data = useLoaderData() as LoaderData; 41 | 42 | return ( 43 |
44 |
45 |
46 | 47 |
48 |
49 |
50 | 65 | 79 |
80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/dashboard._layout.projects.new.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect } from "remix"; 2 | import { Form, useActionData, useTransition } from "@remix-run/react"; 3 | 4 | import type { ActionFunction, LoaderFunction } from "~/types"; 5 | import { requireUserId } from "~/utils"; 6 | 7 | type ActionData = { 8 | errors?: { 9 | global?: string; 10 | name?: string; 11 | gitHubRepo?: string; 12 | }; 13 | }; 14 | 15 | export let action: ActionFunction = async ({ 16 | context: { db, logger, session }, 17 | request, 18 | }) => { 19 | let userId = requireUserId(session, "/projects/new"); 20 | 21 | let formData = await request.formData(); 22 | let name = formData.get("name"); 23 | 24 | let errors: ActionData["errors"]; 25 | if (!name || typeof name !== "string") { 26 | errors = { ...errors, name: "Name is required" }; 27 | } 28 | 29 | if (errors) return json({ errors }); 30 | 31 | name = String(name); 32 | 33 | try { 34 | let projectId = await db.createProject({ userId, name }); 35 | return redirect(`/dashboard/project/${projectId}`); 36 | } catch (error) { 37 | logger.captureException(error); 38 | return json({ 39 | errors: { global: "An error occurred. Please try again later." }, 40 | }); 41 | } 42 | }; 43 | 44 | export let loader: LoaderFunction = async ({ context: { session } }) => { 45 | requireUserId(session, "/dashboard/projects/new"); 46 | 47 | return json(null); 48 | }; 49 | 50 | export default function NewProjectDashboard() { 51 | let { errors } = (useActionData() || {}) as ActionData; 52 | let { state } = useTransition(); 53 | 54 | return ( 55 |
56 |
57 |

New Project

58 | 59 | {errors?.global &&

{errors.global}

} 60 | 61 |
62 | 63 | 71 | {errors?.name && ( 72 | 75 | )} 76 |
77 | 78 | 81 |
82 |
83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /packages/db-fauna/resources/roles/Worker.fql: -------------------------------------------------------------------------------- 1 | CreateRole({ 2 | name: "Worker", 3 | privileges: [ 4 | { 5 | resource: Function("CreateFlag"), 6 | actions: { 7 | call: true 8 | } 9 | }, 10 | { 11 | resource: Function("CreateProject"), 12 | actions: { 13 | call: true 14 | } 15 | }, 16 | { 17 | resource: Function("CreateUser"), 18 | actions: { 19 | call: true 20 | } 21 | }, 22 | { 23 | resource: Function("DeleteFlagById"), 24 | actions: { 25 | call: true 26 | } 27 | }, 28 | { 29 | resource: Function("SetFlagEnabled"), 30 | actions: { 31 | call: true 32 | } 33 | }, 34 | { 35 | resource: Function("DeleteProjectById"), 36 | actions: { 37 | call: true 38 | } 39 | }, 40 | { 41 | resource: Function("GetFlagById"), 42 | actions: { 43 | call: true 44 | } 45 | }, 46 | { 47 | resource: Function("DeleteTokenById"), 48 | actions: { 49 | call: true 50 | } 51 | }, 52 | { 53 | resource: Function("GetProjectById"), 54 | actions: { 55 | call: true 56 | } 57 | }, 58 | { 59 | resource: Function("GetFlagsByProjectId"), 60 | actions: { 61 | call: true 62 | } 63 | }, 64 | { 65 | resource: Function("GetFlagsByProjectIdWithToken"), 66 | actions: { 67 | call: true 68 | } 69 | }, 70 | { 71 | resource: Function("GetTokenById"), 72 | actions: { 73 | call: true 74 | } 75 | }, 76 | { 77 | resource: Function("GetTokensByProjectId"), 78 | actions: { 79 | call: true 80 | } 81 | }, 82 | { 83 | resource: Function("CreateToken"), 84 | actions: { 85 | call: true 86 | } 87 | }, 88 | { 89 | resource: Function("GetProjectsByUserId"), 90 | actions: { 91 | call: true 92 | } 93 | }, 94 | { 95 | resource: Function("LoginUser"), 96 | actions: { 97 | call: true 98 | } 99 | }, 100 | { 101 | resource: Collection("Projects"), 102 | actions: { 103 | read: true, 104 | write: true, 105 | delete: true 106 | } 107 | }, 108 | { 109 | resource: Collection("Users"), 110 | actions: { 111 | read: true, 112 | write: true 113 | } 114 | } 115 | ] 116 | }) 117 | -------------------------------------------------------------------------------- /patches/@remix-run+server-runtime+0.0.0-experimental-9784dd06.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@remix-run/server-runtime/esm/server.js b/node_modules/@remix-run/server-runtime/esm/server.js 2 | index 5ba508c..e8202cd 100644 3 | --- a/node_modules/@remix-run/server-runtime/esm/server.js 4 | +++ b/node_modules/@remix-run/server-runtime/esm/server.js 5 | @@ -410,7 +410,7 @@ async function handleDocumentRequest({ 6 | let handleDocumentRequest = build.entry.module.default; 7 | 8 | try { 9 | - return await handleDocumentRequest(request, responseStatusCode, responseHeaders, entryContext); 10 | + return await handleDocumentRequest(request, responseStatusCode, responseHeaders, entryContext, loadContext); 11 | } catch (error) { 12 | responseStatusCode = 500; // Go again, this time with the componentDidCatch emulation. As it rendered 13 | // last time we mutated `componentDidCatch.routeId` for the last rendered 14 | @@ -424,7 +424,7 @@ async function handleDocumentRequest({ 15 | entryContext.serverHandoffString = createServerHandoffString(serverHandoff); 16 | 17 | try { 18 | - return await handleDocumentRequest(request, responseStatusCode, responseHeaders, entryContext); 19 | + return await handleDocumentRequest(request, responseStatusCode, responseHeaders, entryContext, loadContext); 20 | } catch (error) { 21 | if (serverMode !== ServerMode.Test) { 22 | console.error(error); 23 | diff --git a/node_modules/@remix-run/server-runtime/server.js b/node_modules/@remix-run/server-runtime/server.js 24 | index 659c2b6..f072d37 100644 25 | --- a/node_modules/@remix-run/server-runtime/server.js 26 | +++ b/node_modules/@remix-run/server-runtime/server.js 27 | @@ -414,7 +414,7 @@ async function handleDocumentRequest({ 28 | let handleDocumentRequest = build.entry.module.default; 29 | 30 | try { 31 | - return await handleDocumentRequest(request, responseStatusCode, responseHeaders, entryContext); 32 | + return await handleDocumentRequest(request, responseStatusCode, responseHeaders, entryContext, loadContext); 33 | } catch (error) { 34 | responseStatusCode = 500; // Go again, this time with the componentDidCatch emulation. As it rendered 35 | // last time we mutated `componentDidCatch.routeId` for the last rendered 36 | @@ -428,7 +428,7 @@ async function handleDocumentRequest({ 37 | entryContext.serverHandoffString = serverHandoff.createServerHandoffString(serverHandoff$1); 38 | 39 | try { 40 | - return await handleDocumentRequest(request, responseStatusCode, responseHeaders, entryContext); 41 | + return await handleDocumentRequest(request, responseStatusCode, responseHeaders, entryContext, loadContext); 42 | } catch (error) { 43 | if (serverMode !== mode.ServerMode.Test) { 44 | console.error(error); 45 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/dashboard.project.$projectId.tsx: -------------------------------------------------------------------------------- 1 | import { json } from "remix"; 2 | import { 3 | Link, 4 | NavLink, 5 | Outlet, 6 | useLoaderData, 7 | useMatches, 8 | } from "@remix-run/react"; 9 | 10 | import type { LoaderFunction } from "~/types"; 11 | import { requireUserId } from "~/utils"; 12 | 13 | type LoaderData = { 14 | project: { id: string; name: string }; 15 | }; 16 | 17 | export type OutletContext = LoaderData["project"]; 18 | 19 | export let loader: LoaderFunction = async ({ 20 | context: { db, session }, 21 | params: { projectId }, 22 | }) => { 23 | if (!projectId) throw json(null, { status: 404, statusText: "Not Found" }); 24 | 25 | let userId = requireUserId(session, `/dashboard/project/${projectId}`); 26 | 27 | let project = await db.getProjectById({ userId, projectId }); 28 | 29 | if (!project) throw json(null, { status: 404, statusText: "Not Found" }); 30 | 31 | return json({ project }); 32 | }; 33 | 34 | export default function ProjectDashboard() { 35 | let { project } = useLoaderData() as LoaderData; 36 | let matches = useMatches(); 37 | 38 | let breadcrumbs: { text: string; to?: string }[] = []; 39 | for (let match of matches) { 40 | if (match.handle?.breadcrumb) { 41 | breadcrumbs.push(match.handle.breadcrumb); 42 | } 43 | } 44 | 45 | return ( 46 | <> 47 |
    51 |
  • 52 | Projects 53 |
  • 54 |
  • {project.name}
  • 55 | {breadcrumbs.map((breadcrumb) => ( 56 |
  • 57 | {breadcrumb.to ? ( 58 | {breadcrumb.text} 59 | ) : ( 60 | breadcrumb.text 61 | )} 62 |
  • 63 | ))} 64 |
65 |
66 |
67 | 94 |
95 |
96 |
97 | 98 |
99 |
100 |
101 | 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/dashboard.project.$projectId.new.flag.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect } from "remix"; 2 | import { Form, useActionData, useTransition } from "@remix-run/react"; 3 | 4 | import type { ActionFunction, LoaderFunction } from "~/types"; 5 | import { requireUserId } from "~/utils"; 6 | 7 | type ActionData = { 8 | errors?: { 9 | global?: string; 10 | name?: string; 11 | gitHubRepo?: string; 12 | }; 13 | }; 14 | 15 | export let handle = { 16 | breadcrumb: { text: "New Flag" }, 17 | }; 18 | 19 | export let action: ActionFunction = async ({ 20 | context: { db, logger, session }, 21 | params: { projectId }, 22 | request, 23 | }) => { 24 | if (!projectId) return redirect("/dashboard"); 25 | let userId = requireUserId( 26 | session, 27 | `/dashboard/project/${projectId}/new/flag` 28 | ); 29 | 30 | let formData = await request.formData(); 31 | let name = formData.get("name"); 32 | let enabled = formData.get("enabled") === "on"; 33 | 34 | let errors: ActionData["errors"]; 35 | if (!name || typeof name !== "string") { 36 | errors = { ...errors, name: "Name is required" }; 37 | } 38 | 39 | if (errors) return json({ errors }); 40 | 41 | name = String(name); 42 | 43 | try { 44 | await db.createFlag({ userId, name, enabled, projectId }); 45 | return redirect(`/dashboard/project/${projectId}`); 46 | } catch (error) { 47 | logger.captureException(error); 48 | return json({ 49 | errors: { global: "An error occurred. Please try again later." }, 50 | }); 51 | } 52 | }; 53 | 54 | export let loader: LoaderFunction = async ({ 55 | context: { session }, 56 | params: { projectId }, 57 | }) => { 58 | if (!projectId) throw json(null, { status: 404, statusText: "Not Found" }); 59 | requireUserId(session, `/dashboard/project/${projectId}/new/flag`); 60 | 61 | return json(null); 62 | }; 63 | 64 | export default function NewFlagDashboard() { 65 | let { errors } = (useActionData() || {}) as ActionData; 66 | let { state } = useTransition(); 67 | 68 | return ( 69 |
70 |
71 |

New Flag

72 | 73 | {errors?.global &&

{errors.global}

} 74 | 75 |
76 | 77 | 85 | {errors?.name && ( 86 | 89 | )} 90 |
91 | 92 |
93 | 104 |
105 | 106 | 109 |
110 |
111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/dashboard._layout.index.tsx: -------------------------------------------------------------------------------- 1 | import { deferred, json } from "remix"; 2 | import { Link, useLoaderData } from "@remix-run/react"; 3 | 4 | import type { LoaderFunction } from "~/types"; 5 | import type { Deferrable } from "~/utils"; 6 | import { InlineDeferred, requireUserId } from "~/utils"; 7 | 8 | type LoaderData = { 9 | projects: { id: string; name: string }[]; 10 | [key: string]: Deferrable<{ enabled: boolean }[]>; 11 | }; 12 | 13 | export let loader: LoaderFunction = async ({ context: { db, session } }) => { 14 | let userId = requireUserId(session, "/dashboard"); 15 | 16 | let projects = await db.getProjectsByUserId(userId); 17 | 18 | if (!projects) throw json(null, { status: 404, statusText: "Not Found" }); 19 | 20 | let projectFlags = projects.slice(0, 5).reduce( 21 | (acc, project) => ({ 22 | ...acc, 23 | [project.id]: db 24 | .getFlagsByProjectId({ projectId: project.id, userId }) 25 | .then((flags) => flags.map((flag) => ({ enabled: flag.enabled }))), 26 | }), 27 | {} as Record> 28 | ); 29 | 30 | return deferred({ ...projectFlags, projects }); 31 | }; 32 | 33 | export default function ProjectsDashboard() { 34 | let { projects, ...deferredLoaderData } = useLoaderData() as LoaderData; 35 | 36 | return ( 37 |
38 |

Projects

39 |

40 | 41 | New Project 42 | 43 |

44 | 45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | {projects.map(({ id, name }) => ( 57 | 58 | 61 | 64 | 68 | 69 | 70 | 71 | } 72 | fallback={ 73 | <> 74 | 75 | 76 | 77 | } 78 | > 79 | {(flags) => 80 | flags ? ( 81 | <> 82 | 91 | 92 | 93 | ) : ( 94 | <> 95 | 96 | 97 | 98 | ) 99 | } 100 | 101 | 102 | ))} 103 | 104 |
NameIDEnabled FlagsTotal Flags
59 | {name} 60 | 62 | {id} 63 | ............ 83 | {flags.reduce( 84 | (enabled: number, flag: { enabled: boolean }) => { 85 | if (flag.enabled) enabled++; 86 | return enabled; 87 | }, 88 | 0 89 | )} 90 | {flags.length || 0}......
105 |
106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/_public.login.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect } from "remix"; 2 | import { 3 | Form, 4 | Link, 5 | useActionData, 6 | useFormAction, 7 | useSearchParams, 8 | useTransition, 9 | } from "@remix-run/react"; 10 | 11 | import type { ActionFunction } from "~/types"; 12 | 13 | type ActionData = { 14 | errors?: { 15 | global?: string; 16 | email?: string; 17 | password?: string; 18 | }; 19 | }; 20 | 21 | export let action: ActionFunction = async ({ 22 | context: { db, logger, session }, 23 | request, 24 | }) => { 25 | try { 26 | let formData = await request.formData(); 27 | let email = String(formData.get("email")); 28 | let password = String(formData.get("password")); 29 | 30 | let errors: ActionData["errors"]; 31 | if (!email.match(/^.+@.+\..+$/)) { 32 | errors = { ...errors, email: "Invalid email" }; 33 | } 34 | 35 | if (!password.match(/^.{8,}$/)) { 36 | errors = { 37 | ...errors, 38 | password: "Password must be at least 8 characters", 39 | }; 40 | } 41 | 42 | if (errors) return json({ errors }); 43 | 44 | let user = await db.login({ email, password }); 45 | 46 | if (!user) { 47 | return json({ 48 | errors: { global: "An error occurred. Invalid email or password?" }, 49 | }); 50 | } 51 | 52 | session.set("userId", user.id); 53 | 54 | let url = new URL(request.url); 55 | let redirectTo = url.searchParams.get("redirect") || "/"; 56 | if (!redirectTo.startsWith("/") || redirectTo.charAt(1) === "/") 57 | redirectTo = "/"; 58 | 59 | return redirect(redirectTo); 60 | } catch (error) { 61 | logger.captureException(error); 62 | return json({ 63 | errors: { global: "An unknown error occurred." }, 64 | }); 65 | } 66 | }; 67 | 68 | export default function Login() { 69 | let { errors } = (useActionData() || {}) as ActionData; 70 | let [searchParams] = useSearchParams(); 71 | let { state } = useTransition(); 72 | 73 | let formAction = useFormAction(".", "post"); 74 | if (searchParams.has("redirect")) { 75 | formAction += `?redirect=${searchParams.get("redirect")}`; 76 | } 77 | 78 | return ( 79 |
80 |
81 |

Login

82 | 83 | {errors?.global &&

{errors.global}

} 84 | 85 |
86 | 87 | 96 | {errors?.email && ( 97 | 100 | )} 101 |
102 |
103 | 104 | 113 | {errors?.password && ( 114 | 117 | )} 118 |
119 | 120 | 123 |
124 |

125 | Don't have an account? Signup. 126 |

127 |
128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/_public.signup.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect } from "remix"; 2 | import { Form, Link, useActionData, useTransition } from "@remix-run/react"; 3 | 4 | import type { ActionFunction } from "~/types"; 5 | 6 | type ActionData = { 7 | errors?: { 8 | global?: string; 9 | email?: string; 10 | password?: string; 11 | verifyPassword?: string; 12 | }; 13 | }; 14 | 15 | export let action: ActionFunction = async ({ 16 | request, 17 | context: { db, session }, 18 | }) => { 19 | let formData = await request.formData(); 20 | let email = String(formData.get("email")); 21 | let password = String(formData.get("password")); 22 | let verifyPassword = String(formData.get("verifyPassword")); 23 | 24 | let errors: ActionData["errors"]; 25 | if (!email.match(/^.+@.+\..+$/)) { 26 | errors = { ...errors, email: "Invalid email" }; 27 | } 28 | 29 | if (!password.match(/^.{8,}$/)) { 30 | errors = { ...errors, password: "Password must be at least 8 characters" }; 31 | } else if (password !== verifyPassword) { 32 | errors = { ...errors, verifyPassword: "Passwords must match" }; 33 | } 34 | 35 | if (errors) return json({ errors }); 36 | 37 | let user = await db.signup({ email, password }); 38 | 39 | if (!user) { 40 | return json({ 41 | errors: { global: "An error occurred. Does your account already exist?" }, 42 | }); 43 | } 44 | 45 | session.set("userId", user.id); 46 | 47 | return redirect("/"); 48 | }; 49 | 50 | export default function Signup() { 51 | let { errors } = (useActionData() || {}) as ActionData; 52 | let { state } = useTransition(); 53 | 54 | return ( 55 |
56 |
57 |

Signup

58 | 59 | {errors?.global &&

{errors.global}

} 60 | 61 |
62 | 63 | 72 | {errors?.email && ( 73 | 76 | )} 77 |
78 |
79 | 80 | 89 | {errors?.password && ( 90 | 93 | )} 94 |
95 |
96 | 97 | 106 | {errors?.verifyPassword && ( 107 | 110 | )} 111 |
112 | 113 | 116 |
117 |

118 | Already have an account? Login. 119 |

120 |
121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # remix-flags 2 | 3 | _Eventually_ Everything you'll need for successful feature flagging 4 | 5 | ## What's inside? 6 | 7 | This repo uses [npm](https://www.npmjs.com/) as a package manager. It includes the following packages/apps: 8 | 9 | ### Packages 10 | 11 | - `packages/db`: abstractions for DB interactions 12 | - `packages/db-fauna`: an implementation of the `db` abstraction using FaunaDB 13 | - `packages/remix-app`: a [Remix](https://remix.run/) application that makes up the public facing UX 14 | - `packages/worker`: a Cloudflare Worker that brings everything together for deployment 15 | - `config/cloudflare-env`: type definitions for bindings shared across the packages 16 | - `config/eslint-config-custom`: shared eslint config that includes `@remix-run/eslint-config` and `prettier` 17 | - `config/tsconfig`: base tsconfig that other packages inherit from 18 | 19 | Each `package/*` is 100% [TypeScript](https://www.typescriptlang.org/). 20 | 21 | ### Utilities 22 | 23 | This turborepo has some additional tools already setup for you: 24 | 25 | - [TypeScript](https://www.typescriptlang.org/) for static type checking 26 | - [ESLint](https://eslint.org/) for code linting 27 | - [Prettier](https://prettier.io) for code formatting 28 | - [Github Actions](https://github.com/features/actions) 29 | 30 | ## Setup 31 | 32 | Clone and install dependencies: 33 | 34 | ``` 35 | npm i 36 | ``` 37 | 38 | ### Build 39 | 40 | To build all apps and packages, run the following command: 41 | 42 | ``` 43 | npm run build 44 | ``` 45 | 46 | ### Develop 47 | 48 | Copy `packages/worker/wrangler.example.toml` to `packages/worker/wrangler.dev.toml` and edit it to match your environment, or leave it as-is for a local dev environment using the `docker-compose.yml`. 49 | 50 | If using docker for your local database, you can run the following command to start the database: 51 | 52 | ``` 53 | docker-compose up -d 54 | ``` 55 | 56 | To develop all apps and packages, run the following command: 57 | 58 | ``` 59 | npm run dev 60 | ``` 61 | 62 | ### Deployment 63 | 64 | Deployment is done through GitHub Actions. Deployments are triggered whenever you merge to the `main` branch. 65 | 66 | The following GitHub action secrets are required: 67 | 68 | - `CF_API_TOKEN`: your Cloudflare API token used for deployment 69 | - `FAUNA_ADMIN_KEY`: your FaunaDB admin key used for migrations 70 | 71 | The following GitHub action secrets are optional: 72 | 73 | - `TURBO_TEAM`: your Turbo team ID used for remote caching 74 | - `TURBO_TOKEN`: your Turbo token used for remote caching 75 | 76 | ### Remote Caching 77 | 78 | Turborepo can use a technique known as [Remote Caching (Beta)](https://turborepo.org/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines. 79 | 80 | By default, Turborepo will cache locally. To enable Remote Caching (Beta) you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup), then enter the following commands: 81 | 82 | ``` 83 | npx turbo login 84 | ``` 85 | 86 | This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview). 87 | 88 | Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your turborepo: 89 | 90 | ``` 91 | npx turbo link 92 | ``` 93 | 94 | ## Useful Links 95 | 96 | Learn more about the power of Turborepo: 97 | 98 | - [Pipelines](https://turborepo.org/docs/core-concepts/pipelines) 99 | - [Caching](https://turborepo.org/docs/core-concepts/caching) 100 | - [Remote Caching (Beta)](https://turborepo.org/docs/core-concepts/remote-caching) 101 | - [Scoped Tasks](https://turborepo.org/docs/core-concepts/scopes) 102 | - [Configuration Options](https://turborepo.org/docs/reference/configuration) 103 | - [CLI Usage](https://turborepo.org/docs/reference/command-line-reference) 104 | -------------------------------------------------------------------------------- /packages/worker/entry.worker.ts: -------------------------------------------------------------------------------- 1 | import { getAssetFromKV } from "@cloudflare/kv-asset-handler"; 2 | import { 3 | createRequestHandler, 4 | createCookieSessionStorage, 5 | } from "@remix-run/cloudflare"; 6 | import Toucan from "toucan-js"; 7 | 8 | import manifestJSON from "__STATIC_CONTENT_MANIFEST"; 9 | 10 | import { createDb } from "db-fauna"; 11 | import * as build from "remix-app"; 12 | import type { AppLoadContext } from "remix-app"; 13 | 14 | let assetManifest = JSON.parse(manifestJSON); 15 | let handleRemixRequest = createRequestHandler( 16 | build as any, 17 | process.env.NODE_ENV 18 | ); 19 | 20 | export default { 21 | async fetch( 22 | request: Request, 23 | env: Env, 24 | context: ExecutionContext 25 | ): Promise { 26 | let url = new URL(request.url); 27 | 28 | try { 29 | let ttl = url.pathname.startsWith("/build/") 30 | ? 60 * 60 * 24 * 365 // 1 year 31 | : 60 * 5; // 5 minutes 32 | return await getAssetFromKV( 33 | { 34 | request: request.clone(), 35 | waitUntil(promise) { 36 | return context.waitUntil(promise); 37 | }, 38 | }, 39 | { 40 | ASSET_NAMESPACE: env.__STATIC_CONTENT, 41 | ASSET_MANIFEST: assetManifest, 42 | cacheControl: { 43 | browserTTL: ttl, 44 | edgeTTL: ttl, 45 | }, 46 | } 47 | ); 48 | } catch (error) { 49 | // if (error instanceof MethodNotAllowedError) { 50 | // return new Response("Method not allowed", { status: 405 }); 51 | // } else if (!(error instanceof NotFoundError)) { 52 | // return new Response("An unexpected error occurred", { status: 500 }); 53 | // } 54 | } 55 | 56 | const sentry = new Toucan({ 57 | dsn: env.SENTRY_DSN, 58 | context, // Includes 'waitUntil', which is essential for Sentry logs to be delivered. Modules workers do not include 'request' in context -- you'll need to set it separately. 59 | request, // request is not included in 'context', so we set it here. 60 | allowedHeaders: ["user-agent"], 61 | allowedSearchParams: /(.*)/, 62 | }); 63 | 64 | try { 65 | let otherOptions: any = {}; 66 | if (env.FAUNA_URL) { 67 | let url = new URL(env.FAUNA_URL); 68 | otherOptions.domain = url.hostname; 69 | otherOptions.port = url.port ? Number(url.port) : undefined; 70 | otherOptions.scheme = url.protocol.split(":", 1)[0]; 71 | if (otherOptions.scheme !== "http" && otherOptions.scheme !== "https") { 72 | otherOptions.scheme = undefined; 73 | } 74 | } 75 | let db = createDb({ ...otherOptions, secret: env.FAUNA_SECRET }, sentry); 76 | let sessionStorage = createCookieSessionStorage({ 77 | cookie: { 78 | httpOnly: true, 79 | path: "/", 80 | sameSite: "lax", 81 | secrets: [env.SESSION_SECRET], 82 | secure: !url.host.startsWith("localhost"), 83 | }, 84 | }); 85 | 86 | let session = await sessionStorage.getSession( 87 | request.headers.get("Cookie") 88 | ); 89 | 90 | let loadContext: AppLoadContext = { 91 | // @ts-expect-error 92 | cache: caches.default, 93 | db, 94 | env, 95 | logger: sentry, 96 | session, 97 | }; 98 | let response = await handleRemixRequest(request, loadContext); 99 | 100 | let headers = new Headers(response.headers); 101 | headers.append("Set-Cookie", await sessionStorage.commitSession(session)); 102 | 103 | return new Response(response.body, { 104 | headers, 105 | status: response.status, 106 | statusText: response.statusText, 107 | }); 108 | } catch (error) { 109 | sentry.captureException(error); 110 | return new Response("An unexpected error occurred", { status: 500 }); 111 | } 112 | }, 113 | }; 114 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/dashboard.project.$projectId.index.tsx: -------------------------------------------------------------------------------- 1 | import { json, redirect } from "remix"; 2 | import { Link, useFetcher, useLoaderData, useParams } from "@remix-run/react"; 3 | 4 | import type { ActionFunction, LoaderFunction } from "~/types"; 5 | import { requireUserId } from "~/utils"; 6 | 7 | export let handle = { 8 | breadcrumb: { text: "Flags" }, 9 | }; 10 | 11 | export let action: ActionFunction = async ({ 12 | context: { db, session }, 13 | params: { projectId }, 14 | request, 15 | }) => { 16 | if (!projectId) return redirect("/dashboard"); 17 | 18 | let userId = requireUserId(session, `/dashboard/project/${projectId}`); 19 | 20 | let formData = await request.formData(); 21 | let intent = formData.get("intent"); 22 | 23 | switch (intent) { 24 | case "toggle": { 25 | let enabled = formData.get("enabled") === "on"; 26 | let flagId = formData.get("flagId"); 27 | if (!flagId || typeof flagId !== "string") { 28 | throw json(null, { status: 400, statusText: "Bad Request" }); 29 | } 30 | 31 | await db.setFlagEnabled({ enabled, flagId, userId }); 32 | break; 33 | } 34 | } 35 | 36 | return json(null); 37 | }; 38 | 39 | type LoaderData = { 40 | flags: { id: string; name: string; enabled: boolean }[]; 41 | }; 42 | 43 | export let loader: LoaderFunction = async ({ 44 | context: { db, session }, 45 | params: { projectId }, 46 | }) => { 47 | if (!projectId) throw json(null, { status: 404, statusText: "Not Found" }); 48 | 49 | let userId = requireUserId(session, `/dashboard/project/${projectId}`); 50 | 51 | let flags = await db.getFlagsByProjectId({ projectId, userId }); 52 | 53 | return json({ 54 | flags, 55 | }); 56 | }; 57 | 58 | export default function ProjectDashboard() { 59 | let { flags } = useLoaderData() as LoaderData; 60 | 61 | return ( 62 |
63 |

Flags

64 |

65 | 66 | New Flag 67 | 68 |

69 |
70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | {flags.map(({ id, name, enabled }) => ( 80 | 81 | ))} 82 | 83 |
EnabledNameActions
84 |
85 |
86 | ); 87 | } 88 | 89 | function FlagRow({ 90 | id, 91 | name, 92 | enabled, 93 | }: { 94 | id: string; 95 | name: string; 96 | enabled: boolean; 97 | }) { 98 | let fetcher = useFetcher(); 99 | let { projectId } = useParams(); 100 | 101 | return ( 102 | 103 | 104 | 108 | 109 | 110 |
111 | 123 |
124 |
125 | 126 | {name} 127 | 128 | 132 | Delete 133 | 134 | 135 | 136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /packages/remix-app/app/routes/dashboard.project.$projectId.tokens.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { json, redirect } from "remix"; 3 | import { 4 | Form, 5 | Link, 6 | useActionData, 7 | useLoaderData, 8 | useParams, 9 | } from "@remix-run/react"; 10 | 11 | import type { ActionFunction, LoaderFunction } from "~/types"; 12 | import { requireUserId } from "~/utils"; 13 | 14 | export let handle = { 15 | breadcrumb: { text: "API Tokens" }, 16 | }; 17 | 18 | type ActionData = { 19 | errors?: { 20 | name?: string; 21 | }; 22 | secret?: string; 23 | }; 24 | 25 | export let action: ActionFunction = async ({ 26 | context: { db, session }, 27 | params: { projectId }, 28 | request, 29 | }) => { 30 | if (!projectId) return redirect("/dashboard"); 31 | 32 | let userId = requireUserId(session, `/dashboard/project/${projectId}/tokens`); 33 | 34 | let formData = await request.formData(); 35 | let intent = formData.get("intent"); 36 | 37 | switch (intent) { 38 | case "create": { 39 | let name = formData.get("name"); 40 | if (typeof name !== "string" || !name) { 41 | return json({ 42 | errors: { name: "Name is required" }, 43 | }); 44 | } 45 | 46 | let secret = await db.createToken({ name, projectId, userId }); 47 | 48 | return json({ secret }); 49 | } 50 | } 51 | 52 | return json(null); 53 | }; 54 | 55 | type LoaderData = { 56 | tokens: { id: string; name: string }[]; 57 | }; 58 | 59 | export let loader: LoaderFunction = async ({ 60 | context: { db, session }, 61 | params: { projectId }, 62 | }) => { 63 | if (!projectId) throw json(null, { status: 404, statusText: "Not Found" }); 64 | 65 | let userId = requireUserId(session, `/dashboard/project/${projectId}`); 66 | 67 | let tokens = await db.getTokensByProjectId({ projectId, userId }); 68 | 69 | if (!tokens) throw json(null, { status: 404, statusText: "Not Found" }); 70 | 71 | return json({ tokens }); 72 | }; 73 | 74 | export default function TokensDashboard() { 75 | let { tokens } = useLoaderData() as LoaderData; 76 | let { errors, secret } = (useActionData() as ActionData) || {}; 77 | 78 | let [clicked, setClicked] = useState(false); 79 | 80 | useEffect(() => { 81 | if (clicked) { 82 | let timeout = setTimeout(() => setClicked(false), 1000); 83 | return () => clearTimeout(timeout); 84 | } 85 | }, [clicked]); 86 | 87 | return ( 88 |
89 |

API Tokens

90 | 91 |
92 | 93 |
94 | 95 | 102 | {errors?.name && ( 103 | 106 | )} 107 |
108 | 109 |
110 | 111 |
112 | {secret && ( 113 |
117 | New Token (this will only be shown once):{" "} 118 | { 122 | if (secret) { 123 | navigator.clipboard.writeText(secret); 124 | setClicked(true); 125 | } 126 | }} 127 | > 128 | {secret} 129 | 130 |
131 | )} 132 | 133 |
134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | {tokens.map(({ id, name }) => ( 143 | 144 | ))} 145 | 146 |
NameActions
147 |
148 |
149 | ); 150 | } 151 | 152 | function TokenRow({ id, name }: { id: string; name: string }) { 153 | let { projectId } = useParams(); 154 | 155 | return ( 156 | 157 | {name} 158 | 159 | 163 | Delete 164 | 165 | 166 | 167 | ); 168 | } 169 | -------------------------------------------------------------------------------- /packages/remix-app/app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | import { useEffect, useMemo } from "react"; 3 | import type { MetaFunction } from "remix"; 4 | import { json } from "remix"; 5 | import { 6 | Form, 7 | Link, 8 | Links, 9 | LiveReload, 10 | Meta, 11 | Outlet, 12 | Scripts, 13 | ScrollRestoration, 14 | useCatch, 15 | useFetchers, 16 | useLocation, 17 | useMatches, 18 | useTransition, 19 | } from "@remix-run/react"; 20 | import NProgress from "nprogress"; 21 | 22 | import type { LoaderFunction } from "~/types"; 23 | import { getUserId } from "~/utils"; 24 | 25 | declare global { 26 | var process: { env: { NODE_ENV: "development" | "production" } }; 27 | } 28 | 29 | export let meta: MetaFunction = () => ({ 30 | charset: "utf-8", 31 | title: "remix-flags", 32 | description: "Everything you need for successful feature flagging", 33 | viewport: "width=device-width,initial-scale=1", 34 | }); 35 | 36 | type LoaderData = { 37 | loggedIn: boolean; 38 | }; 39 | 40 | export let loader: LoaderFunction = async ({ context: { session } }) => { 41 | return json({ 42 | loggedIn: !!getUserId(session), 43 | }); 44 | }; 45 | 46 | function Document({ children }: PropsWithChildren<{}>) { 47 | let transition = useTransition(); 48 | let location = useLocation(); 49 | let fetchers = useFetchers(); 50 | 51 | let match = useMatches().find((match) => match.id === "root"); 52 | let { loggedIn } = match?.data || { loggedIn: false }; 53 | 54 | let fetchersRunning = useMemo( 55 | () => fetchers.some((fetcher) => fetcher.state !== "idle"), 56 | [fetchers] 57 | ); 58 | 59 | useEffect(() => { 60 | if (!fetchersRunning && transition.state === "idle") NProgress.done(); 61 | else NProgress.start(); 62 | 63 | return () => { 64 | NProgress.done(); 65 | }; 66 | }, [transition.state, fetchersRunning]); 67 | 68 | return ( 69 | 70 | 71 | 72 | 77 | 82 | 87 | 88 | 92 | 96 | 97 | 98 | 133 | {children} 134 | 135 | 136 | {process.env.NODE_ENV === "development" ? : null} 137 | 138 | 139 | ); 140 | } 141 | 142 | export default function App() { 143 | return ( 144 | 145 | 146 | 147 | ); 148 | } 149 | 150 | export function CatchBoundary() { 151 | let { status, statusText } = useCatch(); 152 | 153 | return ( 154 | 155 |
156 |
157 |
158 |

{status}

159 | {statusText &&

{statusText}

} 160 |
161 |
162 |
163 |
164 | ); 165 | } 166 | 167 | export function ErrorBoundary({ error }: { error: Error }) { 168 | console.error(error); 169 | 170 | return ( 171 | 172 |
173 |
174 |
175 |

Oops, looks like something went wrong 😭

176 |
177 |
178 |
179 |
180 | ); 181 | } 182 | -------------------------------------------------------------------------------- /packages/db-fauna/index.ts: -------------------------------------------------------------------------------- 1 | import { Client, errors, query as q } from "faunadb"; 2 | import type { ClientConfig, values } from "faunadb"; 3 | import { nanoid } from "nanoid"; 4 | 5 | import type { Db } from "db"; 6 | import type { Logger } from "logger"; 7 | 8 | export function createDb(config: ClientConfig, logger: Logger): Db { 9 | let client = new Client(config); 10 | 11 | return { 12 | // User functions 13 | getUserById: async (userId) => { 14 | let result = await client.query<{ 15 | ref: values.Ref; 16 | data: { email: string; githubAccessToken?: string }; 17 | }>(q.Get(q.Ref(q.Collection("Users"), userId))); 18 | 19 | return { 20 | id: result.ref.id, 21 | email: result.data.email, 22 | }; 23 | }, 24 | login: async ({ email, password }) => { 25 | try { 26 | let result = await client.query<{ 27 | instance: values.Ref; 28 | }>(q.Call("LoginUser", [email, password])); 29 | 30 | if (!result?.instance?.id) { 31 | return null; 32 | } 33 | 34 | return { 35 | id: result.instance.id, 36 | email, 37 | }; 38 | } catch (error) { 39 | if (error instanceof errors.BadRequest) { 40 | return null; 41 | } 42 | throw error; 43 | } 44 | }, 45 | signup: async ({ email, password }) => { 46 | try { 47 | let result = await client.query<{ 48 | ref: values.Ref; 49 | data: { email: string }; 50 | }>(q.Call("CreateUser", [email, password])); 51 | 52 | return { 53 | id: result.ref.id, 54 | email: result.data.email, 55 | }; 56 | } catch (error) { 57 | if (error instanceof errors.BadRequest) { 58 | return null; 59 | } 60 | throw error; 61 | } 62 | }, 63 | 64 | // Project functions 65 | createProject: async ({ userId, name }) => { 66 | let result = await client.query<{ ref: values.Ref }>( 67 | q.Call("CreateProject", [name, userId]) 68 | ); 69 | 70 | return result.ref.id; 71 | }, 72 | deleteProjectById: async ({ projectId, userId }) => { 73 | await client.query(q.Call("DeleteProjectById", [projectId, userId])); 74 | 75 | return projectId; 76 | }, 77 | getProjectById: async ({ projectId, userId }) => { 78 | try { 79 | let result = await client.query< 80 | | false 81 | | { 82 | ref: values.Ref; 83 | data: { name: string; userId: string }; 84 | } 85 | >(q.Call("GetProjectById", [projectId, userId])); 86 | 87 | if (!result) { 88 | return null; 89 | } 90 | 91 | return { 92 | id: result.ref.id, 93 | name: result.data.name, 94 | }; 95 | } catch (error) { 96 | if (error instanceof errors.NotFound) { 97 | return null; 98 | } 99 | throw error; 100 | } 101 | }, 102 | getProjectsByUserId: async (userId) => { 103 | try { 104 | const result = await client.query<{ 105 | data: { 106 | ref: values.Ref; 107 | data: { name: string; userId: string }; 108 | }[]; 109 | }>(q.Call("GetProjectsByUserId", [userId])); 110 | 111 | return result.data.map((item) => ({ 112 | id: item.ref.id, 113 | name: item.data.name, 114 | })); 115 | } catch (error) { 116 | logger.captureException(error); 117 | return null; 118 | } 119 | }, 120 | 121 | // Flag functions 122 | createFlag: async ({ enabled, name, projectId, userId }) => { 123 | let result = await client.query( 124 | q.Call("CreateFlag", [name, enabled, projectId, userId]) 125 | ); 126 | 127 | if (!result) throw new Error("Failed to create flag"); 128 | 129 | return result.ref.id; 130 | }, 131 | deleteFlagById: async ({ flagId, userId }) => { 132 | await client.query(q.Call("DeleteFlagById", [flagId, userId])); 133 | 134 | return flagId; 135 | }, 136 | getFlagById: async ({ flagId, userId }) => { 137 | try { 138 | let result = await client.query<{ 139 | ref: values.Ref; 140 | data: { name: string; enabled: boolean; projectId: string }; 141 | }>(q.Call("GetFlagById", [flagId, userId])); 142 | 143 | if (!result) return null; 144 | 145 | return { 146 | id: result.ref.id, 147 | name: result.data.name, 148 | enabled: result.data.enabled, 149 | }; 150 | } catch (error) { 151 | logger.captureException(error); 152 | return null; 153 | } 154 | }, 155 | getFlagsByProjectId: async ({ projectId, userId }) => { 156 | let results = await client.query<{ 157 | data: { 158 | ref: values.Ref; 159 | data: { name: string; enabled: boolean; projectId: string }; 160 | }[]; 161 | }>(q.Call("GetFlagsByProjectId", [projectId, userId])); 162 | 163 | if (!results) return []; 164 | 165 | return results.data.map((item) => ({ 166 | id: item.ref.id, 167 | name: item.data.name, 168 | enabled: item.data.enabled, 169 | })); 170 | }, 171 | getFlagsByProjectIdWithToken: async ({ projectId, token }) => { 172 | let results = await client.query<{ 173 | data: { 174 | ref: values.Ref; 175 | data: { name: string; enabled: boolean; projectId: string }; 176 | }[]; 177 | }>(q.Call("GetFlagsByProjectIdWithToken", [projectId, token])); 178 | 179 | if (!results) return []; 180 | 181 | return results.data.map((item) => ({ 182 | id: item.ref.id, 183 | name: item.data.name, 184 | enabled: item.data.enabled, 185 | })); 186 | }, 187 | setFlagEnabled: async ({ flagId, enabled, userId }) => { 188 | let result = await client.query( 189 | q.Call("SetFlagEnabled", [flagId, enabled, userId]) 190 | ); 191 | 192 | if (!result) throw new Error("Failed to set flag enabled"); 193 | }, 194 | 195 | // Token functions 196 | createToken: async ({ name, projectId, userId }) => { 197 | let secret = nanoid() + nanoid(); 198 | let result = await client.query( 199 | q.Call("CreateToken", [name, secret, projectId, userId]) 200 | ); 201 | 202 | if (!result) throw new Error("Failed to create token"); 203 | 204 | return secret; 205 | }, 206 | getTokenById: async ({ tokenId, userId }) => { 207 | try { 208 | let result = await client.query<{ 209 | ref: values.Ref; 210 | data: { name: string; projectId: string }; 211 | }>(q.Call("GetTokenById", [tokenId, userId])); 212 | 213 | if (!result) return null; 214 | 215 | return { 216 | id: result.ref.id, 217 | name: result.data.name, 218 | }; 219 | } catch (error) { 220 | logger.captureException(error); 221 | return null; 222 | } 223 | }, 224 | getTokensByProjectId: async ({ projectId, userId }) => { 225 | let results = await client.query<{ 226 | data: { 227 | ref: values.Ref; 228 | data: { name: string; projectId: string }; 229 | }[]; 230 | }>(q.Call("GetTokensByProjectId", [projectId, userId])); 231 | 232 | return results.data.map((item) => ({ 233 | id: item.ref.id, 234 | name: item.data.name, 235 | })); 236 | }, 237 | deleteTokenById: async ({ tokenId, userId }) => { 238 | await client.query(q.Call("DeleteTokenById", [tokenId, userId])); 239 | 240 | return tokenId; 241 | }, 242 | }; 243 | } 244 | --------------------------------------------------------------------------------