diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..87876d3 Binary files /dev/null and b/.DS_Store differ diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..4e4b960 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +/* +!/src \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..701ab02 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,88 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true + }, + extends: [ + 'eslint:recommended', + 'plugin:import/recommended', + 'plugin:import/typescript', + 'plugin:@typescript-eslint/strict-type-checked', + 'prettier' + ], + overrides: [], + parser: "@typescript-eslint/parser", + parserOptions: { + project: './tsconfig.json' + }, + plugins: [ + "@typescript-eslint", + "import" + ], + root: true, + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + "ignoreRestSiblings": true, + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], + "@typescript-eslint/consistent-type-definitions": [ + "warn", + "type" + ], + "@typescript-eslint/consistent-type-imports": [ + "error" + ], + "sort-imports": [ + "warn", + { + ignoreCase: false, + ignoreDeclarationSort: true, // don"t want to sort import lines, use eslint-plugin-import instead + ignoreMemberSort: false, + memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], + allowSeparatedGroups: true, + }, + ], + + "import/no-unresolved": "error", + "import/newline-after-import": [ + "warn", + { + "count": 1 + } + ], + 'import/order': [ + 'warn', + { + groups: [ + 'builtin', // Built-in imports (come from NodeJS native) go first + 'external', // <- External imports + 'internal', // <- Absolute imports + ['sibling', 'parent'], // <- Relative imports, the sibling and parent types they can be mingled together + 'index', // <- index imports + 'unknown', // <- unknown + ], + 'newlines-between': 'ignore', + alphabetize: { + /* sort in ascending order. Options: ["ignore", "asc", "desc"] */ + order: 'asc', + /* ignore case. Options: [true, false] */ + caseInsensitive: true, + }, + }, + ] + }, + settings: { + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"] + }, + "import/resolver": { + "typescript": true + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1dde9d7..b512c09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1 @@ -.DS_Store -node_modules -/build -/.svelte-kit -/package -.env -.env.* -!.env.example -vite.config.js.timestamp-* -vite.config.ts.timestamp-* -sqlite.db -TODO.txt - -# sst -.sst -sst.config.ts -src/sst-env.d.ts \ No newline at end of file +node_modules \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..5f7b9ac --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "semi": false, + "singleQuote": true, + "arrowParens": "avoid", + "printWidth": 100, + "quoteProps": "consistent", + "bracketSpacing": true, + "htmlWhitespaceSensitivity": "ignore", + "bracketSameLine": true, + "useTabs": false +} diff --git a/LICENSE b/LICENSE index 0b9c381..aca6dbd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Passlock +Copyright (c) 2023 Passlock Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 02c9e1b..89a2d8f 100644 --- a/README.md +++ b/README.md @@ -1,264 +1,138 @@ -
- - -
+ Typescript library for next generation authentication. Passkeys, Apple login, Google one-tap and more..
- SvelteKit authentication template project featuring Passkeys, social login (Apple and Google), mailbox verification and much more. Preline and Shadcn variants.
-
- Demo (Preline) | Demo (Shadcn)
+ Project website ยป
+
+ Demo
+ ยท
+ Documentation
+ ยท
+ Tutorial
Creating a new account and passkey
-Shadcn/ui variant (dark mode)
- -# Frameworks used - -1. [Passlock][passlock] - Serverless passkey platform -2. [Superforms][superforms] - Makes form handling a breeze -3. [Lucia][lucia] - Robust session management -4. [Tailwind][tailwind] - Utility-first CSS framework -5. [Preline][preline] - Tailwind UI library 1 -6. [shadcn-svelte][shadcn-svelte] - Tailwind components for Svelte 2 -7. [Melt UI][meltui] - Headless component library for Svelte - -[1] Uses native Svelte in place of Preline JavaScript -[2] See the [shadcn branch](#shadcnui-variant) - - - -# About - -The future of web authenticaton lies in [Passkeys][google-passkeys]. Learn how to add Passkey authentication to your SvelteKit app, perform facial or fingerprint recognition and more. You'll also learn how to use some of SvelteKit's hottest libraries and implement Google's latest social sign in feature. - -# Demos - -I've deployed 2 live versions of this project: - -- [Master demo](https://d1rl0ue18b0151.cloudfront.net) - A version of the master branch (uses Preline + Melt UI) - -- [Shadcn demo](https://dbr4qrmypnl85.cloudfront.net) - A version of the shadcn branch (uses shadcn-svelte) - -# Getting started - -## Prerequisites - -This example project uses the cloud based [Passlock][passlock] framework for passkey registration and authentication. **Passlock is free for personal and commercial use**. Create an account at [passlock.dev][passlock-signup] - -## Clone this repo - -`git clone git@github.com:passlock-dev/svelte-passkeys.git` - -## Install the dependencies - -``` -cd svelte-passkeys -npm install -``` - -## Set the environment variables - -You'll need to set four variables: - -1. PUBLIC_PASSLOCK_TENANCY_ID -2. PUBLIC_PASSLOCK_CLIENT_ID -3. PUBLIC_APPLE_CLIENT_ID 1 -4. PUBLIC_APPLE_REDIRECT_URL 1 -5. PUBLIC_GOOGLE_CLIENT_ID 1 -6. PASSLOCK_API_KEY +> [!IMPORTANT] +> **Looking for the SvelteKit templates?** You'll find the SvelteKit app templates in [apps/sveltekit](./apps/sveltekit/) -[1] Optional - If not using Apple/Google set to an empty string +## Features -### Where to find these variables +Passkeys and the WebAuthn API are quite complex. I've taken an opinionated approach to simplify things for you. Following the 80/20 principle, I've tried to focus on the features most valuable to developers and users. -Your Passlock Tenancy ID, Client ID and Api Key (token) can be found in your [Passlock console][passlock-console] under [settings][passlock-settings] and [API Keys][passlock-apikeys]. Please see the section [Sign in with google](#sign-in-with-google) if using Google sign in. +1. **๐ Primary or secondary authentication** - 2FA or a complete replacement for passwords -Create a `.env.local` file containing the relevant credentials. +2. **๐ Social login** - Supporting Apple & Google. GitHub coming soon.. -> [!TIP] -> Alternatively you can download a ready made .env file from your passlock console [settings][passlock-settings]: -> -> `Tenancy information -> Vite .env -> Download` +3. **โ๐ป Biometrics** - Frictionless facial or fingerprint recognition for your webapps - +4. **๐ฅ๏ธ Management console** - Suspend users, disable or revoke passkeys and more.. -# Usage +5. **๐ต๏ธ Audit trail** - View a full audit trail for each user -Start the dev server +6. **๐ฅ๏ธ Dev console** - Something not working? check the web console for details -`npm run dev` +7. **๐ Headless components** - You have 100% control over the UI -**Note:** by default this app runs on port 5174 when in dev mode (see [vite.config.ts](vite.config.ts)) +## Screen recording -## Register a passkey +https://github.com/user-attachments/assets/f1c21242-74cb-4739-8eff-fddb19cb3256 -Navigate to the [home page](http://localhost:5174/) page and complete the form. Assuming your browser supports passkeys (most do), you should be prompted to create a passkey. +## Screenshots -## Authenticate + +Demo app using this library for passkey and social login
-Logout then navigate to the [login](http://localhost:5174/login) page. You should be prompted to authenticate using your newly created passkey. + +Viewing a user's authentication activity on their profile page
-Shadcn/ui variant
- -The default (master) branch uses [Preline][preline], however a [shadcn-svelte] variant is also available: +```typescript +import { Passlock } from '@passlock/node' -```bash -git checkout -b shadcn origin/shadcn -``` +// API Keys can be found in your passlock console +const passlock = new Passlock({ tenancyId, apiKey }) -**IMPORTANT**: When switching between branches please re-install the NPM dependencies: +// token comes from your frontend +const principal = await passlock.fetchPrincipal({ token }) -```bash -rm -r node_modules pnpm-lock.yaml -pnpm install +// get the user id +console.log(principal.user.id) ``` - - -# Documentation - -Please see the [developer docs](./docs/intro.md) - -# Questions? Problems +## More information -Please file an [issue][issues] and I'll respond ASAP. +Please see the [tutorial][tutorial] and [documentation][docs] -[passlock]: https://passlock.dev -[lucia]: https://lucia-auth.com -[tailwind]: https://tailwindcss.com -[preline]: https://preline.co -[meltui]: https://melt-ui.com -[shadcn-svelte]: https://www.shadcn-svelte.com -[passlock-signup]: https://console.passlock.dev/register -[passlock-console]: https://console.passlock.dev -[passlock-settings]: https://console.passlock.dev/settings -[passlock-apikeys]: https://console.passlock.dev/apikeys -[google-signin]: https://developers.google.com/identity/gsi/web/guides/overview -[google-client-id]: https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid#get_your_google_api_client_id -[issues]: https://github.com/passlock-dev/svelte-passkeys/issues -[superforms]: https://superforms.rocks -[apple-verification-codes]: https://www.cultofmac.com/819421/ios-17-autofill-verification-codes-safari-mail-app/ -[google-passkeys]: https://safety.google/authentication/passkey/ +[contact]: https://passlock.dev/contact +[tutorial]: https://docs.passlock.dev/docs/tutorial/introduction +[docs]: https://docs.passlock.dev +[node]: https://www.npmjs.com/package/@passlock/node +[melt]: https://melt-ui.com +[shadcn]: https://www.shadcn-svelte.com diff --git a/README_assets/console.png b/README_assets/console.png new file mode 100644 index 0000000..e473dde Binary files /dev/null and b/README_assets/console.png differ diff --git a/README_assets/passlock-demo.mp4 b/README_assets/passlock-demo.mp4 new file mode 100644 index 0000000..cd1da07 Binary files /dev/null and b/README_assets/passlock-demo.mp4 differ diff --git a/README_assets/preline.dark.png b/README_assets/preline.dark.png new file mode 100644 index 0000000..f7b1370 Binary files /dev/null and b/README_assets/preline.dark.png differ diff --git a/docs/preline.png b/README_assets/preline.png similarity index 100% rename from docs/preline.png rename to README_assets/preline.png diff --git a/README_assets/repo-banner.dark.svg b/README_assets/repo-banner.dark.svg new file mode 100644 index 0000000..0851ee4 --- /dev/null +++ b/README_assets/repo-banner.dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/README_assets/repo-banner.svg b/README_assets/repo-banner.svg new file mode 100644 index 0000000..73f534f --- /dev/null +++ b/README_assets/repo-banner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/shadcn.png b/README_assets/shadcn.png similarity index 100% rename from docs/shadcn.png rename to README_assets/shadcn.png diff --git a/apps/.DS_Store b/apps/.DS_Store new file mode 100644 index 0000000..22a5e5a Binary files /dev/null and b/apps/.DS_Store differ diff --git a/apps/sveltekit/.gitignore b/apps/sveltekit/.gitignore new file mode 100644 index 0000000..7309a2d --- /dev/null +++ b/apps/sveltekit/.gitignore @@ -0,0 +1,2 @@ +preline-sst +shadcn-sst \ No newline at end of file diff --git a/apps/sveltekit/README.md b/apps/sveltekit/README.md new file mode 100644 index 0000000..60498f6 --- /dev/null +++ b/apps/sveltekit/README.md @@ -0,0 +1,267 @@ + +
+
+ SvelteKit authentication template featuring Passkeys, social login (Apple and Google), mailbox verification and much more.
Preline and Shadcn variants available.
+
+ Demo (Preline) | Demo (Shadcn) +
+Creating a new account and passkey
+ +Shadcn/ui variant (dark mode)
+ +# Frameworks used + +1. [Passlock][passlock] - Serverless passkey platform +2. [Superforms][superforms] - Makes form handling a breeze +3. [Lucia][lucia] - Robust session management +4. [Tailwind][tailwind] - Utility-first CSS framework +5. [Preline][preline] - Tailwind UI library 1 +6. [shadcn-svelte][shadcn-svelte] - Tailwind components for Svelte 2 +7. [Melt UI][meltui] - Headless component library for Svelte + +[1] Uses native Svelte in place of Preline JavaScript +[2] See the [shadcn branch](#shadcnui-variant) + + + +# About + +The future of web authenticaton lies in [Passkeys][google-passkeys]. Learn how to add Passkey authentication to your SvelteKit app, perform facial or fingerprint recognition and more. You'll also learn how to use some of SvelteKit's hottest libraries and implement Google's latest social sign in feature. + +# Demos + +I've deployed 2 live versions of this project: + +- [Master demo](https://d1rl0ue18b0151.cloudfront.net) - A version of the master branch (uses Preline + Melt UI) + +- [Shadcn demo](https://dbr4qrmypnl85.cloudfront.net) - A version of the shadcn branch (uses shadcn-svelte) + +# Getting started + +## Prerequisites + +This example project uses the cloud based [Passlock][passlock] framework for passkey registration and authentication. **Passlock is free for personal and commercial use**. Create an account at [passlock.dev][passlock-signup] + +## Clone this repo + +`git clone git@github.com:passlock-dev/passkeys.git` + +## Navigate to the template + +`cd apps/sveltekit/preline` or `cd apps/sveltekit/shadcn` + +## Install the dependencies + +``` +npm install +``` + +## Set the environment variables + +You'll need to set four variables: + +1. PUBLIC_PASSLOCK_TENANCY_ID +2. PUBLIC_PASSLOCK_CLIENT_ID +3. PUBLIC_APPLE_CLIENT_ID 1 +4. PUBLIC_APPLE_REDIRECT_URL 1 +5. PUBLIC_GOOGLE_CLIENT_ID 1 +6. PASSLOCK_API_KEY + +[1] Optional - If not using Apple/Google set to an empty string + +### Where to find these variables + +Your Passlock Tenancy ID, Client ID and Api Key (token) can be found in your [Passlock console][passlock-console] under [settings][passlock-settings] and [API Keys][passlock-apikeys]. Please see the section [Sign in with google](#sign-in-with-google) if using Google sign in. + +Create a `.env.local` file containing the relevant credentials. + +> [!TIP] +> Alternatively you can download a ready made .env file from your passlock console [settings][passlock-settings]: +> +> `Tenancy information -> Vite .env -> Download` + + + +# Usage + +Start the dev server + +`npm run dev` + +**Note:** by default this app runs on port 5174 when in dev mode (see [vite.config.ts](vite.config.ts)) + +## Register a passkey + +Navigate to the [home page](http://localhost:5174/) page and complete the form. Assuming your browser supports passkeys (most do), you should be prompted to create a passkey. + +## Authenticate + +Logout then navigate to the [login](http://localhost:5174/login) page. You should be prompted to authenticate using your newly created passkey. + +Shadcn/ui variant
+ +The default (master) branch uses [Preline][preline], however a [shadcn-svelte] variant is also available: + +```bash +git checkout -b shadcn origin/shadcn +``` + +**IMPORTANT**: When switching between branches please re-install the NPM dependencies: + +```bash +rm -r node_modules pnpm-lock.yaml +pnpm install +``` + + + +# Documentation + +Please see the [developer docs](./preline/docs/intro.md) + +# Questions? Problems + +Please file an [issue][issues] and I'll respond ASAP. + +[passlock]: https://passlock.dev +[lucia]: https://lucia-auth.com +[tailwind]: https://tailwindcss.com +[preline]: https://preline.co +[meltui]: https://melt-ui.com +[shadcn-svelte]: https://www.shadcn-svelte.com +[passlock-signup]: https://console.passlock.dev/register +[passlock-console]: https://console.passlock.dev +[passlock-settings]: https://console.passlock.dev/settings +[passlock-apikeys]: https://console.passlock.dev/apikeys +[google-signin]: https://developers.google.com/identity/gsi/web/guides/overview +[google-client-id]: https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid#get_your_google_api_client_id +[issues]: https://github.com/passlock-dev/svelte-passkeys/issues +[superforms]: https://superforms.rocks +[apple-verification-codes]: https://www.cultofmac.com/819421/ios-17-autofill-verification-codes-safari-mail-app/ +[google-passkeys]: https://safety.google/authentication/passkey/ diff --git a/apps/sveltekit/README_assets/preline.png b/apps/sveltekit/README_assets/preline.png new file mode 100644 index 0000000..cee32d8 Binary files /dev/null and b/apps/sveltekit/README_assets/preline.png differ diff --git a/apps/sveltekit/README_assets/repo-banner.dark.svg b/apps/sveltekit/README_assets/repo-banner.dark.svg new file mode 100644 index 0000000..85ee444 --- /dev/null +++ b/apps/sveltekit/README_assets/repo-banner.dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/sveltekit/README_assets/repo-banner.svg b/apps/sveltekit/README_assets/repo-banner.svg new file mode 100644 index 0000000..ab2bdeb --- /dev/null +++ b/apps/sveltekit/README_assets/repo-banner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/sveltekit/README_assets/shadcn.png b/apps/sveltekit/README_assets/shadcn.png new file mode 100644 index 0000000..fbc657d Binary files /dev/null and b/apps/sveltekit/README_assets/shadcn.png differ diff --git a/.env.example b/apps/sveltekit/preline/.env.example similarity index 100% rename from .env.example rename to apps/sveltekit/preline/.env.example diff --git a/apps/sveltekit/preline/.gitignore b/apps/sveltekit/preline/.gitignore new file mode 100644 index 0000000..1dde9d7 --- /dev/null +++ b/apps/sveltekit/preline/.gitignore @@ -0,0 +1,17 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +sqlite.db +TODO.txt + +# sst +.sst +sst.config.ts +src/sst-env.d.ts \ No newline at end of file diff --git a/.npmrc b/apps/sveltekit/preline/.npmrc similarity index 100% rename from .npmrc rename to apps/sveltekit/preline/.npmrc diff --git a/apps/sveltekit/preline/.prettierignore b/apps/sveltekit/preline/.prettierignore new file mode 100644 index 0000000..cc41cea --- /dev/null +++ b/apps/sveltekit/preline/.prettierignore @@ -0,0 +1,4 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/.prettierrc b/apps/sveltekit/preline/.prettierrc similarity index 100% rename from .prettierrc rename to apps/sveltekit/preline/.prettierrc diff --git a/apps/sveltekit/preline/LICENSE b/apps/sveltekit/preline/LICENSE new file mode 100644 index 0000000..0b9c381 --- /dev/null +++ b/apps/sveltekit/preline/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Passlock + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/intro.md b/apps/sveltekit/preline/docs/intro.md similarity index 95% rename from docs/intro.md rename to apps/sveltekit/preline/docs/intro.md index 22f55f5..7c286e6 100644 --- a/docs/intro.md +++ b/apps/sveltekit/preline/docs/intro.md @@ -35,12 +35,6 @@ However, Melt UI and Preline CSS classes are an awesome combination. We use **Pr > [!NOTE] > Unlike [shadcn-svelte][shadcn-svelte], I haven't ported the entire Preline framework across to Svelte. I've simply used Melt UI to build the components required by this app. However as you will see, it's really easy to build a component using Melt (or [bits-ui][bitsui]) -## shadcn-svelte - -Shadcn/ui is a collection of tailwind components. [Shadcn-svelte][shadcn-svelte] is a excellent Svelte port, built on top of [bits-ui][bitsui] (which is itself built on Melt UI). - -So you have a choice in styling between Shadcn/ui or Preline. Ultimately the behavior is handled by Melt UI. - # The code The code is quite well commented so please check it out. I'll give a quite overview of the operations, with pointers to the relevant source code. diff --git a/apps/sveltekit/preline/package.json b/apps/sveltekit/preline/package.json new file mode 100644 index 0000000..0e8bd3b --- /dev/null +++ b/apps/sveltekit/preline/package.json @@ -0,0 +1,54 @@ +{ + "name": "sveltekit-template", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev --host", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:errors": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --threshold error", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "typecheck": "pnpm run check:errors", + "format": "prettier --write .", + "ncu": "ncu --peer -x @passlock/sveltekit -x better-sqlite3 -x postcss-load-config", + "ncu:save": "ncu --peer -x @passlock/sveltekit -x better-sqlite3 -x postcss-load-config -u", + "pnpm:upgrade": "corepack use pnpm@latest" + }, + "devDependencies": { + "@lucia-auth/adapter-sqlite": "^3.0.2", + "@melt-ui/pp": "^0.3.2", + "@melt-ui/svelte": "^0.83.0", + "@passlock/sveltekit": "0.9.20", + "@sveltejs/adapter-auto": "^3.2.2", + "@sveltejs/kit": "^2.5.18", + "@sveltejs/vite-plugin-svelte": "^3.1.1", + "@tailwindcss/aspect-ratio": "^0.4.2", + "@tailwindcss/forms": "^0.5.7", + "@types/apple-signin-api": "^1.5.3", + "@types/better-sqlite3": "^7.6.11", + "@types/google-one-tap": "^1.2.6", + "autoprefixer": "^10.4.19", + "better-sqlite3": "~9.6.0", + "dedent": "^1.5.3", + "js-sha256": "^0.11.0", + "lucia": "^3.2.0", + "lucide-svelte": "^0.399.0", + "mode-watcher": "^0.3.1", + "node-check-updates": "^0.1.9", + "postcss": "^8.4.39", + "postcss-load-config": "^5.1.0", + "prettier": "^3.3.2", + "prettier-plugin-svelte": "^3.2.5", + "svelte": "^4.2.18", + "svelte-check": "^3.8.4", + "sveltekit-superforms": "^2.16.0", + "tailwindcss": "^3.4.4", + "tslib": "^2.6.3", + "typescript": "^5.5.3", + "valibot": "^0.36.0", + "vite": "^5.3.3" + }, + "type": "module", + "packageManager": "pnpm@9.5.0+sha256.dbdf5961c32909fb030595a9daa1dae720162e658609a8f92f2fa99835510ca5" +} diff --git a/postcss.config.cjs b/apps/sveltekit/preline/postcss.config.cjs similarity index 100% rename from postcss.config.cjs rename to apps/sveltekit/preline/postcss.config.cjs diff --git a/src/app.d.ts b/apps/sveltekit/preline/src/app.d.ts similarity index 100% rename from src/app.d.ts rename to apps/sveltekit/preline/src/app.d.ts diff --git a/src/app.html b/apps/sveltekit/preline/src/app.html similarity index 100% rename from src/app.html rename to apps/sveltekit/preline/src/app.html diff --git a/src/app.pcss b/apps/sveltekit/preline/src/app.pcss similarity index 100% rename from src/app.pcss rename to apps/sveltekit/preline/src/app.pcss diff --git a/src/hooks.server.ts b/apps/sveltekit/preline/src/hooks.server.ts similarity index 100% rename from src/hooks.server.ts rename to apps/sveltekit/preline/src/hooks.server.ts diff --git a/src/lib/components/icons/Apple.svelte b/apps/sveltekit/preline/src/lib/components/icons/Apple.svelte similarity index 100% rename from src/lib/components/icons/Apple.svelte rename to apps/sveltekit/preline/src/lib/components/icons/Apple.svelte diff --git a/src/lib/components/icons/FieldError.svelte b/apps/sveltekit/preline/src/lib/components/icons/FieldError.svelte similarity index 100% rename from src/lib/components/icons/FieldError.svelte rename to apps/sveltekit/preline/src/lib/components/icons/FieldError.svelte diff --git a/src/lib/components/icons/Google.svelte b/apps/sveltekit/preline/src/lib/components/icons/Google.svelte similarity index 100% rename from src/lib/components/icons/Google.svelte rename to apps/sveltekit/preline/src/lib/components/icons/Google.svelte diff --git a/src/lib/components/icons/Passkey.svelte b/apps/sveltekit/preline/src/lib/components/icons/Passkey.svelte similarity index 100% rename from src/lib/components/icons/Passkey.svelte rename to apps/sveltekit/preline/src/lib/components/icons/Passkey.svelte diff --git a/src/lib/components/icons/index.ts b/apps/sveltekit/preline/src/lib/components/icons/index.ts similarity index 100% rename from src/lib/components/icons/index.ts rename to apps/sveltekit/preline/src/lib/components/icons/index.ts diff --git a/src/lib/components/layout/Banner.svelte b/apps/sveltekit/preline/src/lib/components/layout/Banner.svelte similarity index 100% rename from src/lib/components/layout/Banner.svelte rename to apps/sveltekit/preline/src/lib/components/layout/Banner.svelte diff --git a/src/lib/components/layout/Footer.svelte b/apps/sveltekit/preline/src/lib/components/layout/Footer.svelte similarity index 100% rename from src/lib/components/layout/Footer.svelte rename to apps/sveltekit/preline/src/lib/components/layout/Footer.svelte diff --git a/src/lib/components/layout/Header.svelte b/apps/sveltekit/preline/src/lib/components/layout/Header.svelte similarity index 100% rename from src/lib/components/layout/Header.svelte rename to apps/sveltekit/preline/src/lib/components/layout/Header.svelte diff --git a/src/lib/components/layout/Link.svelte b/apps/sveltekit/preline/src/lib/components/layout/Link.svelte similarity index 100% rename from src/lib/components/layout/Link.svelte rename to apps/sveltekit/preline/src/lib/components/layout/Link.svelte diff --git a/src/lib/components/layout/MenuBar.svelte b/apps/sveltekit/preline/src/lib/components/layout/MenuBar.svelte similarity index 100% rename from src/lib/components/layout/MenuBar.svelte rename to apps/sveltekit/preline/src/lib/components/layout/MenuBar.svelte diff --git a/src/lib/components/layout/RegisterButton.svelte b/apps/sveltekit/preline/src/lib/components/layout/RegisterButton.svelte similarity index 100% rename from src/lib/components/layout/RegisterButton.svelte rename to apps/sveltekit/preline/src/lib/components/layout/RegisterButton.svelte diff --git a/src/lib/components/layout/ThemeSelector.svelte b/apps/sveltekit/preline/src/lib/components/layout/ThemeSelector.svelte similarity index 100% rename from src/lib/components/layout/ThemeSelector.svelte rename to apps/sveltekit/preline/src/lib/components/layout/ThemeSelector.svelte diff --git a/src/lib/components/layout/language-selector/DE.svelte b/apps/sveltekit/preline/src/lib/components/layout/language-selector/DE.svelte similarity index 100% rename from src/lib/components/layout/language-selector/DE.svelte rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/DE.svelte diff --git a/src/lib/components/layout/language-selector/DK.svelte b/apps/sveltekit/preline/src/lib/components/layout/language-selector/DK.svelte similarity index 100% rename from src/lib/components/layout/language-selector/DK.svelte rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/DK.svelte diff --git a/src/lib/components/layout/language-selector/EN.svelte b/apps/sveltekit/preline/src/lib/components/layout/language-selector/EN.svelte similarity index 100% rename from src/lib/components/layout/language-selector/EN.svelte rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/EN.svelte diff --git a/src/lib/components/layout/language-selector/IT.svelte b/apps/sveltekit/preline/src/lib/components/layout/language-selector/IT.svelte similarity index 100% rename from src/lib/components/layout/language-selector/IT.svelte rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/IT.svelte diff --git a/src/lib/components/layout/language-selector/JP.svelte b/apps/sveltekit/preline/src/lib/components/layout/language-selector/JP.svelte similarity index 100% rename from src/lib/components/layout/language-selector/JP.svelte rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/JP.svelte diff --git a/src/lib/components/layout/language-selector/Selector.svelte b/apps/sveltekit/preline/src/lib/components/layout/language-selector/Selector.svelte similarity index 100% rename from src/lib/components/layout/language-selector/Selector.svelte rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/Selector.svelte diff --git a/src/lib/components/layout/language-selector/index.ts b/apps/sveltekit/preline/src/lib/components/layout/language-selector/index.ts similarity index 100% rename from src/lib/components/layout/language-selector/index.ts rename to apps/sveltekit/preline/src/lib/components/layout/language-selector/index.ts diff --git a/src/lib/components/layout/mobile/MobileAvatar.svelte b/apps/sveltekit/preline/src/lib/components/layout/mobile/MobileAvatar.svelte similarity index 100% rename from src/lib/components/layout/mobile/MobileAvatar.svelte rename to apps/sveltekit/preline/src/lib/components/layout/mobile/MobileAvatar.svelte diff --git a/src/lib/components/layout/mobile/MobileMenu.svelte b/apps/sveltekit/preline/src/lib/components/layout/mobile/MobileMenu.svelte similarity index 100% rename from src/lib/components/layout/mobile/MobileMenu.svelte rename to apps/sveltekit/preline/src/lib/components/layout/mobile/MobileMenu.svelte diff --git a/src/lib/components/layout/mobile/MobileMenuBar.svelte b/apps/sveltekit/preline/src/lib/components/layout/mobile/MobileMenuBar.svelte similarity index 100% rename from src/lib/components/layout/mobile/MobileMenuBar.svelte rename to apps/sveltekit/preline/src/lib/components/layout/mobile/MobileMenuBar.svelte diff --git a/src/lib/components/layout/mobile/MobileMenuToggle.svelte b/apps/sveltekit/preline/src/lib/components/layout/mobile/MobileMenuToggle.svelte similarity index 100% rename from src/lib/components/layout/mobile/MobileMenuToggle.svelte rename to apps/sveltekit/preline/src/lib/components/layout/mobile/MobileMenuToggle.svelte diff --git a/src/lib/components/ui/forms/CenteredPanel.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/CenteredPanel.svelte similarity index 100% rename from src/lib/components/ui/forms/CenteredPanel.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/CenteredPanel.svelte diff --git a/src/lib/components/ui/forms/Checkbox.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/Checkbox.svelte similarity index 100% rename from src/lib/components/ui/forms/Checkbox.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/Checkbox.svelte diff --git a/src/lib/components/ui/forms/Divider.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/Divider.svelte similarity index 100% rename from src/lib/components/ui/forms/Divider.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/Divider.svelte diff --git a/src/lib/components/ui/forms/FormErrors.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/FormErrors.svelte similarity index 100% rename from src/lib/components/ui/forms/FormErrors.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/FormErrors.svelte diff --git a/src/lib/components/ui/forms/Heading.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/Heading.svelte similarity index 100% rename from src/lib/components/ui/forms/Heading.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/Heading.svelte diff --git a/src/lib/components/ui/forms/InputEmail.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/InputEmail.svelte similarity index 100% rename from src/lib/components/ui/forms/InputEmail.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/InputEmail.svelte diff --git a/src/lib/components/ui/forms/InputText.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/InputText.svelte similarity index 100% rename from src/lib/components/ui/forms/InputText.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/InputText.svelte diff --git a/src/lib/components/ui/forms/MultiFieldPIN.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/MultiFieldPIN.svelte similarity index 100% rename from src/lib/components/ui/forms/MultiFieldPIN.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/MultiFieldPIN.svelte diff --git a/src/lib/components/ui/forms/PoweredBy.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/PoweredBy.svelte similarity index 100% rename from src/lib/components/ui/forms/PoweredBy.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/PoweredBy.svelte diff --git a/src/lib/components/ui/forms/SingleFieldPIN.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/SingleFieldPIN.svelte similarity index 100% rename from src/lib/components/ui/forms/SingleFieldPIN.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/SingleFieldPIN.svelte diff --git a/src/lib/components/ui/forms/SubHeading.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/SubHeading.svelte similarity index 100% rename from src/lib/components/ui/forms/SubHeading.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/SubHeading.svelte diff --git a/src/lib/components/ui/forms/SubmitButton.svelte b/apps/sveltekit/preline/src/lib/components/ui/forms/SubmitButton.svelte similarity index 100% rename from src/lib/components/ui/forms/SubmitButton.svelte rename to apps/sveltekit/preline/src/lib/components/ui/forms/SubmitButton.svelte diff --git a/src/lib/components/ui/forms/index.ts b/apps/sveltekit/preline/src/lib/components/ui/forms/index.ts similarity index 100% rename from src/lib/components/ui/forms/index.ts rename to apps/sveltekit/preline/src/lib/components/ui/forms/index.ts diff --git a/src/lib/components/ui/forms/utils.ts b/apps/sveltekit/preline/src/lib/components/ui/forms/utils.ts similarity index 100% rename from src/lib/components/ui/forms/utils.ts rename to apps/sveltekit/preline/src/lib/components/ui/forms/utils.ts diff --git a/src/lib/components/ui/social/Apple.svelte b/apps/sveltekit/preline/src/lib/components/ui/social/Apple.svelte similarity index 100% rename from src/lib/components/ui/social/Apple.svelte rename to apps/sveltekit/preline/src/lib/components/ui/social/Apple.svelte diff --git a/src/lib/components/ui/social/Google.svelte b/apps/sveltekit/preline/src/lib/components/ui/social/Google.svelte similarity index 100% rename from src/lib/components/ui/social/Google.svelte rename to apps/sveltekit/preline/src/lib/components/ui/social/Google.svelte diff --git a/src/lib/components/ui/social/index.ts b/apps/sveltekit/preline/src/lib/components/ui/social/index.ts similarity index 100% rename from src/lib/components/ui/social/index.ts rename to apps/sveltekit/preline/src/lib/components/ui/social/index.ts diff --git a/src/lib/routes.ts b/apps/sveltekit/preline/src/lib/routes.ts similarity index 96% rename from src/lib/routes.ts rename to apps/sveltekit/preline/src/lib/routes.ts index 2b088b5..5bd7beb 100644 --- a/src/lib/routes.ts +++ b/apps/sveltekit/preline/src/lib/routes.ts @@ -4,7 +4,7 @@ */ import { resolveRoute } from '$app/paths' -import type RouteMetadata from '../../.svelte-kit/types/route_meta_data.json' +import type RouteMetadata from '../../route_meta_data.json' type RouteMetadata = typeof RouteMetadata type Prettify
+
+ Demo + - Please try registration, email verification & login +
++ Enter your email below to create your account +
++ By creating an account, you agree to our + + Terms of Service + + and + + Privacy Policy + + . +
++ Enter your email below to login to your account +
+ in the url
+ * @returns
+ */
+export const extractCodeFromHref = () => {
+ return pipe(
+ URLQueryString,
+ E.flatMap(identity),
+ E.map(search => new URLSearchParams(search)),
+ E.flatMap(params => O.fromNullable(params.get('code'))),
+ )
+}
+
+/* Effects */
+
+/**
+ * Verify the mailbox using the given code
+ * @param request
+ * @returns
+ */
+export const verifyEmail = (
+ request: VerifyRequest,
+): E.Effect => {
+ return E.gen(function* (_) {
+ // Re-authenticate the user if required
+ const { token } = yield* _(getToken())
+
+ yield* _(E.logDebug('Making request'))
+ const client = yield* _(UserClient)
+ const { principal } = yield* _(
+ client.verifyEmail(new VerifyEmailReq({ token, code: request.code })),
+ )
+
+ return principal
+ })
+}
+
+/**
+ * Look for a code in the current url and verify it
+ * @returns
+ */
+export const verifyEmailLink = () =>
+ pipe(
+ extractCodeFromHref(),
+ E.mapError(() => new BadRequest({ message: 'Expected ?code=xxx in window.location' })),
+ E.flatMap(code => verifyEmail({ code })),
+ )
+
+/* Live */
+
+/* v8 ignore start */
+export const EmailServiceLive = Layer.effect(
+ EmailService,
+ E.gen(function* (_) {
+ const context = yield* _(
+ E.context(),
+ )
+ return EmailService.of({
+ verifyEmailCode: flow(verifyEmail, E.provide(context)),
+ verifyEmailLink: flow(verifyEmailLink, E.provide(context)),
+ })
+ }),
+)
+/* v8 ignore stop */
diff --git a/packages/client/src/event/event.node.test.ts b/packages/client/src/event/event.node.test.ts
new file mode 100644
index 0000000..a8918f3
--- /dev/null
+++ b/packages/client/src/event/event.node.test.ts
@@ -0,0 +1,20 @@
+import { Effect, pipe } from 'effect'
+import { describe, expect, test } from 'vitest'
+import { fireEvent } from './event.js'
+
+// @vitest-environment node
+
+describe('isPasslockEvent', () => {
+ test("return a Passlock error if custom events aren't supported", async () => {
+ const program = pipe(
+ fireEvent('hello world'),
+ Effect.flip,
+ Effect.tap(e => {
+ expect(e._tag).toBe('InternalBrowserError')
+ expect(e.message).toBe('Unable to fire custom event')
+ }),
+ )
+
+ await Effect.runPromise(program)
+ })
+})
diff --git a/packages/client/src/event/event.test.ts b/packages/client/src/event/event.test.ts
new file mode 100644
index 0000000..430244e
--- /dev/null
+++ b/packages/client/src/event/event.test.ts
@@ -0,0 +1,36 @@
+import { Effect } from 'effect'
+import { afterEach, describe, expect, test, vi } from 'vitest'
+import { DebugMessage, fireEvent, isPasslockEvent } from './event.js'
+
+describe('fireEvent', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ test('fire a custom log event', () => {
+ const effect = fireEvent('hello world')
+ const eventSpy = vi.spyOn(globalThis, 'dispatchEvent')
+ Effect.runSync(effect)
+
+ const expectedEvent = new CustomEvent(DebugMessage, {
+ detail: 'hello world',
+ })
+
+ expect(eventSpy).toHaveBeenCalledWith(expectedEvent)
+ })
+})
+
+describe('isPasslockEvent', () => {
+ test('return true for our custom event', () => {
+ const passlockEvent = new CustomEvent(DebugMessage, {
+ detail: 'hello world',
+ })
+
+ expect(isPasslockEvent(passlockEvent)).toBe(true)
+ })
+
+ test('return false for other events', () => {
+ const otherEvent = new MouseEvent('click')
+ expect(isPasslockEvent(otherEvent)).toBe(false)
+ })
+})
diff --git a/packages/client/src/event/event.ts b/packages/client/src/event/event.ts
new file mode 100644
index 0000000..60dacbc
--- /dev/null
+++ b/packages/client/src/event/event.ts
@@ -0,0 +1,24 @@
+/**
+ * Fire DOM events
+ */
+import { InternalBrowserError } from '@passlock/shared/dist/error/error.js'
+import { Effect } from 'effect'
+
+export const DebugMessage = 'PasslogDebugMessage'
+
+export const fireEvent = (message: string) => {
+ return Effect.try({
+ try: () => {
+ const evt = new CustomEvent(DebugMessage, { detail: message })
+ globalThis.dispatchEvent(evt)
+ },
+ catch: () => {
+ return new InternalBrowserError({ message: 'Unable to fire custom event' })
+ },
+ })
+}
+
+export function isPasslockEvent(event: Event): event is CustomEvent {
+ if (event.type !== DebugMessage) return false
+ return 'detail' in event
+}
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
new file mode 100644
index 0000000..5c0701a
--- /dev/null
+++ b/packages/client/src/index.ts
@@ -0,0 +1,349 @@
+import type {
+ BadRequest,
+ Disabled,
+ Duplicate,
+ Forbidden,
+ NotFound,
+ NotSupported,
+ Unauthorized,
+} from '@passlock/shared/dist/error/error.js'
+
+import { ErrorCode } from '@passlock/shared/dist/error/error.js'
+import { RpcConfig } from '@passlock/shared/dist/rpc/config.js'
+import type { Principal } from '@passlock/shared/dist/schema/schema.js'
+import { Effect as E, Layer as L, Layer, Option, Runtime, Scope, pipe } from 'effect'
+import { AuthenticationService, type AuthenticationRequest } from './authentication/authenticate.js'
+import { Capabilities } from './capabilities/capabilities.js'
+import { ConnectionService } from './connection/connection.js'
+import { allRequirements } from './effect.js'
+import { EmailService, type VerifyRequest } from './email/email.js'
+import { RegistrationService, type RegistrationRequest } from './registration/register.js'
+import { SocialService, type AuthenticateOidcReq, type RegisterOidcReq } from './social/social.js'
+import { Storage, StorageService, type AuthType, type StoredToken } from './storage/storage.js'
+import { UserService, type Email, type ResendEmail } from './user/user.js'
+
+/* Exports */
+
+export type Options = { signal?: AbortSignal }
+export type { Principal, UserVerification, VerifyEmail } from '@passlock/shared/dist/schema/schema.js'
+export type { AuthenticationRequest } from './authentication/authenticate.js'
+export type { VerifyRequest } from './email/email.js'
+export type { RegistrationRequest } from './registration/register.js'
+export type { AuthType, StoredToken } from './storage/storage.js'
+export type { Email } from './user/user.js'
+
+export type PasslockProps = {
+ tenancyId: string;
+ clientId: string;
+ endpoint?: string
+}
+
+export { ErrorCode } from '@passlock/shared/dist/error/error.js'
+
+export class PasslockError extends Error {
+ readonly code: ErrorCode
+ readonly detail: string | undefined
+
+ constructor(message: string, code: ErrorCode, detail?: string) {
+ super(message)
+ this.code = code
+ this.detail = detail
+ }
+
+ static readonly isError = (error: unknown): error is PasslockError => {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ error instanceof PasslockError
+ )
+ }
+}
+
+/* // Exports */
+
+type PasslockErrors =
+ | BadRequest
+ | NotSupported
+ | Duplicate
+ | Unauthorized
+ | Forbidden
+ | Disabled
+ | NotFound
+
+const hasMessage = (defect: unknown): defect is { message: string } => {
+ return (
+ typeof defect === 'object' &&
+ defect !== null &&
+ 'message' in defect &&
+ typeof defect['message'] === 'string'
+ )
+}
+
+const transformErrors = (
+ effect: E.Effect,
+): E.Effect => {
+ const withErrorHandling = E.catchTags(effect, {
+ NotSupported: e => E.succeed(new PasslockError(e.message, ErrorCode.NotSupported)),
+ BadRequest: e => E.succeed(new PasslockError(e.message, ErrorCode.BadRequest, e.detail)),
+ Duplicate: e => E.succeed(new PasslockError(e.message, ErrorCode.Duplicate, e.detail)),
+ Unauthorized: e => E.succeed(new PasslockError(e.message, ErrorCode.Unauthorized, e.detail)),
+ Forbidden: e => E.succeed(new PasslockError(e.message, ErrorCode.Forbidden, e.detail)),
+ Disabled: e => E.succeed(new PasslockError(e.message, ErrorCode.Disabled, e.detail)),
+ NotFound: e => E.succeed(new PasslockError(e.message, ErrorCode.NotFound, e.detail)),
+ })
+
+ const sandboxed = E.sandbox(withErrorHandling)
+
+ const withSandboxing = E.catchTags(sandboxed, {
+ Die: ({ defect }) => {
+ return hasMessage(defect)
+ ? E.succeed(new PasslockError(defect.message, ErrorCode.InternalServerError))
+ : E.succeed(new PasslockError('Sorry, something went wrong', ErrorCode.InternalServerError))
+ },
+
+ Interrupt: () => {
+ return E.succeed(new PasslockError('Operation aborted', ErrorCode.InternalBrowserError))
+ },
+
+ Sequential: errors => {
+ console.error(errors)
+ return E.succeed(
+ new PasslockError('Sorry, something went wrong', ErrorCode.InternalServerError),
+ )
+ },
+
+ Parallel: errors => {
+ console.error(errors)
+ return E.succeed(
+ new PasslockError('Sorry, something went wrong', ErrorCode.InternalServerError),
+ )
+ },
+ })
+
+ return E.unsandbox(withSandboxing)
+}
+
+type Requirements =
+ | UserService
+ | RegistrationService
+ | AuthenticationService
+ | ConnectionService
+ | EmailService
+ | StorageService
+ | Capabilities
+ | SocialService
+
+export class PasslockUnsafe {
+ private readonly runtime: Runtime.Runtime
+
+ constructor(config: PasslockProps) {
+ const rpcConfig = Layer.succeed(RpcConfig, RpcConfig.of(config))
+ const storage = Layer.succeed(Storage, Storage.of(globalThis.localStorage))
+ const allLayers = pipe(allRequirements, L.provide(rpcConfig), L.provide(storage))
+ const scope = E.runSync(Scope.make())
+ this.runtime = E.runSync(Layer.toRuntime(allLayers).pipe(Scope.extend(scope)))
+ }
+
+ private readonly runPromise = (
+ effect: E.Effect,
+ options: Options | undefined = undefined
+ ) => {
+ return pipe(
+ transformErrors(effect),
+ E.flatMap(result => (PasslockError.isError(result) ? E.fail(result) : E.succeed(result))),
+ effect => Runtime.runPromise(this.runtime)(effect, options),
+ )
+ }
+
+ preConnect = (options?: Options): Promise =>
+ pipe(
+ ConnectionService,
+ E.flatMap(service => service.preConnect()),
+ effect => Runtime.runPromise(this.runtime)(effect, options),
+ )
+
+ isPasskeySupport = (): Promise =>
+ pipe(
+ Capabilities,
+ E.flatMap(service => service.isPasskeySupport),
+ effect => Runtime.runPromise(this.runtime)(effect),
+ )
+
+ isExistingUser = (email: Email, options?: Options): Promise =>
+ pipe(
+ UserService,
+ E.flatMap(service => service.isExistingUser(email)),
+ effect => this.runPromise(effect, options),
+ )
+
+ registerPasskey = (request: RegistrationRequest, options?: Options): Promise =>
+ pipe(
+ RegistrationService,
+ E.flatMap(service => service.registerPasskey(request)),
+ effect => this.runPromise(effect, options),
+ )
+
+ authenticatePasskey = (request: AuthenticationRequest, options?: Options): Promise =>
+ pipe(
+ AuthenticationService,
+ E.flatMap(service => service.authenticatePasskey(request)),
+ effect => this.runPromise(effect, options),
+ )
+
+ registerOidc = (request: RegisterOidcReq, options?: Options) =>
+ pipe(
+ SocialService,
+ E.flatMap(service => service.registerOidc(request)),
+ effect => this.runPromise(effect, options),
+ )
+
+ authenticateOidc = (request: AuthenticateOidcReq, options?: Options) =>
+ pipe(
+ SocialService,
+ E.flatMap(service => service.authenticateOidc(request)),
+ effect => this.runPromise(effect, options),
+ )
+
+ verifyEmailCode = (request: VerifyRequest, options?: Options): Promise =>
+ pipe(
+ EmailService,
+ E.flatMap(service => service.verifyEmailCode(request)),
+ effect => this.runPromise(effect, options),
+ )
+
+ resendVerificationEmail = (request: ResendEmail, options?: Options): Promise =>
+ pipe(
+ UserService,
+ E.flatMap(service => service.resendVerificationEmail(request)),
+ effect => this.runPromise(effect, options),
+ )
+
+ verifyEmailLink = (options?: Options): Promise =>
+ pipe(
+ EmailService,
+ E.flatMap(service => service.verifyEmailLink()),
+ effect => this.runPromise(effect, options),
+ )
+
+ getSessionToken = (authType: AuthType): StoredToken | undefined =>
+ pipe(
+ StorageService,
+ E.flatMap(service => service.getToken(authType).pipe(effect => E.option(effect))),
+ E.map(Option.getOrUndefined),
+ effect => Runtime.runSync(this.runtime)(effect),
+ )
+
+ clearExpiredTokens = (): void =>
+ pipe(
+ StorageService,
+ E.flatMap(service => service.clearExpiredTokens),
+ effect => Runtime.runSync(this.runtime)(effect),
+ )
+}
+
+export class Passlock {
+ private readonly runtime: Runtime.Runtime
+
+ constructor(config: PasslockProps) {
+ const rpcConfig = Layer.succeed(RpcConfig, RpcConfig.of(config))
+ const storage = Layer.succeed(Storage, Storage.of(globalThis.localStorage))
+ const allLayers = pipe(allRequirements, L.provide(rpcConfig), L.provide(storage))
+ const scope = E.runSync(Scope.make())
+ this.runtime = E.runSync(Layer.toRuntime(allLayers).pipe(Scope.extend(scope)))
+ }
+
+ private readonly runPromise = (
+ effect: E.Effect,
+ options: Options | undefined = undefined
+ ) => {
+ return pipe(
+ transformErrors(effect),
+ effect => Runtime.runPromise(this.runtime)(effect, options)
+ )
+ }
+
+ preConnect = (options?: Options): Promise =>
+ pipe(
+ ConnectionService,
+ E.flatMap(service => service.preConnect()),
+ effect => this.runPromise(effect, options),
+ )
+
+ isPasskeySupport = (): Promise =>
+ pipe(
+ Capabilities,
+ E.flatMap(service => service.isPasskeySupport),
+ effect => Runtime.runPromise(this.runtime)(effect),
+ )
+
+ isExistingUser = (email: Email, options?: Options): Promise =>
+ pipe(
+ UserService,
+ E.flatMap(service => service.isExistingUser(email)),
+ effect => this.runPromise(effect, options),
+ )
+
+ registerPasskey = (request: RegistrationRequest, options?: Options): Promise =>
+ pipe(
+ RegistrationService,
+ E.flatMap(service => service.registerPasskey(request)),
+ effect => this.runPromise(effect, options),
+ )
+
+ authenticatePasskey = (request: AuthenticationRequest = {}, options?: Options): Promise =>
+ pipe(
+ AuthenticationService,
+ E.flatMap(service => service.authenticatePasskey(request)),
+ effect => this.runPromise(effect, options),
+ )
+
+ registerOidc = (request: RegisterOidcReq, options?: Options) =>
+ pipe(
+ SocialService,
+ E.flatMap(service => service.registerOidc(request)),
+ effect => this.runPromise(effect, options),
+ )
+
+ authenticateOidc = (request: AuthenticateOidcReq, options?: Options) =>
+ pipe(
+ SocialService,
+ E.flatMap(service => service.authenticateOidc(request)),
+ effect => this.runPromise(effect, options),
+ )
+
+ verifyEmailCode = (request: VerifyRequest, options?: Options): Promise =>
+ pipe(
+ EmailService,
+ E.flatMap(service => service.verifyEmailCode(request)),
+ effect => this.runPromise(effect, options),
+ )
+
+ verifyEmailLink = (options?: Options): Promise =>
+ pipe(
+ EmailService,
+ E.flatMap(service => service.verifyEmailLink()),
+ effect => this.runPromise(effect, options),
+ )
+
+ resendVerificationEmail = (request: ResendEmail, options?: Options): Promise =>
+ pipe(
+ UserService,
+ E.flatMap(service => service.resendVerificationEmail(request)),
+ effect => this.runPromise(effect, options),
+ )
+
+ getSessionToken = (authType: AuthType): StoredToken | undefined =>
+ pipe(
+ StorageService,
+ E.flatMap(service => service.getToken(authType).pipe(effect => E.option(effect))),
+ E.map(maybeToken => Option.getOrUndefined(maybeToken)),
+ effect => Runtime.runSync(this.runtime)(effect),
+ )
+
+ clearExpiredTokens = (): void =>
+ pipe(
+ StorageService,
+ E.flatMap(service => service.clearExpiredTokens),
+ effect => Runtime.runSync(this.runtime)(effect),
+ )
+}
diff --git a/packages/client/src/logging/eventLogger.test.ts b/packages/client/src/logging/eventLogger.test.ts
new file mode 100644
index 0000000..f2736b5
--- /dev/null
+++ b/packages/client/src/logging/eventLogger.test.ts
@@ -0,0 +1,103 @@
+import { Effect as E, LogLevel, Logger } from 'effect'
+import { describe, expect, test, vi } from 'vitest'
+import { eventLoggerLive, logRaw } from './eventLogger.js'
+
+/**
+ * Although the core log functionality is tested alongside the logger in the @passlock/shared
+ * package, those tests deliberately exclude the event dispatch elements as the package
+ * is intended to be agnostic to the runtime environment. This client package however is
+ * intented to be run in the browser, so we can plugin a real event dispatcher and ensure
+ * it's working as expected.
+ */
+
+describe('log', () => {
+ test('log DEBUG to the console', () => {
+ const logStatement = E.logDebug('hello world')
+
+ const logSpy = vi.spyOn(globalThis.console, 'log').mockImplementation(() => undefined)
+ const withLogLevel = logStatement.pipe(Logger.withMinimumLogLevel(LogLevel.Debug))
+
+ const effect = E.provide(withLogLevel, eventLoggerLive)
+ E.runSync(effect)
+
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('hello world'))
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('DEBUG'))
+ })
+
+ test('log INFO to the console', () => {
+ const logStatement = E.logInfo('hello world')
+
+ const logSpy = vi.spyOn(globalThis.console, 'log').mockImplementation(() => undefined)
+ const withLogLevel = logStatement.pipe(Logger.withMinimumLogLevel(LogLevel.Info))
+
+ const effect = E.provide(withLogLevel, eventLoggerLive)
+ E.runSync(effect)
+
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('hello world'))
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('INFO'))
+ })
+
+ test('log WARN to the console', () => {
+ const logStatement = E.logWarning('hello world')
+
+ const logSpy = vi.spyOn(globalThis.console, 'log').mockImplementation(() => undefined)
+ const withLogLevel = logStatement.pipe(Logger.withMinimumLogLevel(LogLevel.Warning))
+
+ const effect = E.provide(withLogLevel, eventLoggerLive)
+ E.runSync(effect)
+
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('hello world'))
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('WARN'))
+ })
+
+ test('log ERROR to the console', () => {
+ const logStatement = E.logError('hello world')
+
+ const logSpy = vi.spyOn(globalThis.console, 'log').mockImplementation(() => undefined)
+ const withLogLevel = logStatement.pipe(Logger.withMinimumLogLevel(LogLevel.Error))
+
+ const effect = E.provide(withLogLevel, eventLoggerLive)
+ E.runSync(effect)
+
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('hello world'))
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('ERROR'))
+ })
+
+ test('log raw data to the console', () => {
+ const logStatement = logRaw('hello world')
+ const logSpy = vi.spyOn(globalThis.console, 'log').mockImplementation(() => undefined)
+
+ E.runSync(logStatement)
+
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('hello world'))
+ })
+
+ test('fire a custom log event', () => {
+ const logStatement = E.logWarning('hello world')
+
+ const eventSpy = vi.spyOn(globalThis, 'dispatchEvent').mockImplementation(() => false)
+ const withLogLevel = logStatement.pipe(Logger.withMinimumLogLevel(LogLevel.Warning))
+
+ const effect = E.provide(withLogLevel, eventLoggerLive)
+
+ E.runSync(effect)
+
+ const expectedEvent = new CustomEvent('PasslogDebugMessage', {
+ detail: 'hello world',
+ })
+
+ expect(eventSpy).toHaveBeenCalledWith(expectedEvent)
+ })
+
+ test('not fire a log event for a debug message', () => {
+ const logStatement = E.logDebug('hello world')
+
+ const eventSpy = vi.spyOn(globalThis, 'dispatchEvent').mockImplementation(() => false)
+ const withDebugLevel = logStatement.pipe(Logger.withMinimumLogLevel(LogLevel.Debug))
+
+ const effect = E.provide(withDebugLevel, eventLoggerLive)
+ E.runSync(effect)
+
+ expect(eventSpy).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/client/src/logging/eventLogger.ts b/packages/client/src/logging/eventLogger.ts
new file mode 100644
index 0000000..d405fbb
--- /dev/null
+++ b/packages/client/src/logging/eventLogger.ts
@@ -0,0 +1,41 @@
+/**
+ * Logger implementation that also fires DOM events.
+ * This is useful to allow external code to plug into the logging
+ * mechanism. E.g. the Passlock demo subscribes to events to generate
+ * a typewriter style effect
+ */
+import { Effect as E, LogLevel, Logger } from 'effect'
+
+/**
+ * Some log messages span multiple lines/include json etc which is
+ * better output without being formatted by Effect's logging framework
+ *
+ * @param message
+ * @returns
+ */
+export const logRaw = (message: T) => {
+ return E.sync(() => {
+ console.log(message)
+ })
+}
+
+export const DebugMessage = 'PasslogDebugMessage'
+
+const dispatch = (message: string) => {
+ try {
+ const evt = new CustomEvent(DebugMessage, { detail: message })
+ globalThis.dispatchEvent(evt)
+ } catch (e) {
+ globalThis.console.log('Unable to fire custom event')
+ }
+}
+
+export const eventLoggerLive = Logger.add(
+ Logger.make(({ logLevel, message }) => {
+ if (typeof message === 'string' && logLevel !== LogLevel.Debug) {
+ dispatch(message)
+ } else if (Array.isArray(message) && logLevel !== LogLevel.Debug) {
+ message.forEach(dispatch)
+ }
+ })
+)
diff --git a/packages/client/src/registration/register.fixture.ts b/packages/client/src/registration/register.fixture.ts
new file mode 100644
index 0000000..a866e01
--- /dev/null
+++ b/packages/client/src/registration/register.fixture.ts
@@ -0,0 +1,88 @@
+import {
+ OptionsReq,
+ OptionsRes,
+ RegistrationClient,
+ VerificationReq,
+ VerificationRes,
+} from '@passlock/shared/dist/rpc/registration.js'
+import type { RegistrationCredential } from '@passlock/shared/dist/schema/schema.js'
+import { Effect as E, Layer as L } from 'effect'
+import * as Fixtures from '../test/fixtures.js'
+import { UserService } from '../user/user.js'
+import { CreateCredential, type RegistrationRequest } from './register.js'
+
+export const session = 'session'
+export const token = 'token'
+export const code = 'code'
+export const authType = 'passkey'
+export const expireAt = Date.now() + 10000
+
+export const registrationRequest: RegistrationRequest = {
+ email: 'jdoe@gmail.com',
+ givenName: 'john',
+ familyName: 'doe',
+}
+
+export const rpcOptionsReq = new OptionsReq(registrationRequest)
+
+export const registrationOptions: OptionsRes = {
+ session,
+ publicKey: {
+ rp: {
+ name: 'passlock',
+ id: 'passlock.dev',
+ },
+ user: {
+ name: 'john doe',
+ id: 'jdoe',
+ displayName: 'john doe',
+ },
+ challenge: 'FKZSl_saKu5OXjLLwoq8eK3wlD8XgpGiS10SszW5RiE',
+ pubKeyCredParams: [],
+ },
+}
+
+export const rpcOptionsRes = new OptionsRes(registrationOptions)
+
+export const credential: RegistrationCredential = {
+ type: 'public-key',
+ id: '1',
+ rawId: '1',
+ response: {
+ transports: [],
+ clientDataJSON: '',
+ attestationObject: '',
+ },
+ clientExtensionResults: {},
+}
+
+export const rpcVerificationReq = new VerificationReq({ session, credential })
+
+export const rpcVerificationRes = new VerificationRes({ principal: Fixtures.principal })
+
+export const createCredentialTest = L.succeed(
+ CreateCredential,
+ CreateCredential.of(() => E.succeed(credential)),
+)
+
+export const userServiceTest = L.succeed(
+ UserService,
+ UserService.of({
+ isExistingUser: () => E.succeed(false),
+ resendVerificationEmail: () => E.succeed(true)
+ }),
+)
+
+export const rpcClientTest = L.succeed(
+ RegistrationClient,
+ RegistrationClient.of({
+ getRegistrationOptions: () => E.succeed(rpcOptionsRes),
+ verifyRegistrationCredential: () => E.succeed(rpcVerificationRes),
+ })
+)
+
+export const principal = Fixtures.principal
+
+export const capabilitiesTest = Fixtures.capabilitiesTest
+
+export const storageServiceTest = Fixtures.storageServiceTest
diff --git a/packages/client/src/registration/register.test.ts b/packages/client/src/registration/register.test.ts
new file mode 100644
index 0000000..31d9808
--- /dev/null
+++ b/packages/client/src/registration/register.test.ts
@@ -0,0 +1,210 @@
+import { Duplicate, InternalBrowserError } from '@passlock/shared/dist/error/error.js'
+import { RegistrationClient } from '@passlock/shared/dist/rpc/registration.js'
+import { Effect as E, Layer as L, Layer, LogLevel, Logger, pipe } from 'effect'
+import { describe, expect, test, vi } from 'vitest'
+import { mock } from 'vitest-mock-extended'
+import * as Fixture from './register.fixture.js'
+import { CreateCredential, RegistrationService, RegistrationServiceLive } from './register.js'
+
+describe('register should', () => {
+ test('return a valid credential', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(RegistrationService)
+ const result = yield* _(service.registerPasskey(Fixture.registrationRequest))
+ expect(result).toEqual(Fixture.principal)
+ })
+
+ const service = pipe(
+ RegistrationServiceLive,
+ L.provide(Fixture.createCredentialTest),
+ L.provide(Fixture.userServiceTest),
+ L.provide(Fixture.capabilitiesTest),
+ L.provide(Fixture.storageServiceTest),
+ L.provide(Fixture.rpcClientTest),
+ )
+
+ const effect = pipe(E.provide(assertions, service), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+
+ test('pass the registration data to the backend', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(RegistrationService)
+ yield* _(service.registerPasskey(Fixture.registrationRequest))
+
+ const rpcClient = yield* _(RegistrationClient)
+ expect(rpcClient.getRegistrationOptions).toHaveBeenCalledWith(Fixture.rpcOptionsReq)
+ })
+
+ const rpcClientTest = L.effect(
+ RegistrationClient,
+ E.sync(() => {
+ const rpcMock = mock()
+
+ rpcMock.getRegistrationOptions.mockReturnValue(E.succeed(Fixture.rpcOptionsRes))
+ rpcMock.verifyRegistrationCredential.mockReturnValue(E.succeed(Fixture.rpcVerificationRes))
+
+ return rpcMock
+ }),
+ )
+
+ const service = pipe(
+ RegistrationServiceLive,
+ L.provide(Fixture.createCredentialTest),
+ L.provide(Fixture.capabilitiesTest),
+ L.provide(Fixture.storageServiceTest),
+ L.provide(Fixture.userServiceTest),
+ L.provide(rpcClientTest),
+ )
+
+ const layers = Layer.merge(service, rpcClientTest)
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+
+ test('send the new credential to the backend', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(RegistrationService)
+ yield* _(service.registerPasskey(Fixture.registrationRequest))
+
+ const rpcClient = yield* _(RegistrationClient)
+ expect(rpcClient.verifyRegistrationCredential).toHaveBeenCalledWith(Fixture.rpcVerificationReq)
+ })
+
+ const rpcClientTest = L.effect(
+ RegistrationClient,
+ E.sync(() => {
+ const rpcMock = mock()
+
+ rpcMock.getRegistrationOptions.mockReturnValue(E.succeed(Fixture.rpcOptionsRes))
+ rpcMock.verifyRegistrationCredential.mockReturnValue(E.succeed(Fixture.rpcVerificationRes))
+
+ return rpcMock
+ }),
+ )
+
+ const service = pipe(
+ RegistrationServiceLive,
+ L.provide(Fixture.createCredentialTest),
+ L.provide(Fixture.capabilitiesTest),
+ L.provide(Fixture.storageServiceTest),
+ L.provide(Fixture.userServiceTest),
+ L.provide(rpcClientTest),
+ )
+
+ const layers = Layer.merge(service, rpcClientTest)
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+
+ test('short-circuit if the user is already registered', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(RegistrationService)
+
+ const error = yield* _(service.registerPasskey(Fixture.registrationRequest), E.flip)
+
+ expect(error).toBeInstanceOf(Duplicate)
+ })
+
+ const rpcClientTest = L.effect(
+ RegistrationClient,
+ E.sync(() => {
+ const rpcMock = mock()
+
+ rpcMock.getRegistrationOptions.mockReturnValue(E.fail(new Duplicate({ message: 'User already exists' })))
+
+ return rpcMock
+ }),
+ )
+
+ const service = pipe(
+ RegistrationServiceLive,
+ L.provide(Fixture.createCredentialTest),
+ L.provide(Fixture.capabilitiesTest),
+ L.provide(Fixture.storageServiceTest),
+ L.provide(Fixture.userServiceTest),
+ L.provide(rpcClientTest),
+ )
+
+ const layers = Layer.merge(service, rpcClientTest)
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+
+ test('return an error if we try to re-register a credential', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(RegistrationService)
+
+ const defect = yield* _(service.registerPasskey(Fixture.registrationRequest), E.flip)
+
+ expect(defect).toBeInstanceOf(Duplicate)
+ })
+
+ const createTest = L.effect(
+ CreateCredential,
+ E.sync(() => {
+ const createTest = vi.fn()
+
+ createTest.mockReturnValue(E.fail(new Duplicate({ message: 'boom!' })))
+
+ return createTest
+ }),
+ )
+
+ const service = pipe(
+ RegistrationServiceLive,
+ L.provide(Fixture.userServiceTest),
+ L.provide(Fixture.capabilitiesTest),
+ L.provide(Fixture.storageServiceTest),
+ L.provide(Fixture.rpcClientTest),
+ L.provide(createTest),
+ )
+
+ const layers = Layer.merge(service, createTest)
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+
+ test("throw an error if the browser can't create a credential", async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(RegistrationService)
+
+ const defect = yield* _(
+ service.registerPasskey(Fixture.registrationRequest),
+ E.catchAllDefect(defect => E.succeed(defect)),
+ )
+
+ expect(defect).toBeInstanceOf(InternalBrowserError)
+ })
+
+ const createTest = L.effect(
+ CreateCredential,
+ E.sync(() => {
+ const createTest = vi.fn()
+
+ createTest.mockReturnValue(E.fail(new InternalBrowserError({ message: 'boom!' })))
+
+ return createTest
+ }),
+ )
+
+ const service = pipe(
+ RegistrationServiceLive,
+ L.provide(Fixture.userServiceTest),
+ L.provide(Fixture.capabilitiesTest),
+ L.provide(Fixture.storageServiceTest),
+ L.provide(Fixture.rpcClientTest),
+ L.provide(createTest),
+ )
+
+ const layers = Layer.merge(service, createTest)
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+})
diff --git a/packages/client/src/registration/register.ts b/packages/client/src/registration/register.ts
new file mode 100644
index 0000000..06e8cda
--- /dev/null
+++ b/packages/client/src/registration/register.ts
@@ -0,0 +1,156 @@
+/**
+ * User & passkey registration effects
+ */
+import {
+ parseCreationOptionsFromJSON,
+ type CredentialCreationOptionsJSON,
+} from '@github/webauthn-json/browser-ponyfill'
+import type { NotSupported } from '@passlock/shared/dist/error/error.js'
+import { Duplicate, InternalBrowserError } from '@passlock/shared/dist/error/error.js'
+import type { OptionsErrors, VerificationErrors } from '@passlock/shared/dist/rpc/registration.js'
+import { OptionsReq, RegistrationClient, VerificationReq } from '@passlock/shared/dist/rpc/registration.js'
+import type {
+ Principal,
+ RegistrationCredential,
+ UserVerification,
+ VerifyEmail,
+} from '@passlock/shared/dist/schema/schema.js'
+import { Context, Effect as E, Layer, flow, pipe } from 'effect'
+import { Capabilities } from '../capabilities/capabilities.js'
+import { StorageService } from '../storage/storage.js'
+import { UserService } from '../user/user.js'
+
+/* Requests */
+
+export type RegistrationRequest = {
+ email: string
+ givenName: string
+ familyName: string
+ userVerification?: UserVerification
+ verifyEmail?: VerifyEmail
+}
+
+/* Dependencies */
+
+export type CreateCredential = (
+ request: CredentialCreationOptions,
+) => E.Effect
+
+export const CreateCredential = Context.GenericTag('@services/Create')
+
+/* Errors */
+
+export type RegistrationErrors = NotSupported | OptionsErrors | VerificationErrors
+
+/* Service */
+
+export type RegistrationService = {
+ registerPasskey: (request: RegistrationRequest) => E.Effect
+}
+
+export const RegistrationService = Context.GenericTag(
+ '@services/RegistrationService',
+)
+
+/* Utilities */
+
+const fetchOptions = (request: OptionsReq) => {
+ return E.gen(function* (_) {
+ yield* _(E.logDebug('Making request'))
+
+ const rpcClient = yield* _(RegistrationClient)
+ const { publicKey, session } = yield* _(rpcClient.getRegistrationOptions(request))
+
+ yield* _(E.logDebug('Converting Passlock options to CredentialCreationOptions'))
+ const options = yield* _(toCreationOptions({ publicKey }))
+
+ return { options, session }
+ })
+}
+
+const toCreationOptions = (jsonOptions: CredentialCreationOptionsJSON) => {
+ return pipe(
+ E.try(() => parseCreationOptionsFromJSON(jsonOptions)),
+ E.mapError(
+ error =>
+ new InternalBrowserError({
+ message: 'Browser was unable to create credential creation options',
+ detail: String(error.error),
+ }),
+ ),
+ )
+}
+
+const verifyCredential = (request: VerificationReq) => {
+ return E.gen(function* (_) {
+ yield* _(E.logDebug('Making request'))
+
+ const rpcClient = yield* _(RegistrationClient)
+ const { principal } = yield* _(rpcClient.verifyRegistrationCredential(request))
+
+ return principal
+ })
+}
+
+/* Effects */
+
+type Dependencies = Capabilities | CreateCredential | StorageService | UserService | RegistrationClient
+
+export const registerPasskey = (
+ request: RegistrationRequest,
+): E.Effect => {
+ const effect = E.gen(function* (_) {
+ yield* _(E.logInfo('Checking if browser supports Passkeys'))
+ const capabilities = yield* _(Capabilities)
+ yield* _(capabilities.passkeySupport)
+
+ yield* _(E.logInfo('Fetching registration options from Passlock'))
+ const { options, session } = yield* _(fetchOptions(new OptionsReq(request)))
+
+ yield* _(E.logInfo('Building new credential'))
+ const createCredential = yield* _(CreateCredential)
+ const credential = yield* _(createCredential(options))
+
+ yield* _(E.logInfo('Storing credential public key in Passlock'))
+ const verificationRequest = new VerificationReq({
+ ...request,
+ credential,
+ session,
+ })
+
+ const principal = yield* _(verifyCredential(verificationRequest))
+
+ const storageService = yield* _(StorageService)
+ yield* _(storageService.storeToken(principal))
+ yield* _(E.logDebug('Storing token in local storage'))
+
+ yield* _(E.logDebug('Defering local token deletion'))
+ const delayedClearTokenE = pipe(
+ storageService.clearExpiredToken('passkey'),
+ E.delay('6 minutes'),
+ E.fork,
+ )
+ yield* _(delayedClearTokenE)
+
+ return principal
+ })
+
+ return E.catchTag(effect, 'InternalBrowserError', e => E.die(e))
+}
+
+/* Live */
+
+/* v8 ignore start */
+export const RegistrationServiceLive = Layer.effect(
+ RegistrationService,
+ E.gen(function* (_) {
+ const context = yield* _(
+ E.context(),
+ )
+
+ return RegistrationService.of({
+ registerPasskey: flow(registerPasskey, E.provide(context)),
+ })
+ }),
+)
+/* v8 ignore stop */
diff --git a/packages/client/src/social/social.fixture.ts b/packages/client/src/social/social.fixture.ts
new file mode 100644
index 0000000..4540d01
--- /dev/null
+++ b/packages/client/src/social/social.fixture.ts
@@ -0,0 +1,51 @@
+import * as Shared from '@passlock/shared/dist/rpc/social.js'
+import { SocialClient } from '@passlock/shared/dist/rpc/social.js'
+import { Effect as E, Layer as L, Option as O } from 'effect'
+import * as Fixtures from '../test/fixtures.js'
+import type { AuthenticateOidcReq, RegisterOidcReq } from './social.js'
+
+export const session = 'session'
+export const token = 'token'
+export const code = 'code'
+export const authType = 'passkey'
+export const expireAt = Date.now() + 10000
+
+export const registerOidcReq: RegisterOidcReq = {
+ provider: 'google',
+ idToken: 'google-token',
+ nonce: 'nonce',
+ givenName: 'john',
+ familyName: 'doe'
+}
+
+export const authOidcReq: AuthenticateOidcReq = {
+ provider: 'google',
+ idToken: 'google-token',
+ nonce: 'nonce'
+}
+
+export const rpcRegisterReq = new Shared.RegisterOidcReq({
+ ...registerOidcReq,
+ givenName: O.fromNullable(registerOidcReq.givenName),
+ familyName: O.fromNullable(registerOidcReq.familyName)
+})
+
+export const rpcRegisterRes = new Shared.PrincipalRes({ principal: Fixtures.principal })
+
+export const rpcAuthenticateReq = new Shared.AuthOidcReq({ ...authOidcReq })
+
+export const rpcAuthenticateRes = new Shared.PrincipalRes({ principal: Fixtures.principal })
+
+export const rpcClientTest = L.succeed(
+ SocialClient,
+ SocialClient.of({
+ registerOidc: () => E.fail(Fixtures.notImplemented),
+ authenticateOidc: () => E.fail(Fixtures.notImplemented),
+ })
+)
+
+export const principal = Fixtures.principal
+
+export const capabilitiesTest = Fixtures.capabilitiesTest
+
+export const storageServiceTest = Fixtures.storageServiceTest
diff --git a/packages/client/src/social/social.test.ts b/packages/client/src/social/social.test.ts
new file mode 100644
index 0000000..e5ccad9
--- /dev/null
+++ b/packages/client/src/social/social.test.ts
@@ -0,0 +1,193 @@
+import { Duplicate, NotFound } from '@passlock/shared/dist/error/error.js'
+import { SocialClient } from '@passlock/shared/dist/rpc/social.js'
+import { Effect as E, Layer as L, Layer, LogLevel, Logger, pipe } from 'effect'
+import { describe, expect, test } from 'vitest'
+import { mock } from 'vitest-mock-extended'
+import * as Fixture from './social.fixture.js'
+import { SocialService, SocialServiceLive } from './social.js'
+
+describe('registerOidc should', () => {
+ test('return a valid credential', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(SocialService)
+ const result = yield* _(service.registerOidc(Fixture.registerOidcReq))
+ expect(result).toEqual(Fixture.principal)
+ })
+
+ const rpcClientTest = L.effect(
+ SocialClient,
+ E.sync(() => {
+ const rpcMock = mock()
+
+ rpcMock.registerOidc.mockReturnValue(E.succeed(Fixture.rpcRegisterRes))
+
+ return rpcMock
+ }),
+ )
+
+ const service = pipe(
+ SocialServiceLive,
+ L.provide(rpcClientTest),
+ )
+
+ const layers = Layer.merge(service, rpcClientTest)
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+
+ test('pass the request to the backend', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(SocialService)
+ yield* _(service.registerOidc(Fixture.registerOidcReq))
+
+ const rpcClient = yield* _(SocialClient)
+ expect(rpcClient.registerOidc).toHaveBeenCalledWith(Fixture.rpcRegisterReq)
+ })
+
+ const rpcClientTest = L.effect(
+ SocialClient,
+ E.sync(() => {
+ const rpcMock = mock()
+
+ rpcMock.registerOidc.mockReturnValue(E.succeed(Fixture.rpcRegisterRes))
+
+ return rpcMock
+ }),
+ )
+
+ const service = pipe(
+ SocialServiceLive,
+ L.provide(rpcClientTest),
+ )
+
+ const layers = Layer.merge(service, rpcClientTest)
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+
+ test('return an error if we try to register an existing user', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(SocialService)
+
+ const defect = yield* _(service.registerOidc(Fixture.registerOidcReq), E.flip)
+
+ expect(defect).toBeInstanceOf(Duplicate)
+ })
+
+ const rpcClientTest = L.effect(
+ SocialClient,
+ E.sync(() => {
+ const rpcMock = mock()
+
+ rpcMock.registerOidc.mockReturnValue(E.fail(new Duplicate({ message: "Duplicate user" })))
+
+ return rpcMock
+ }),
+ )
+
+ const service = pipe(
+ SocialServiceLive,
+ L.provide(rpcClientTest),
+ )
+
+ const layers = Layer.merge(service, rpcClientTest)
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+})
+
+describe('authenticateIodc should', () => {
+ test('return a valid credential', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(SocialService)
+ const result = yield* _(service.authenticateOidc(Fixture.authOidcReq))
+ expect(result).toEqual(Fixture.principal)
+ })
+
+ const rpcClientTest = L.effect(
+ SocialClient,
+ E.sync(() => {
+ const rpcMock = mock()
+
+ rpcMock.authenticateOidc.mockReturnValue(E.succeed(Fixture.rpcRegisterRes))
+
+ return rpcMock
+ }),
+ )
+
+ const service = pipe(
+ SocialServiceLive,
+ L.provide(rpcClientTest),
+ )
+
+ const layers = Layer.merge(service, rpcClientTest)
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+
+ test('pass the request to the backend', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(SocialService)
+ yield* _(service.authenticateOidc(Fixture.authOidcReq))
+
+ const rpcClient = yield* _(SocialClient)
+ expect(rpcClient.authenticateOidc).toHaveBeenCalledWith(Fixture.rpcAuthenticateReq)
+ })
+
+ const rpcClientTest = L.effect(
+ SocialClient,
+ E.sync(() => {
+ const rpcMock = mock()
+
+ rpcMock.authenticateOidc.mockReturnValue(E.succeed(Fixture.rpcAuthenticateRes))
+
+ return rpcMock
+ }),
+ )
+
+ const service = pipe(
+ SocialServiceLive,
+ L.provide(rpcClientTest),
+ )
+
+ const layers = Layer.merge(service, rpcClientTest)
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+
+ test('return an error if we try to authenticate a non-existing user', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(SocialService)
+
+ const defect = yield* _(service.authenticateOidc(Fixture.authOidcReq), E.flip)
+
+ expect(defect).toBeInstanceOf(NotFound)
+ })
+
+ const rpcClientTest = L.effect(
+ SocialClient,
+ E.sync(() => {
+ const rpcMock = mock()
+
+ rpcMock.authenticateOidc.mockReturnValue(E.fail(new NotFound({ message: "User not found" })))
+
+ return rpcMock
+ }),
+ )
+
+ const service = pipe(
+ SocialServiceLive,
+ L.provide(rpcClientTest),
+ )
+
+ const layers = Layer.merge(service, rpcClientTest)
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+})
diff --git a/packages/client/src/social/social.ts b/packages/client/src/social/social.ts
new file mode 100644
index 0000000..3ac5d92
--- /dev/null
+++ b/packages/client/src/social/social.ts
@@ -0,0 +1,107 @@
+/**
+ * Passkey authentication effects
+ */
+import {
+ type BadRequest,
+ type NotSupported
+} from '@passlock/shared/dist/error/error.js'
+import * as Shared from '@passlock/shared/dist/rpc/social.js'
+import { SocialClient } from '@passlock/shared/dist/rpc/social.js'
+import type {
+ Principal
+} from '@passlock/shared/dist/schema/schema.js'
+import { Context, Effect as E, Layer, Option as O, flow } from 'effect'
+
+/* Requests */
+
+export type Provider = 'apple' | 'google'
+
+export type RegisterOidcReq = {
+ provider: Provider
+ idToken: string
+ nonce: string
+ givenName?: string
+ familyName?: string
+}
+
+export type AuthenticateOidcReq = {
+ provider: Provider
+ idToken: string
+ nonce: string
+}
+
+/* Errors */
+
+export type RegistrationErrors = NotSupported | BadRequest | Shared.RegisterOidcErrors
+
+export type AuthenticationErrors = NotSupported | BadRequest | Shared.AuthOidcErrors
+
+/* Service */
+
+export type SocialService = {
+ registerOidc: (data: RegisterOidcReq) => E.Effect
+ authenticateOidc: (data: AuthenticateOidcReq) => E.Effect
+}
+
+export const SocialService = Context.GenericTag(
+ '@services/SocialService',
+)
+
+/* Effects */
+
+type Dependencies = SocialClient
+
+export const registerOidc = (
+ request: RegisterOidcReq,
+): E.Effect => {
+ return E.gen(function* (_) {
+ yield* _(E.logInfo('Registering social account'))
+
+ const rpcClient = yield* _(SocialClient)
+
+ const rpcRequest = new Shared.RegisterOidcReq({
+ ...request,
+ givenName: O.fromNullable(request.givenName),
+ familyName: O.fromNullable(request.familyName),
+ })
+
+ const { principal } = yield* _(
+ rpcClient.registerOidc(rpcRequest)
+ )
+
+ return principal
+ })
+}
+
+export const authenticateOidc = (
+ request: AuthenticateOidcReq,
+): E.Effect => {
+ return E.gen(function* (_) {
+ yield* _(E.logInfo('Authenticating with social account'))
+
+ const rpcClient = yield* _(SocialClient)
+ const rpcRequest = new Shared.AuthOidcReq(request)
+
+ const { principal } = yield* _(
+ rpcClient.authenticateOidc(rpcRequest)
+ )
+
+ return principal
+ })
+}
+
+/* Live */
+
+/* v8 ignore start */
+export const SocialServiceLive = Layer.effect(
+ SocialService,
+ E.gen(function* (_) {
+ const context = yield* _(E.context())
+
+ return SocialService.of({
+ registerOidc: flow(registerOidc, E.provide(context)),
+ authenticateOidc: flow(authenticateOidc, E.provide(context))
+ })
+ }),
+)
+/* v8 ignore stop */
diff --git a/packages/client/src/storage/storage.fixture.ts b/packages/client/src/storage/storage.fixture.ts
new file mode 100644
index 0000000..eaae895
--- /dev/null
+++ b/packages/client/src/storage/storage.fixture.ts
@@ -0,0 +1,16 @@
+import { Effect as E, Layer, pipe } from 'effect'
+import { mock } from 'vitest-mock-extended'
+import { Storage, StorageServiceLive } from './storage.js'
+
+const storageTest = Layer.effect(
+ Storage,
+ E.sync(() => mock()),
+)
+
+export const testLayers = (storage: Layer.Layer = storageTest) => {
+ const storageService = pipe(StorageServiceLive, Layer.provide(storage))
+
+ return Layer.merge(storage, storageService)
+}
+
+export { principal } from '../test/fixtures.js'
diff --git a/packages/client/src/storage/storage.test.ts b/packages/client/src/storage/storage.test.ts
new file mode 100644
index 0000000..b5e338c
--- /dev/null
+++ b/packages/client/src/storage/storage.test.ts
@@ -0,0 +1,196 @@
+import { Effect as E, Layer, LogLevel, Logger, identity, pipe } from 'effect'
+import { describe, expect, test } from 'vitest'
+import { mock } from 'vitest-mock-extended'
+import { principal, testLayers } from './storage.fixture.js'
+import { Storage, StorageService, clearExpiredToken, clearToken, getToken } from './storage.js'
+
+// eslint chokes on expect(storage.setItem) etc
+/* eslint @typescript-eslint/unbound-method: 0 */
+
+describe('storeToken should', () => {
+ test('set the token in local storage', () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(StorageService)
+ yield* _(service.storeToken(principal))
+
+ const storage = yield* _(Storage)
+ expect(storage.setItem).toHaveBeenCalled()
+ })
+
+ const effect = pipe(
+ E.provide(assertions, testLayers()),
+ Logger.withMinimumLogLevel(LogLevel.None),
+ )
+
+ E.runSync(effect)
+ })
+
+ test('with the key passlock:passkey:token', () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(StorageService)
+ yield* _(service.storeToken(principal))
+
+ const storage = yield* _(Storage)
+ expect(storage.setItem).toHaveBeenCalledWith('passlock:passkey:token', expect.any(String))
+ })
+
+ const effect = pipe(
+ E.provide(assertions, testLayers()),
+ Logger.withMinimumLogLevel(LogLevel.None),
+ )
+
+ E.runSync(effect)
+ })
+
+ test('with the value token:expiry', () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(StorageService)
+ yield* _(service.storeToken(principal))
+
+ const storage = yield* _(Storage)
+ const token = principal.token
+ const expiry = principal.expireAt.getTime()
+ expect(storage.setItem).toHaveBeenCalledWith('passlock:passkey:token', `${token}:${expiry}`)
+ })
+
+ const effect = pipe(
+ E.provide(assertions, testLayers()),
+ Logger.withMinimumLogLevel(LogLevel.None),
+ )
+
+ E.runSync(effect)
+ })
+})
+
+describe('getToken should', () => {
+ test('get the token from local storage', () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(StorageService)
+ yield* _(service.getToken('passkey'))
+
+ const storage = yield* _(Storage)
+ expect(storage.getItem).toHaveBeenCalled()
+ expect(storage.getItem).toHaveBeenCalledWith('passlock:passkey:token')
+ })
+
+ const storageTest = Layer.effect(
+ Storage,
+ E.sync(() => {
+ const mockStorage = mock()
+ const expiry = Date.now() + 1000
+ mockStorage.getItem.mockReturnValue(`token:${expiry}`)
+ return mockStorage
+ }),
+ )
+
+ const effect = pipe(
+ E.provide(assertions, testLayers(storageTest)),
+ Logger.withMinimumLogLevel(LogLevel.None),
+ )
+
+ E.runSync(effect)
+ })
+
+ test('filter out expired tokens', () => {
+ const assertions = pipe(
+ getToken('passkey'),
+ E.match({
+ onSuccess: identity,
+ onFailure: () => undefined,
+ }),
+ E.flatMap(result =>
+ E.sync(() => {
+ expect(result).toBeUndefined()
+ }),
+ ),
+ )
+
+ const storageTest = Layer.effect(
+ Storage,
+ E.sync(() => {
+ const mockStorage = mock()
+ const expiry = Date.now() - 1000
+ mockStorage.getItem.mockReturnValue(`token:${expiry}`)
+ return mockStorage
+ }),
+ )
+
+ const effect = pipe(
+ E.provide(assertions, testLayers(storageTest)),
+ Logger.withMinimumLogLevel(LogLevel.None),
+ )
+
+ E.runSync(effect)
+ })
+})
+
+describe('clearToken should', () => {
+ test('clear the token in local storage', () => {
+ const assertions = E.gen(function* (_) {
+ const storage = yield* _(Storage)
+ yield* _(clearToken('passkey'))
+ expect(storage.removeItem).toHaveBeenCalledWith('passlock:passkey:token')
+ })
+
+ const effect = pipe(
+ E.provide(assertions, testLayers()),
+ Logger.withMinimumLogLevel(LogLevel.None),
+ )
+
+ E.runSync(effect)
+ })
+})
+
+describe('clearExpiredToken should', () => {
+ test('clear an expired token from local storage', () => {
+ const assertions = E.gen(function* (_) {
+ const storage = yield* _(Storage)
+ yield* _(clearExpiredToken('passkey'))
+ expect(storage.getItem).toHaveBeenCalledWith('passlock:passkey:token')
+ expect(storage.removeItem).toHaveBeenCalledWith('passlock:passkey:token')
+ })
+
+ const storageTest = Layer.effect(
+ Storage,
+ E.sync(() => {
+ const mockStorage = mock()
+ const expiry = Date.now() - 1000
+ mockStorage.getItem.mockReturnValue(`token:${expiry}`)
+ return mockStorage
+ }),
+ )
+
+ const effect = pipe(
+ E.provide(assertions, testLayers(storageTest)),
+ Logger.withMinimumLogLevel(LogLevel.None),
+ )
+
+ E.runSync(effect)
+ })
+
+ test('leave a live token in local storage', () => {
+ const assertions = E.gen(function* (_) {
+ const storage = yield* _(Storage)
+ yield* _(clearExpiredToken('passkey'))
+ expect(storage.getItem).toHaveBeenCalledWith('passlock:passkey:token')
+ expect(storage.removeItem).not.toHaveBeenCalled()
+ })
+
+ const storageTest = Layer.effect(
+ Storage,
+ E.sync(() => {
+ const mockStorage = mock()
+ const expiry = Date.now() + 1000
+ mockStorage.getItem.mockReturnValue(`token:${expiry}`)
+ return mockStorage
+ }),
+ )
+
+ const effect = pipe(
+ E.provide(assertions, testLayers(storageTest)),
+ Logger.withMinimumLogLevel(LogLevel.None),
+ )
+
+ E.runSync(effect)
+ })
+})
diff --git a/packages/client/src/storage/storage.ts b/packages/client/src/storage/storage.ts
new file mode 100644
index 0000000..fede0e3
--- /dev/null
+++ b/packages/client/src/storage/storage.ts
@@ -0,0 +1,167 @@
+/**
+ * Wrapper around local storage that allows us to store
+ * authentication tokens in local storage for a short period.
+ */
+import type { Principal } from '@passlock/shared/dist/schema/schema.js'
+import { Context, Effect as E, Layer, Option as O, flow, pipe } from 'effect'
+import type { NoSuchElementException } from 'effect/Cause'
+
+/* Requests */
+
+export type AuthType = 'email' | 'passkey' | 'apple' | 'google'
+
+export type StoredToken = {
+ token: string
+ authType: AuthType
+ expireAt: number
+}
+
+/* Service */
+
+export type StorageService = {
+ storeToken: (principal: Principal) => E.Effect
+ getToken: (authType: AuthType) => E.Effect
+ clearToken: (authType: AuthType) => E.Effect
+ clearExpiredToken: (authType: AuthType) => E.Effect
+ clearExpiredTokens: E.Effect
+}
+
+/* Utilities */
+
+export const StorageService = Context.GenericTag('@services/StorageService')
+
+// inject window.localStorage to make testing easier
+export const Storage = Context.GenericTag('@services/Storage')
+
+export const buildKey = (authType: AuthType) => `passlock:${authType}:token`
+
+// principal => token:expireAt
+export const compressToken = (principal: Principal): string => {
+ const expireAt = principal.expireAt.getTime()
+ const token = principal.token
+ return `${token}:${expireAt}`
+}
+
+// token:expireAt => { authType, token, expireAt }
+export const expandToken =
+ (authType: AuthType) =>
+ (s: string): O.Option => {
+ const tokens = s.split(':')
+ if (tokens.length !== 2) return O.none()
+
+ const [token, expireAtString] = tokens
+ const parse = O.liftThrowable(Number.parseInt)
+ const expireAt = parse(expireAtString)
+
+ return O.map(expireAt, expireAt => ({ authType, token, expireAt }))
+ }
+
+/* Effects */
+
+/**
+ * Store compressed token in local storage
+ * @param principal
+ * @returns
+ */
+export const storeToken = (principal: Principal): E.Effect => {
+ return E.gen(function* (_) {
+ const localStorage = yield* _(Storage)
+
+ const storeEffect = E.try(() => {
+ const compressed = compressToken(principal)
+ const key = buildKey(principal.authStatement.authType)
+ localStorage.setItem(key, compressed)
+ }).pipe(E.orElse(() => E.void)) // We dont care if it fails
+
+ return yield* _(storeEffect)
+ })
+}
+
+/**
+ * Get stored token from local storage
+ * @param authType
+ * @returns
+ */
+export const getToken = (
+ authType: AuthType,
+): E.Effect => {
+ return E.gen(function* (_) {
+ const localStorage = yield* _(Storage)
+
+ const getEffect = pipe(
+ O.some(buildKey(authType)),
+ O.flatMap(key => pipe(localStorage.getItem(key), O.fromNullable)),
+ O.flatMap(expandToken(authType)),
+ O.filter(({ expireAt: expireAt }) => expireAt > Date.now()),
+ )
+
+ return yield* _(getEffect)
+ })
+}
+
+/**
+ * Remove token from local storage
+ * @param authType
+ * @returns
+ */
+export const clearToken = (authType: AuthType): E.Effect => {
+ return E.gen(function* (_) {
+ const localStorage = yield* _(Storage)
+ localStorage.removeItem(buildKey(authType))
+ })
+}
+
+/**
+ * Only clear if now > token.expireAt
+ * @param authType
+ * @param defer
+ * @returns
+ */
+export const clearExpiredToken = (authType: AuthType): E.Effect => {
+ const key = buildKey(authType)
+
+ const effect = E.gen(function* (_) {
+ const storage = yield* _(Storage)
+ const item = yield* _(O.fromNullable(storage.getItem(key)))
+ const token = yield* _(expandToken(authType)(item))
+
+ if (token.expireAt < Date.now()) {
+ storage.removeItem(key)
+ }
+ })
+
+ // we don't care if it fails
+ return pipe(
+ effect,
+ E.match({
+ onSuccess: () => E.void,
+ onFailure: () => E.void,
+ }),
+ )
+}
+
+export const clearExpiredTokens: E.Effect = E.all([
+ clearExpiredToken('passkey'),
+ clearExpiredToken('email'),
+ clearExpiredToken('google'),
+ clearExpiredToken('apple'),
+])
+
+/* Live */
+
+/* v8 ignore start */
+export const StorageServiceLive = Layer.effect(
+ StorageService,
+ E.gen(function* (_) {
+ const context = yield* _(E.context())
+
+ return {
+ storeToken: flow(storeToken, E.provide(context)),
+ getToken: flow(getToken, E.provide(context)),
+ clearToken: flow(clearToken, E.provide(context)),
+ clearExpiredToken: flow(clearExpiredToken, E.provide(context)),
+ clearExpiredTokens: pipe(clearExpiredTokens, E.provide(context)),
+ }
+ }),
+)
+/* v8 ignore stop */
diff --git a/packages/client/src/test/fixtures.ts b/packages/client/src/test/fixtures.ts
new file mode 100644
index 0000000..d5f6dff
--- /dev/null
+++ b/packages/client/src/test/fixtures.ts
@@ -0,0 +1,53 @@
+import { BadRequest } from '@passlock/shared/dist/error/error.js'
+import type { Principal } from '@passlock/shared/dist/schema/schema.js'
+import { Effect as E, Layer as L } from 'effect'
+import { Capabilities } from '../capabilities/capabilities.js'
+import { StorageService, type StoredToken } from '../storage/storage.js'
+
+export const session = 'session'
+export const token = 'token'
+export const code = 'code'
+export const authType = 'passkey'
+export const expireAt = Date.now() + 10000
+
+export const principal: Principal = {
+ token: 'token',
+ user: {
+ id: '1',
+ email: 'john.doe@gmail.com',
+ givenName: 'john',
+ familyName: 'doe',
+ emailVerified: false,
+ },
+ authStatement: {
+ authType: 'passkey',
+ userVerified: false,
+ authTimestamp: new Date(0),
+ },
+ expireAt: new Date(0),
+}
+
+export const capabilitiesTest = L.succeed(
+ Capabilities,
+ Capabilities.of({
+ passkeySupport: E.void,
+ isPasskeySupport: E.succeed(true),
+ autofillSupport: E.void,
+ isAutofillSupport: E.succeed(true),
+ }),
+)
+
+export const storedToken: StoredToken = { token, authType, expireAt }
+
+export const storageServiceTest = L.succeed(
+ StorageService,
+ StorageService.of({
+ storeToken: () => E.void,
+ getToken: () => E.succeed(storedToken),
+ clearToken: () => E.void,
+ clearExpiredToken: () => E.void,
+ clearExpiredTokens: E.void,
+ }),
+)
+
+export const notImplemented = new BadRequest({ message: 'Not implemented' })
\ No newline at end of file
diff --git a/packages/client/src/user/user.fixture.ts b/packages/client/src/user/user.fixture.ts
new file mode 100644
index 0000000..0831718
--- /dev/null
+++ b/packages/client/src/user/user.fixture.ts
@@ -0,0 +1,21 @@
+import { IsExistingUserReq, IsExistingUserRes, ResendEmailReq, ResendEmailRes, UserClient, VerifyEmailRes } from '@passlock/shared/dist/rpc/user.js'
+import { Effect as E, Layer as L } from 'effect'
+import * as Fixtures from '../test/fixtures.js'
+import type { ResendEmail } from './user.js'
+
+export const email = 'jdoe@gmail.com'
+export const isRegisteredReq = new IsExistingUserReq({ email })
+export const isRegisteredRes = new IsExistingUserRes({ existingUser: false })
+export const verifyEmailRes = new VerifyEmailRes({ principal: Fixtures.principal })
+export const resendEmailReq: ResendEmail = { userId: '123', method: 'code' }
+export const rpcResendEmailReq = new ResendEmailReq({ userId: '123', verifyEmail: { method: 'code' }})
+export const rpcResendEmailRes = new ResendEmailRes({ })
+
+export const rpcClientTest = L.succeed(
+ UserClient,
+ UserClient.of({
+ isExistingUser: () => E.succeed({ existingUser: true }),
+ verifyEmail: () => E.succeed(verifyEmailRes),
+ resendVerificationEmail: () => E.fail(Fixtures.notImplemented),
+ }),
+)
\ No newline at end of file
diff --git a/packages/client/src/user/user.test.ts b/packages/client/src/user/user.test.ts
new file mode 100644
index 0000000..f18aea7
--- /dev/null
+++ b/packages/client/src/user/user.test.ts
@@ -0,0 +1,86 @@
+import { UserClient } from '@passlock/shared/dist/rpc/user.js'
+import { Effect as E, Layer as L, Layer, LogLevel, Logger, pipe } from 'effect'
+import { describe, expect, test } from 'vitest'
+import { mock } from 'vitest-mock-extended'
+import * as Fixture from './user.fixture.js'
+import { UserService, UserServiceLive } from './user.js'
+
+describe('isExistingUser should', () => {
+ test('return true when the user already has a passkey', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(UserService)
+ const result = yield* _(service.isExistingUser({ email: Fixture.email }))
+
+ expect(result).toBe(true)
+ })
+
+ const service = pipe(UserServiceLive, L.provide(Fixture.rpcClientTest))
+
+ const effect = pipe(E.provide(assertions, service), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+
+ test('send the email to the backend', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(UserService)
+ const result = yield* _(service.isExistingUser({ email: Fixture.email }))
+
+ expect(result).toBe(false)
+ const rpcClient = yield* _(UserClient)
+ expect(rpcClient.isExistingUser).toBeCalledWith(Fixture.isRegisteredReq)
+ })
+
+ const rpcClientTest = Layer.effect(
+ UserClient,
+ E.sync(() => {
+ const rpcMock = mock()
+
+ rpcMock.isExistingUser.mockReturnValue(E.succeed(Fixture.isRegisteredRes))
+
+ return rpcMock
+ }),
+ )
+
+ const service = pipe(UserServiceLive, L.provide(rpcClientTest))
+
+ const layers = L.merge(service, rpcClientTest)
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
+
+ return E.runPromise(effect)
+ })
+})
+
+describe('resendVerificationEmail should', () => {
+ test('forward the request to the backend', async () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(UserService)
+ yield* _(service.resendVerificationEmail(Fixture.resendEmailReq))
+
+ const rpcClient = yield* _(UserClient)
+ expect(rpcClient.resendVerificationEmail).toBeCalledWith(Fixture.rpcResendEmailReq)
+ })
+
+ const rpcClientTest = Layer.effect(
+ UserClient,
+ E.sync(() => {
+ const rpcMock = mock()
+
+ rpcMock.resendVerificationEmail.mockReturnValue(E.succeed(Fixture.rpcResendEmailRes))
+
+ return rpcMock
+ }),
+ )
+
+ const service = pipe(UserServiceLive, L.provide(rpcClientTest))
+
+ const layers = L.merge(service, rpcClientTest)
+
+ const effect = pipe(
+ E.provide(assertions, layers),
+ Logger.withMinimumLogLevel(LogLevel.None)
+ )
+
+ return E.runPromise(effect)
+ })
+})
diff --git a/packages/client/src/user/user.ts b/packages/client/src/user/user.ts
new file mode 100644
index 0000000..67ed553
--- /dev/null
+++ b/packages/client/src/user/user.ts
@@ -0,0 +1,67 @@
+/**
+ * Check for an existing user
+ */
+import type { BadRequest, Disabled, NotFound } from '@passlock/shared/dist/error/error.js'
+import { IsExistingUserReq, ResendEmailReq, UserClient } from '@passlock/shared/dist/rpc/user.js'
+import type { VerifyEmail } from '@passlock/shared/dist/schema/schema.js'
+import { Context, Effect as E, Layer, flow } from 'effect'
+
+/* Requests */
+
+export type Email = { email: string }
+export type ResendEmail = VerifyEmail & { userId: string }
+
+/* Errors */
+
+export type ResendEmailErrors = BadRequest | NotFound | Disabled
+
+/* Service */
+
+export type UserService = {
+ isExistingUser: (request: Email) => E.Effect
+ resendVerificationEmail: (request: ResendEmail) => E.Effect
+}
+
+export const UserService = Context.GenericTag('@services/UserService')
+
+/* Effects */
+
+type Dependencies = UserClient
+
+export const isExistingUser = (request: Email): E.Effect => {
+ return E.gen(function* (_) {
+ yield* _(E.logInfo('Checking registration status'))
+ const rpcClient = yield* _(UserClient)
+
+ yield* _(E.logDebug('Making RPC request'))
+ const { existingUser } = yield* _(rpcClient.isExistingUser(new IsExistingUserReq(request)))
+
+ return existingUser
+ })
+}
+
+export const resendVerificationEmail = (request: ResendEmail): E.Effect => {
+ return E.gen(function* (_) {
+ yield* _(E.logInfo('Resending verification email'))
+ const rpcClient = yield* _(UserClient)
+
+ yield* _(E.logDebug('Making RPC request'))
+ const { userId, ...verifyEmail } = request
+ yield* _(rpcClient.resendVerificationEmail(new ResendEmailReq({ userId, verifyEmail })))
+ })
+}
+
+/* Live */
+
+/* v8 ignore start */
+export const UserServiceLive = Layer.effect(
+ UserService,
+ E.gen(function* (_) {
+ const context = yield* _(E.context())
+ return UserService.of({
+ isExistingUser: flow(isExistingUser, E.provide(context)),
+ resendVerificationEmail: flow(resendVerificationEmail, E.provide(context))
+ })
+ }),
+)
+/* v8 ignore stop */
diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json
new file mode 100644
index 0000000..fd16bd4
--- /dev/null
+++ b/packages/client/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "extends": "@tsconfig/node18/tsconfig.json",
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": true,
+ "exactOptionalPropertyTypes": true,
+ "lib": ["es2023", "DOM", "DOM.Iterable"],
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitReturns": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "outDir": "./dist",
+ "removeComments": true,
+ "rootDir": "./src",
+ "sourceMap": true,
+ "verbatimModuleSyntax": true
+ },
+ "include": [
+ "./src/**/*.ts",
+ "./src/**/*.json"
+ ],
+ "exclude": [
+ "./node_modules/**"
+ ]
+}
\ No newline at end of file
diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts
new file mode 100644
index 0000000..85b4a1a
--- /dev/null
+++ b/packages/client/vite.config.ts
@@ -0,0 +1,23 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ coverage: {
+ provider: 'v8',
+ include: ['src/**'],
+ reporter: ['text', ['html', { subdir: 'html' }]],
+ exclude: [
+ 'src/index.ts', // re-exports
+ 'src/effect.ts', // re-exports
+ 'src/config.ts', // no real logic here
+ 'src/capabilities/*.ts', // wrapper around browser api
+ 'src/**/*.fixture.ts', // test fixtures
+ 'src/test/fixtures.ts', // test fixtures
+ ],
+ },
+ },
+ server: {
+ port: 5174
+ }
+})
diff --git a/packages/node/.eslintignore b/packages/node/.eslintignore
new file mode 100644
index 0000000..4e4b960
--- /dev/null
+++ b/packages/node/.eslintignore
@@ -0,0 +1,2 @@
+/*
+!/src
\ No newline at end of file
diff --git a/packages/node/.eslintrc.cjs b/packages/node/.eslintrc.cjs
new file mode 100644
index 0000000..701ab02
--- /dev/null
+++ b/packages/node/.eslintrc.cjs
@@ -0,0 +1,88 @@
+module.exports = {
+ env: {
+ browser: true,
+ es2021: true,
+ node: true
+ },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:import/recommended',
+ 'plugin:import/typescript',
+ 'plugin:@typescript-eslint/strict-type-checked',
+ 'prettier'
+ ],
+ overrides: [],
+ parser: "@typescript-eslint/parser",
+ parserOptions: {
+ project: './tsconfig.json'
+ },
+ plugins: [
+ "@typescript-eslint",
+ "import"
+ ],
+ root: true,
+ rules: {
+ "@typescript-eslint/no-unused-vars": [
+ "error",
+ {
+ "ignoreRestSiblings": true,
+ "argsIgnorePattern": "^_",
+ "varsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_"
+ }
+ ],
+ "@typescript-eslint/consistent-type-definitions": [
+ "warn",
+ "type"
+ ],
+ "@typescript-eslint/consistent-type-imports": [
+ "error"
+ ],
+ "sort-imports": [
+ "warn",
+ {
+ ignoreCase: false,
+ ignoreDeclarationSort: true, // don"t want to sort import lines, use eslint-plugin-import instead
+ ignoreMemberSort: false,
+ memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'],
+ allowSeparatedGroups: true,
+ },
+ ],
+
+ "import/no-unresolved": "error",
+ "import/newline-after-import": [
+ "warn",
+ {
+ "count": 1
+ }
+ ],
+ 'import/order': [
+ 'warn',
+ {
+ groups: [
+ 'builtin', // Built-in imports (come from NodeJS native) go first
+ 'external', // <- External imports
+ 'internal', // <- Absolute imports
+ ['sibling', 'parent'], // <- Relative imports, the sibling and parent types they can be mingled together
+ 'index', // <- index imports
+ 'unknown', // <- unknown
+ ],
+ 'newlines-between': 'ignore',
+ alphabetize: {
+ /* sort in ascending order. Options: ["ignore", "asc", "desc"] */
+ order: 'asc',
+ /* ignore case. Options: [true, false] */
+ caseInsensitive: true,
+ },
+ },
+ ]
+ },
+ settings: {
+ "import/parsers": {
+ "@typescript-eslint/parser": [".ts", ".tsx"]
+ },
+ "import/resolver": {
+ "typescript": true
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/node/.gitignore b/packages/node/.gitignore
new file mode 100644
index 0000000..b736c88
--- /dev/null
+++ b/packages/node/.gitignore
@@ -0,0 +1,34 @@
+# dependencies
+node_modules
+
+# production
+build
+dist
+dist-ssr
+*.local
+
+# logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+# typescript
+tsconfig.tsbuildinfo
+
+# editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# coverage
+coverage
diff --git a/packages/node/.prettierrc.json b/packages/node/.prettierrc.json
new file mode 100644
index 0000000..5f7b9ac
--- /dev/null
+++ b/packages/node/.prettierrc.json
@@ -0,0 +1,11 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "arrowParens": "avoid",
+ "printWidth": 100,
+ "quoteProps": "consistent",
+ "bracketSpacing": true,
+ "htmlWhitespaceSensitivity": "ignore",
+ "bracketSameLine": true,
+ "useTabs": false
+}
diff --git a/packages/node/LICENSE b/packages/node/LICENSE
new file mode 100644
index 0000000..0b9c381
--- /dev/null
+++ b/packages/node/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Passlock
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/node/package.json b/packages/node/package.json
new file mode 100644
index 0000000..cd8a52f
--- /dev/null
+++ b/packages/node/package.json
@@ -0,0 +1,73 @@
+{
+ "name": "@passlock/node",
+ "type": "module",
+ "version": "0.9.19",
+ "description": "Server side passkey library for node/express",
+ "keywords": [
+ "passkey",
+ "passkeys",
+ "webauthn",
+ "node",
+ "express"
+ ],
+ "author": {
+ "name": "Toby Hobson",
+ "email": "toby@passlock.dev"
+ },
+ "license": "MIT",
+ "homepage": "https://passlock.dev",
+ "repository": "github.com/passlock-dev/node",
+ "bugs": {
+ "url": "https://github.com/passlock-dev/node/issues",
+ "email": "team@passlock.dev"
+ },
+ "exports": {
+ ".": {
+ "import": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ }
+ },
+ "files": [
+ "src",
+ "dist"
+ ],
+ "scripts": {
+ "clean": "tsc --build --clean",
+ "typecheck": "tsc --noEmit",
+ "test": "vitest run",
+ "test:watch": "vitest dev",
+ "test:ui": "vitest --coverage.enabled=true --ui",
+ "test:coverage": "vitest run --coverage",
+ "build": "tsc --build",
+ "build:clean": "pnpm run clean && pnpm run build",
+ "build:watch": "tsc --build --watch",
+ "format": "prettier --write \"src/**/*.+(js|ts|json)\"",
+ "lint": "eslint --ext .ts src",
+ "lint:fix": "pnpm run lint --fix",
+ "prepublishOnly": "pnpm run clean && pnpm run build",
+ "ncu": "ncu --peer -x @effect/* -x effect",
+ "ncu:save": "ncu --peer -x @effect/* -x effect -u"
+ },
+ "dependencies": {
+ "@passlock/shared": "workspace:^",
+ "effect": "^3.4.8"
+ },
+ "devDependencies": {
+ "@tsconfig/node18": "^18.2.4",
+ "@types/node": "^20.14.10",
+ "@typescript-eslint/eslint-plugin": "^7.15.0",
+ "@typescript-eslint/parser": "^7.15.0",
+ "@vitest/coverage-v8": "^2.0.3",
+ "@vitest/ui": "^2.0.3",
+ "eslint": "^8.57.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-import-resolver-typescript": "^3.6.1",
+ "eslint-plugin-import": "^2.29.1",
+ "prettier": "^3.3.2",
+ "rimraf": "^5.0.8",
+ "tsx": "^4.16.2",
+ "typescript": "^5.5.3",
+ "vite": "^5.3.3",
+ "vitest": "^2.0.3"
+ }
+}
diff --git a/packages/node/src/config/config.ts b/packages/node/src/config/config.ts
new file mode 100644
index 0000000..792b45f
--- /dev/null
+++ b/packages/node/src/config/config.ts
@@ -0,0 +1,10 @@
+import { Context } from 'effect'
+
+export class Config extends Context.Tag('Config')<
+ Config,
+ {
+ readonly tenancyId: string
+ readonly apiKey: string
+ readonly endpoint?: string
+ }
+>() {}
diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts
new file mode 100644
index 0000000..54140e9
--- /dev/null
+++ b/packages/node/src/index.ts
@@ -0,0 +1,153 @@
+import {
+ ErrorCode,
+ Forbidden,
+ InternalServerError,
+ NotFound,
+ Unauthorized,
+} from '@passlock/shared/dist/error/error.js'
+import { Effect as E, Layer as L, Runtime, Scope, pipe } from 'effect'
+import { Config } from './config/config.js'
+import {
+ PrincipalService,
+ PrincipalServiceLive,
+ StreamResponseLive,
+ type PrincipalRequest,
+} from './principal/principal.js'
+
+export type { PrincipalRequest } from './principal/principal.js'
+
+export { ErrorCode } from '@passlock/shared/dist/error/error.js'
+
+export class PasslockError extends Error {
+ readonly _tag = 'PasslockError'
+ readonly code: ErrorCode
+
+ constructor(message: string, code: ErrorCode) {
+ super(message)
+ this.code = code
+ }
+
+ static readonly isError = (error: unknown): error is PasslockError => {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ '_tag' in error &&
+ error['_tag'] === 'PasslockError'
+ )
+ }
+}
+
+type PasslockErrors = NotFound | Unauthorized | Forbidden | InternalServerError
+
+const hasMessage = (defect: unknown): defect is { message: string } => {
+ return (
+ typeof defect === 'object' &&
+ defect !== null &&
+ 'message' in defect &&
+ typeof defect['message'] === 'string'
+ )
+}
+
+const transformErrors = (
+ effect: E.Effect,
+): E.Effect => {
+ const withErrorHandling = E.catchTags(effect, {
+ NotFound: e => E.succeed(new PasslockError(e.message, ErrorCode.NotFound)),
+ Unauthorized: e => E.succeed(new PasslockError(e.message, ErrorCode.Unauthorized)),
+ Forbidden: e => E.succeed(new PasslockError(e.message, ErrorCode.Forbidden)),
+ InternalServerError: e =>
+ E.succeed(new PasslockError(e.message, ErrorCode.InternalServerError)),
+ })
+
+ const sandboxed = E.sandbox(withErrorHandling)
+
+ const withSandboxing = E.catchTags(sandboxed, {
+ Die: ({ defect }) => {
+ return hasMessage(defect)
+ ? E.succeed(new PasslockError(defect.message, ErrorCode.InternalServerError))
+ : E.succeed(new PasslockError('Sorry, something went wrong', ErrorCode.InternalServerError))
+ },
+
+ Interrupt: () => {
+ return E.succeed(new PasslockError('Operation aborted', ErrorCode.InternalBrowserError))
+ },
+
+ Sequential: errors => {
+ console.error(errors)
+ return E.succeed(
+ new PasslockError('Sorry, something went wrong', ErrorCode.InternalServerError),
+ )
+ },
+
+ Parallel: errors => {
+ console.error(errors)
+ return E.succeed(
+ new PasslockError('Sorry, something went wrong', ErrorCode.InternalServerError),
+ )
+ },
+ })
+
+ return E.unsandbox(withSandboxing)
+}
+
+type Requirements = PrincipalService
+
+export class PasslockUnsafe {
+ private readonly runtime: Runtime.Runtime
+
+ constructor(config: { tenancyId: string; apiKey: string; endpoint?: string }) {
+ const configLive = L.succeed(Config, Config.of(config))
+ const allLayers = pipe(
+ PrincipalServiceLive,
+ L.provide(configLive),
+ L.provide(StreamResponseLive),
+ )
+ const scope = E.runSync(Scope.make())
+ this.runtime = E.runSync(L.toRuntime(allLayers).pipe(Scope.extend(scope)))
+ }
+
+ private readonly runPromise = (
+ effect: E.Effect,
+ ) => {
+ return pipe(
+ transformErrors(effect),
+ E.flatMap(result => (PasslockError.isError(result) ? E.fail(result) : E.succeed(result))),
+ effect => Runtime.runPromise(this.runtime)(effect),
+ )
+ }
+
+ fetchPrincipal = (request: PrincipalRequest) =>
+ pipe(
+ PrincipalService,
+ E.flatMap(service => service.fetchPrincipal(request)),
+ effect => this.runPromise(effect),
+ )
+}
+
+export class Passlock {
+ private readonly runtime: Runtime.Runtime
+
+ constructor(config: { tenancyId: string; apiKey: string; endpoint?: string }) {
+ const configLive = L.succeed(Config, Config.of(config))
+ const allLayers = pipe(
+ PrincipalServiceLive,
+ L.provide(configLive),
+ L.provide(StreamResponseLive),
+ )
+ const scope = E.runSync(Scope.make())
+ this.runtime = E.runSync(L.toRuntime(allLayers).pipe(Scope.extend(scope)))
+ }
+
+ private readonly runPromise = (
+ effect: E.Effect,
+ ) => {
+ return pipe(transformErrors(effect), effect => Runtime.runPromise(this.runtime)(effect))
+ }
+
+ fetchPrincipal = (request: PrincipalRequest) =>
+ pipe(
+ PrincipalService,
+ E.flatMap(service => service.fetchPrincipal(request)),
+ effect => this.runPromise(effect),
+ )
+}
diff --git a/packages/node/src/principal/principal.fixture.ts b/packages/node/src/principal/principal.fixture.ts
new file mode 100644
index 0000000..c057fb6
--- /dev/null
+++ b/packages/node/src/principal/principal.fixture.ts
@@ -0,0 +1,80 @@
+import type { Principal } from '@passlock/shared/dist/schema/schema.js'
+import { Context, Effect as E, Layer as L, LogLevel, Logger, Ref, Stream, pipe } from 'effect'
+import type { RequestOptions } from 'https'
+import { Config } from '../config/config.js'
+import {
+ PrincipalServiceLive,
+ StreamResponse,
+ buildError,
+ type PrincipalService,
+} from './principal.js'
+
+export const principal: Principal = {
+ token: 'token',
+ user: {
+ id: '1',
+ email: 'john.doe@gmail.com',
+ givenName: 'john',
+ familyName: 'doe',
+ emailVerified: false,
+ },
+ authStatement: {
+ authType: 'email',
+ userVerified: false,
+ authTimestamp: new Date(0),
+ },
+ expireAt: new Date(0),
+}
+
+export const tenancyId = 'tenancyId'
+export const apiKey = 'apiKey'
+
+export const configTest = L.succeed(Config, Config.of({ tenancyId, apiKey }))
+
+export class State extends Context.Tag('State')>() {}
+
+export const buildEffect = (
+ assertions: E.Effect,
+): E.Effect => {
+ const responseStreamTest = L.effect(
+ StreamResponse,
+ E.gen(function* (_) {
+ const ref = yield* _(State)
+ const buff = Buffer.from(JSON.stringify(principal))
+ return options =>
+ pipe(Stream.fromEffect(Ref.set(ref, options)), Stream.zipRight(Stream.make(buff)))
+ }),
+ )
+
+ const service = pipe(PrincipalServiceLive, L.provide(responseStreamTest), L.provide(configTest))
+
+ const args = L.effect(State, Ref.make(undefined))
+
+ const effect = pipe(
+ E.provide(assertions, service),
+ E.provide(args),
+ Logger.withMinimumLogLevel(LogLevel.None),
+ )
+
+ return effect
+}
+
+export const buildErrorEffect =
+ (statusCode: number) =>
+ (assertions: E.Effect): E.Effect => {
+ const responseStreamTest = L.succeed(StreamResponse, () =>
+ Stream.fail(buildError({ statusCode })),
+ )
+
+ const service = pipe(PrincipalServiceLive, L.provide(responseStreamTest), L.provide(configTest))
+
+ const args = L.effect(State, Ref.make(undefined))
+
+ const effect = pipe(
+ E.provide(assertions, service),
+ E.provide(args),
+ Logger.withMinimumLogLevel(LogLevel.None),
+ )
+
+ return effect
+ }
diff --git a/packages/node/src/principal/principal.test.ts b/packages/node/src/principal/principal.test.ts
new file mode 100644
index 0000000..d77f662
--- /dev/null
+++ b/packages/node/src/principal/principal.test.ts
@@ -0,0 +1,111 @@
+import {
+ Forbidden,
+ InternalServerError,
+ NotFound,
+ Unauthorized,
+} from '@passlock/shared/dist/error/error.js'
+import { Effect as E, Effect, Ref } from 'effect'
+import { describe, expect, test } from 'vitest'
+import * as Fixture from './principal.fixture.js'
+import { PrincipalService } from './principal.js'
+
+describe('fetchPrincipal should', () => {
+ test('return a valid principal', () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(PrincipalService)
+ const result = yield* _(service.fetchPrincipal({ token: 'token' }))
+
+ expect(result).toEqual(Fixture.principal)
+ })
+
+ const effect = Fixture.buildEffect(assertions)
+
+ return E.runPromise(effect)
+ })
+
+ test('call the correct url', () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(PrincipalService)
+ yield* _(service.fetchPrincipal({ token: 'myToken' }))
+
+ const state = yield* _(Fixture.State)
+ const args = yield* _(Ref.get(state))
+
+ expect(args?.hostname).toEqual('api.passlock.dev')
+ expect(args?.method).toEqual('GET')
+ expect(args?.path).toEqual(`/${Fixture.tenancyId}/token/myToken`)
+ })
+
+ const effect = Fixture.buildEffect(assertions)
+
+ return E.runPromise(effect)
+ })
+
+ test('pass the api key as a header', () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(PrincipalService)
+ yield* _(service.fetchPrincipal({ token: 'myToken' }))
+
+ const state = yield* _(Fixture.State)
+ const args = yield* _(Ref.get(state))
+
+ expect(args?.headers?.['Authorization']).toEqual(`Bearer ${Fixture.apiKey}`)
+ })
+
+ const effect = Fixture.buildEffect(assertions)
+
+ return E.runPromise(effect)
+ })
+
+ test('propagate a 401 error', () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(PrincipalService)
+ const result = service.fetchPrincipal({ token: 'myToken' })
+ const error = yield* _(Effect.flip(result))
+ expect(error).toBeInstanceOf(Unauthorized)
+ })
+
+ const effect = Fixture.buildErrorEffect(401)(assertions)
+
+ return E.runPromise(effect)
+ })
+
+ test('propagate a 403 error', () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(PrincipalService)
+ const result = service.fetchPrincipal({ token: 'myToken' })
+ const error = yield* _(Effect.flip(result))
+ expect(error).toBeInstanceOf(Forbidden)
+ })
+
+ const effect = Fixture.buildErrorEffect(403)(assertions)
+
+ return E.runPromise(effect)
+ })
+
+ test('propagate a 404 error', () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(PrincipalService)
+ const result = service.fetchPrincipal({ token: 'myToken' })
+ const error = yield* _(Effect.flip(result))
+ expect(error).toBeInstanceOf(NotFound)
+ })
+
+ const effect = Fixture.buildErrorEffect(404)(assertions)
+
+ return E.runPromise(effect)
+ })
+
+ test('propagate a 500 error', () => {
+ const assertions = E.gen(function* (_) {
+ const service = yield* _(PrincipalService)
+ const result = service.fetchPrincipal({ token: 'myToken' })
+ const error = yield* _(Effect.flip(result))
+ expect(error).toBeInstanceOf(InternalServerError)
+ })
+
+ const effect = Fixture.buildErrorEffect(500)(assertions)
+
+ return E.runPromise(effect)
+ })
+})
diff --git a/packages/node/src/principal/principal.ts b/packages/node/src/principal/principal.ts
new file mode 100644
index 0000000..e6cfc77
--- /dev/null
+++ b/packages/node/src/principal/principal.ts
@@ -0,0 +1,151 @@
+import {
+ Forbidden,
+ InternalServerError,
+ NotFound,
+ Unauthorized,
+} from '@passlock/shared/dist/error/error.js'
+import { Principal, createParser } from '@passlock/shared/dist/schema/schema.js'
+import type { StreamEmit } from 'effect'
+import { Chunk, Console, Context, Effect as E, Layer, Option, Stream, flow, pipe } from 'effect'
+import * as https from 'https'
+import { Config } from '../config/config.js'
+
+/* Dependencies */
+
+export type StreamResponse = (
+ options: https.RequestOptions,
+) => Stream.Stream
+export const StreamResponse = Context.GenericTag('@services/ResponseStream')
+
+/* Service */
+
+const parsePrincipal = createParser(Principal)
+
+export type PrincipalErrors = NotFound | Unauthorized | Forbidden | InternalServerError
+export type PrincipalRequest = { token: string }
+
+export type PrincipalService = {
+ fetchPrincipal: (request: PrincipalRequest) => E.Effect
+}
+
+export const PrincipalService = Context.GenericTag('@services/Principal')
+
+/* Effects */
+
+const buildHostname = (endpoint: string | undefined) => {
+ return new URL(endpoint || 'https://api.passlock.dev').hostname
+}
+
+const buildOptions = (token: string) =>
+ pipe(
+ Config,
+ E.map(({ endpoint, tenancyId, apiKey }) => ({
+ hostname: buildHostname(endpoint),
+ port: 443,
+ path: `/${tenancyId}/token/${token}`,
+ method: 'GET',
+ headers: {
+ 'Accept': 'application/json',
+ 'Authorization': `Bearer ${apiKey}`,
+ },
+ })),
+ )
+
+export const buildError = (res: {
+ statusCode?: number | undefined
+ statusMessage?: string | undefined
+}) => {
+ if (res.statusCode === 404) return new NotFound({ message: 'Invalid token' })
+ if (res.statusCode === 401) return new Unauthorized({ message: 'Unauthorized' })
+ if (res.statusCode === 403) return new Forbidden({ message: 'Forbidden' })
+
+ if (res.statusCode && res.statusMessage)
+ return new InternalServerError({ message: `${String(res.statusCode)} - ${res.statusMessage}` })
+
+ if (res.statusCode) return new InternalServerError({ message: String(res.statusCode) })
+
+ if (res.statusMessage) return new InternalServerError({ message: res.statusMessage })
+
+ return new InternalServerError({ message: 'Received non 200 response' })
+}
+
+const fail = (error: PrincipalErrors) => E.fail(Option.some(error))
+const succeed = (data: Buffer) => E.succeed(Chunk.of(data))
+const close = E.fail(Option.none())
+
+const buildStream = (token: string) =>
+ pipe(
+ Stream.fromEffect(buildOptions(token)),
+ Stream.zip(StreamResponse),
+ Stream.flatMap(([options, streamResponse]) => streamResponse(options)),
+ )
+
+export const fetchPrincipal = (
+ request: PrincipalRequest,
+): E.Effect => {
+ const stream = buildStream(request.token)
+
+ const json = pipe(
+ Stream.runCollect(stream),
+ E.map(Chunk.toReadonlyArray),
+ E.map(buffers => Buffer.concat(buffers)),
+ E.flatMap(buffer =>
+ E.try({
+ try: () => buffer.toString(),
+ catch: e =>
+ new InternalServerError({
+ message: 'Unable to convert response to string',
+ detail: String(e),
+ }),
+ }),
+ ),
+ E.flatMap(buffer =>
+ E.try({
+ try: () => JSON.parse(buffer) as unknown,
+ catch: e =>
+ new InternalServerError({
+ message: 'Unable to parse response to json',
+ detail: String(e),
+ }),
+ }),
+ ),
+ E.flatMap(json =>
+ pipe(
+ parsePrincipal(json),
+ E.tapError(error => Console.error(error.detail)),
+ E.mapError(
+ () => new InternalServerError({ message: 'Unable to parse response as Principal' }),
+ ),
+ ),
+ ),
+ )
+
+ return json
+}
+
+/* Live */
+
+/* v8 ignore start */
+export const StreamResponseLive = Layer.succeed(StreamResponse, options => {
+ return Stream.async((emit: StreamEmit.Emit) => {
+ https
+ .request(options, res => {
+ if (200 !== res.statusCode) void emit(fail(buildError(res)))
+ res.on('data', (data: Buffer) => void emit(succeed(data)))
+ res.on('close', () => void emit(close))
+ res.on('error', e => void emit(fail(new InternalServerError({ message: e.message }))))
+ })
+ .end()
+ })
+})
+
+export const PrincipalServiceLive = Layer.effect(
+ PrincipalService,
+ E.gen(function* (_) {
+ const context = yield* _(E.context())
+ return PrincipalService.of({
+ fetchPrincipal: flow(fetchPrincipal, E.provide(context)),
+ })
+ }),
+)
+/* v8 ignore stop */
diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json
new file mode 100644
index 0000000..0b38859
--- /dev/null
+++ b/packages/node/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "extends": "@tsconfig/node18/tsconfig.json",
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": true,
+ "exactOptionalPropertyTypes": true,
+ "lib": ["es2023", "DOM", "DOM.Iterable"],
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "noFallthroughCasesInSwitch": true,
+ "noImplicitReturns": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "outDir": "./dist",
+ "removeComments": true,
+ "rootDir": "./src",
+ "sourceMap": true,
+ "verbatimModuleSyntax": true
+ },
+ "include": [
+ "./src/**/*.ts",
+ "./src/**/*.json"
+ ],
+ "exclude": ["./node_modules/**"]
+}
\ No newline at end of file
diff --git a/packages/node/vite.config.ts b/packages/node/vite.config.ts
new file mode 100644
index 0000000..77ca5cc
--- /dev/null
+++ b/packages/node/vite.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ coverage: {
+ provider: 'v8',
+ include: ['src/**'],
+ reporter: ['text', ['html', { subdir: 'html' }]],
+ exclude: [
+ 'src/index.ts', // no real logic here
+ 'src/config.ts', // no real logic here
+ 'src/effect.ts', // no real logic here
+ 'src/**/*.fixture.ts', // test fixtures
+ 'src/test/fixtures.ts', // test fixtures
+ ],
+ },
+ },
+ server: {
+ port: 5174
+ }
+})
diff --git a/packages/shared/.eslintignore b/packages/shared/.eslintignore
new file mode 100644
index 0000000..4e4b960
--- /dev/null
+++ b/packages/shared/.eslintignore
@@ -0,0 +1,2 @@
+/*
+!/src
\ No newline at end of file
diff --git a/packages/shared/.eslintrc.cjs b/packages/shared/.eslintrc.cjs
new file mode 100644
index 0000000..540dd96
--- /dev/null
+++ b/packages/shared/.eslintrc.cjs
@@ -0,0 +1,84 @@
+module.exports = {
+ env: {
+ browser: true,
+ es2021: true,
+ node: true
+ },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:import/recommended',
+ 'plugin:import/typescript',
+ 'plugin:@typescript-eslint/strict-type-checked',
+ 'prettier'
+ ],
+ overrides: [],
+ parser: "@typescript-eslint/parser",
+ parserOptions: {
+ project: './tsconfig.json'
+ },
+ plugins: [
+ "@typescript-eslint",
+ "import"
+ ],
+ root: true,
+ rules: {
+ "@typescript-eslint/no-unused-vars": [
+ "error",
+ {
+ "ignoreRestSiblings": true,
+ "argsIgnorePattern": "^_",
+ "varsIgnorePattern": "^_",
+ "caughtErrorsIgnorePattern": "^_"
+ }
+ ],
+ "@typescript-eslint/consistent-type-definitions": [
+ "warn",
+ "type"
+ ],
+ "sort-imports": [
+ "warn",
+ {
+ ignoreCase: false,
+ ignoreDeclarationSort: true, // don"t want to sort import lines, use eslint-plugin-import instead
+ ignoreMemberSort: false,
+ memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'],
+ allowSeparatedGroups: true,
+ },
+ ],
+ "import/no-unresolved": "error",
+ "import/newline-after-import": [
+ "warn",
+ {
+ "count": 1
+ }
+ ],
+ 'import/order': [
+ 'warn',
+ {
+ groups: [
+ 'builtin', // Built-in imports (come from NodeJS native) go first
+ 'external', // <- External imports
+ 'internal', // <- Absolute imports
+ ['sibling', 'parent'], // <- Relative imports, the sibling and parent types they can be mingled together
+ 'index', // <- index imports
+ 'unknown', // <- unknown
+ ],
+ 'newlines-between': 'always',
+ alphabetize: {
+ /* sort in ascending order. Options: ["ignore", "asc", "desc"] */
+ order: 'asc',
+ /* ignore case. Options: [true, false] */
+ caseInsensitive: true,
+ },
+ },
+ ]
+ },
+ settings: {
+ "import/parsers": {
+ "@typescript-eslint/parser": [".ts", ".tsx"]
+ },
+ "import/resolver": {
+ "typescript": true
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/shared/.gitignore b/packages/shared/.gitignore
new file mode 100644
index 0000000..b736c88
--- /dev/null
+++ b/packages/shared/.gitignore
@@ -0,0 +1,34 @@
+# dependencies
+node_modules
+
+# production
+build
+dist
+dist-ssr
+*.local
+
+# logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+# typescript
+tsconfig.tsbuildinfo
+
+# editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# coverage
+coverage
diff --git a/packages/shared/.prettierrc.json b/packages/shared/.prettierrc.json
new file mode 100644
index 0000000..5f7b9ac
--- /dev/null
+++ b/packages/shared/.prettierrc.json
@@ -0,0 +1,11 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "arrowParens": "avoid",
+ "printWidth": 100,
+ "quoteProps": "consistent",
+ "bracketSpacing": true,
+ "htmlWhitespaceSensitivity": "ignore",
+ "bracketSameLine": true,
+ "useTabs": false
+}
diff --git a/packages/shared/LICENSE b/packages/shared/LICENSE
new file mode 100644
index 0000000..0b9c381
--- /dev/null
+++ b/packages/shared/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Passlock
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/shared/package.json b/packages/shared/package.json
new file mode 100644
index 0000000..ccdd694
--- /dev/null
+++ b/packages/shared/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@passlock/shared",
+ "version": "0.9.19",
+ "type": "module",
+ "description": "Shared code use by Passlock frontend libraries & backend SDKs",
+ "keywords": [
+ "passlock"
+ ],
+ "author": {
+ "name": "Toby Hobson",
+ "email": "toby@passlock.dev"
+ },
+ "license": "MIT",
+ "homepage": "https://github.com/passlock-dev/shared",
+ "files": [
+ "dist",
+ "src"
+ ],
+ "scripts": {
+ "clean": "rimraf ./dist",
+ "typecheck": "tsc --noEmit",
+ "build": "tsc --build",
+ "build:clean": "pnpm run clean && pnpm run build",
+ "build:watch": "tsc --build --watch",
+ "format": "prettier --write \"src/**/*.+(js|ts|json)\"",
+ "lint": "eslint --ext .ts src",
+ "lint:fix": "pnpm run lint --fix",
+ "prepublishOnly": "pnpm run clean && pnpm run build",
+ "ncu": "ncu --peer -x @effect/* -x effect",
+ "ncu:save": "ncu --peer -x @effect/* -x effect -u"
+ },
+ "devDependencies": {
+ "@tsconfig/node18": "^18.2.4",
+ "@types/node": "^20.14.10",
+ "@typescript-eslint/eslint-plugin": "^7.15.0",
+ "@typescript-eslint/parser": "^7.15.0",
+ "@vitest/coverage-v8": "^2.0.3",
+ "eslint": "^8.57.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-import-resolver-typescript": "^3.6.1",
+ "eslint-plugin-import": "^2.29.1",
+ "eslint-plugin-prettier": "^5.1.3",
+ "fast-check": "^3.19.0",
+ "prettier": "^3.3.2",
+ "rimraf": "^5.0.8",
+ "typescript": "^5.5.3",
+ "vitest": "^2.0.3"
+ },
+ "dependencies": {
+ "effect": "^3.4.8",
+ "@effect/rpc": "^0.31.21",
+ "@effect/schema": "^0.68.18",
+ "@effect/platform": "^0.58.21"
+ }
+}
diff --git a/packages/shared/src/error/error.ts b/packages/shared/src/error/error.ts
new file mode 100644
index 0000000..572afe5
--- /dev/null
+++ b/packages/shared/src/error/error.ts
@@ -0,0 +1,89 @@
+import * as S from '@effect/schema/Schema'
+import { Data } from 'effect'
+
+export const ErrorCode = {
+ NotSupported: 'NotSupported',
+ BadRequest: 'BadRequest',
+ Duplicate: 'Duplicate',
+ Forbidden: 'Forbidden',
+ InternalBrowserError: 'InternalBrowserError',
+ InternalServerError: 'InternalServerError',
+ NetworkError: 'NetworkError',
+ NotFound: 'NotFound',
+ Disabled: 'Disabled',
+ Unauthorized: 'Unauthorized',
+} as const
+
+export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]
+
+/* Client errors */
+
+/**
+ * Browser doesn't support passkeys, autofill etc
+ */
+export class NotSupported extends Data.TaggedError(ErrorCode.NotSupported)<{
+ message: string
+}> {}
+
+export class InternalBrowserError extends S.TaggedError()(
+ ErrorCode.InternalBrowserError,
+ {
+ message: S.String,
+ detail: S.optional(S.String),
+ },
+) {}
+
+/* 400 style errors */
+
+export class BadRequest extends S.TaggedError()(ErrorCode.BadRequest, {
+ message: S.String,
+ detail: S.optional(S.String),
+}) {}
+
+/**
+ * Email already in use, Passkey already registered etc
+ */
+export class Duplicate extends S.TaggedError()(ErrorCode.Duplicate, {
+ message: S.String,
+ detail: S.optional(S.String),
+}) {}
+
+export class NotFound extends S.TaggedError()(ErrorCode.NotFound, {
+ message: S.String,
+ detail: S.optional(S.String),
+}) {}
+
+/**
+ * User/API key is disabled
+ */
+export class Disabled extends S.TaggedError()(ErrorCode.Disabled, {
+ message: S.String,
+ detail: S.optional(S.String),
+}) {}
+
+/* Permissions */
+
+export class Unauthorized extends S.TaggedError()(ErrorCode.Unauthorized, {
+ message: S.String,
+ detail: S.optional(S.String),
+}) {}
+
+export class Forbidden extends S.TaggedError()(ErrorCode.Forbidden, {
+ message: S.String,
+ detail: S.optional(S.String),
+}) {}
+
+/* Other errors */
+
+export class NetworkError extends S.TaggedError()(ErrorCode.NetworkError, {
+ message: S.String,
+ detail: S.optional(S.String),
+}) {}
+
+export class InternalServerError extends S.TaggedError()(
+ ErrorCode.InternalServerError,
+ {
+ message: S.String,
+ detail: S.optional(S.String),
+ },
+) {}
diff --git a/packages/shared/src/rpc/authentication.ts b/packages/shared/src/rpc/authentication.ts
new file mode 100644
index 0000000..9c84ad7
--- /dev/null
+++ b/packages/shared/src/rpc/authentication.ts
@@ -0,0 +1,87 @@
+import * as S from '@effect/schema/Schema'
+import { Context, Effect as E, Layer } from 'effect'
+
+import { BadRequest, Disabled, Forbidden, NotFound, Unauthorized } from '../error/error.js'
+
+import {
+ AuthenticationCredential,
+ AuthenticationOptions,
+ Principal,
+ UserVerification,
+} from '../schema/schema.js'
+import { makePostRequest } from './client.js'
+import { Dispatcher } from './dispatcher.js'
+
+/* Options */
+
+export class OptionsReq extends S.Class(`@passkey/auth/optionsReq`)({
+ email: S.optional(S.String, { exact: true }),
+ userVerification: S.optional(UserVerification, { exact: true }),
+}) {}
+
+export class OptionsRes extends S.Class('@passkey/auth/optionsRes')({
+ session: S.String,
+ publicKey: AuthenticationOptions,
+}) {}
+
+export const OptionsErrors = S.Union(BadRequest, NotFound)
+
+export type OptionsErrors = S.Schema.Type
+
+/* Verification */
+
+export class VerificationReq extends S.Class("@passkey/auth/verificationReq")({
+ session: S.String,
+ credential: AuthenticationCredential,
+}) {}
+
+export class VerificationRes extends S.Class('@passkey/auth/verificationRes')({
+ principal: Principal,
+}) {}
+
+export const VerificationErrors = S.Union(BadRequest, Unauthorized, Forbidden, Disabled)
+
+export type VerificationErrors = S.Schema.Type
+
+/* Service */
+
+export type AuthenticationService = {
+ getAuthenticationOptions: (
+ req: OptionsReq
+ ) => E.Effect
+
+ verifyAuthenticationCredential: (
+ req: VerificationReq,
+ ) => E.Effect
+}
+
+/* Client */
+
+export const OPTIONS_ENDPOINT = '/passkey/auth/options'
+export const VERIFY_ENDPOINT = '/passkey/auth/verify'
+
+export class AuthenticationClient extends Context.Tag("@passkey/auth/client")<
+ AuthenticationClient,
+ AuthenticationService
+>() {}
+
+export const AuthenticationClientLive = Layer.effect(
+ AuthenticationClient,
+ E.gen(function* (_) {
+ const dispatcher = yield* _(Dispatcher)
+ const optionsResolver = makePostRequest(OptionsReq, OptionsRes, OptionsErrors, dispatcher)
+ const verifyResolver = makePostRequest(VerificationReq, VerificationRes, VerificationErrors, dispatcher)
+
+ return {
+ getAuthenticationOptions: req => optionsResolver(OPTIONS_ENDPOINT, req),
+ verifyAuthenticationCredential: (req) => verifyResolver(VERIFY_ENDPOINT, req)
+ }
+ })
+)
+
+/* Handler */
+
+export class AuthenticationHandler extends Context.Tag("@passkey/auth/handler")<
+ AuthenticationHandler,
+ AuthenticationService
+>() {}
\ No newline at end of file
diff --git a/packages/shared/src/rpc/client.ts b/packages/shared/src/rpc/client.ts
new file mode 100644
index 0000000..505d012
--- /dev/null
+++ b/packages/shared/src/rpc/client.ts
@@ -0,0 +1,43 @@
+import * as S from '@effect/schema/Schema'
+import { Effect as E, pipe } from "effect"
+import { Dispatcher } from "./dispatcher.js"
+
+export const makeGetRequest = (
+ responseSchema: S.Schema,
+ errorSchema: S.Schema,
+ dispatcher: Dispatcher['Type']
+) => (path: string) => pipe(
+ dispatcher.get(path),
+ E.flatMap(res => {
+ if (res.status === 200) return S.decodeUnknown(responseSchema)(res.body)
+ return pipe(
+ S.decodeUnknown(errorSchema)(res.body),
+ E.flatMap(err => E.fail(err))
+ )
+ }),
+ E.catchTag('ParseError', e => E.die(e)),
+ E.catchTag('NetworkError', e => E.die(e))
+)
+
+export const makePostRequest = (
+ requestSchema: S.Schema,
+ responseSchema: S.Schema,
+ errorSchema: S.Schema,
+ dispatcher: Dispatcher['Type']
+) => (path: string, request: RI) => {
+ return pipe(
+ S.encode(requestSchema)(request),
+ E.flatMap(request =>
+ dispatcher.post(path, JSON.stringify(request))
+ ),
+ E.flatMap(res => {
+ if (res.status === 200) return S.decodeUnknown(responseSchema)(res.body)
+ return pipe(
+ S.decodeUnknown(errorSchema)(res.body),
+ E.flatMap(err => E.fail(err))
+ )
+ }),
+ E.catchTag('ParseError', e => E.die(e)),
+ E.catchTag('NetworkError', e => E.die(e))
+ )
+}
\ No newline at end of file
diff --git a/packages/shared/src/rpc/config.ts b/packages/shared/src/rpc/config.ts
new file mode 100644
index 0000000..bfe52bf
--- /dev/null
+++ b/packages/shared/src/rpc/config.ts
@@ -0,0 +1,17 @@
+import { Context, Schedule } from "effect"
+
+export class RpcConfig extends Context.Tag('@rpc/RpcConfig')<
+ RpcConfig,
+ {
+ endpoint?: string
+ tenancyId: string
+ clientId: string
+ }
+>() {}
+
+export class RetrySchedule extends Context.Tag('@rpc/RetrySchedule')<
+ RetrySchedule,
+ {
+ schedule: Schedule.Schedule
+ }
+>() {}
\ No newline at end of file
diff --git a/packages/shared/src/rpc/connection.ts b/packages/shared/src/rpc/connection.ts
new file mode 100644
index 0000000..837e980
--- /dev/null
+++ b/packages/shared/src/rpc/connection.ts
@@ -0,0 +1,42 @@
+import * as S from '@effect/schema/Schema'
+import { Context, Effect as E, Layer } from 'effect'
+import { makeGetRequest } from './client.js'
+import { Dispatcher } from './dispatcher.js'
+
+/* Pre connect */
+
+export class ConnectRes extends S.Class('@connection/preConnectRes')({
+ warmed: S.Boolean,
+}) {}
+
+export type ConnectionService = {
+ preConnect: () => E.Effect
+}
+
+/* Client */
+
+export const CONNECT_ENDPOINT = '/connection/pre-connect'
+
+export class ConnectionClient extends Context.Tag("@connection/client")<
+ ConnectionClient,
+ ConnectionService
+>() {}
+
+export const ConnectionClientLive = Layer.effect(
+ ConnectionClient,
+ E.gen(function* (_) {
+ const dispatcher = yield* _(Dispatcher)
+ const preConnectResolver = makeGetRequest(ConnectRes, S.Never, dispatcher)
+
+ return {
+ preConnect: () => preConnectResolver(CONNECT_ENDPOINT)
+ }
+ })
+)
+
+/* Handler */
+
+export class ConnectionHandler extends Context.Tag("@connection/handler")<
+ConnectionHandler,
+ ConnectionService
+>() {}
\ No newline at end of file
diff --git a/packages/shared/src/rpc/dispatcher.ts b/packages/shared/src/rpc/dispatcher.ts
new file mode 100644
index 0000000..4d3c131
--- /dev/null
+++ b/packages/shared/src/rpc/dispatcher.ts
@@ -0,0 +1,147 @@
+import { make as makeEffect } from '@effect/rpc/ResolverNoStream'
+import { Context, Effect as E, Layer } from 'effect'
+
+import type { Router } from '@effect/rpc'
+import { NetworkError } from '../error/error.js'
+import { RetrySchedule, RpcConfig } from './config.js'
+
+/* Services */
+
+export type DispatcherResponse = {
+ status: number
+ body: object
+}
+
+/** To send the JSON to the backend */
+export class Dispatcher extends Context.Tag('@rpc/Dispatcher')<
+ Dispatcher,
+ {
+ get: (path: string) => E.Effect
+ post: (path: string, body: string) => E.Effect
+ }
+>() {}
+
+/** Fires off requests using a Dispatcher */
+export const dispatchResolver = >(router: R) => makeEffect(u => {
+ return E.gen(function* (_) {
+ const dispatcher = yield* _(Dispatcher)
+
+ const requestBody = yield* _(
+ E.try({
+ try: () => JSON.stringify(u),
+ catch: () => new NetworkError({ message: 'Unable to serialize RPC request' }),
+ }),
+ )
+
+ return yield* _(dispatcher.post('/rpc', requestBody))
+ })
+})()
+
+/** Fires off client requests using fetch */
+/** TODO: Write tests */
+/** TODO: Evaluate platform/http client (if now stable) */
+export const DispatcherLive = Layer.effect(
+ Dispatcher,
+ E.gen(function* (_) {
+ const { schedule } = yield* _(RetrySchedule)
+ const { tenancyId, clientId, endpoint: maybeEndpoint } = yield* _(RpcConfig)
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const parseJson = (res: Response, url: string) =>
+ E.tryPromise({
+ try: () => res.json() as Promise,
+ catch: e =>
+ new NetworkError({
+ message: 'Unable to extract json response from ' + url,
+ detail: String(e),
+ }),
+ })
+
+ // 400 errors are reflected in the RPC response error channel
+ // so in network terms they're still "ok"
+ const assertNo500s = (res: Response, url: string) => {
+ if (res.status >= 500) {
+ return E.fail(
+ new NetworkError({
+ message: 'Received 500 response code from ' + url,
+ }),
+ )
+ } else return E.void
+ }
+
+ const parseJsonObject = (json: unknown) => {
+ return typeof json === 'object' && json !== null
+ ? E.succeed(json)
+ : E.fail(
+ new NetworkError({
+ message: `Expected JSON object to be returned from RPC endpoint, actual ${typeof json}`,
+ }),
+ )
+ }
+
+ const buildUrl = (_path: string) => {
+ const endpoint = maybeEndpoint || 'https://api.passlock.dev'
+ // drop leading /
+ const path = _path.replace(/^\//, '')
+ return `${endpoint}/${tenancyId}/${path}`
+ }
+
+ return {
+ get: (path: string) => {
+ const effect = E.gen(function* (_) {
+ const headers = {
+ 'Accept': 'application/json',
+ 'X-CLIENT-ID': clientId,
+ }
+
+ const url = buildUrl(path)
+
+ const res = yield* _(
+ E.tryPromise({
+ try: () => fetch(url, { method: 'GET', headers }),
+ catch: e =>
+ new NetworkError({ message: 'Unable to fetch from ' + url, detail: String(e) }),
+ }),
+ )
+
+ const json = yield* _(parseJson(res, url))
+ yield* _(assertNo500s(res, url))
+ const jsonObject = yield* _(parseJsonObject(json))
+
+ return { status: res.status, body: jsonObject }
+ })
+
+ return E.retry(effect, { schedule })
+ },
+
+ post: (_path: string, body: string) => {
+ const effect = E.gen(function* (_) {
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'Accept': 'application/json',
+ 'X-CLIENT-ID': clientId,
+ }
+
+ // drop leading /
+ const url = buildUrl(_path)
+
+ const res = yield* _(
+ E.tryPromise({
+ try: () => fetch(url, { method: 'POST', headers, body }),
+ catch: e =>
+ new NetworkError({ message: 'Unable to fetch from ' + url, detail: String(e) }),
+ }),
+ )
+
+ const json = yield* _(parseJson(res, url))
+ yield* _(assertNo500s(res, url))
+ const jsonObject = yield* _(parseJsonObject(json))
+
+ return { status: res.status, body: jsonObject }
+ })
+
+ return E.retry(effect, { schedule })
+ },
+ }
+ })
+)
diff --git a/packages/shared/src/rpc/registration.ts b/packages/shared/src/rpc/registration.ts
new file mode 100644
index 0000000..da7c4c3
--- /dev/null
+++ b/packages/shared/src/rpc/registration.ts
@@ -0,0 +1,90 @@
+import * as S from '@effect/schema/Schema'
+import { Context, Effect as E, Layer } from 'effect'
+
+import { BadRequest, Duplicate, Forbidden, Unauthorized } from '../error/error.js'
+import {
+ Principal,
+ RegistrationCredential,
+ RegistrationOptions,
+ UserVerification,
+ VerifyEmail,
+} from '../schema/schema.js'
+import { makePostRequest } from './client.js'
+import { Dispatcher } from './dispatcher.js'
+
+/* Options */
+
+export class OptionsReq extends S.Class('@passkey/register/optionsReq')({
+ email: S.String,
+ givenName: S.String,
+ familyName: S.String,
+ userVerification: S.optional(UserVerification),
+ verifyEmail: S.optional(VerifyEmail),
+ redirectUrl: S.optional(S.String),
+}) {}
+
+export class OptionsRes extends S.Class('@passkey/register/optionsRes')({
+ session: S.String,
+ publicKey: RegistrationOptions,
+}) {}
+
+export const OptionsErrors = S.Union(BadRequest, Duplicate)
+
+export type OptionsErrors = S.Schema.Type
+
+/* Verification */
+
+export class VerificationReq extends S.Class('@passkey/register/verificationReq')({
+ session: S.String,
+ credential: RegistrationCredential,
+ verifyEmail: S.optional(VerifyEmail),
+ redirectUrl: S.optional(S.String),
+}) {}
+
+export class VerificationRes extends S.Class('@passkey/register/verificationRes')({
+ principal: Principal,
+}) {}
+
+export const VerificationErrors = S.Union(BadRequest, Duplicate, Unauthorized, Forbidden)
+
+export type VerificationErrors = S.Schema.Type
+
+/* Service */
+
+export type RegistrationService = {
+ getRegistrationOptions: (req: OptionsReq) => E.Effect
+ verifyRegistrationCredential: (
+ req: VerificationReq,
+ ) => E.Effect
+}
+
+/* Client */
+
+export const OPTIONS_ENDPOINT = '/passkey/register/options'
+export const VERIFY_ENDPOINT = '/passkey/register/verify'
+
+export class RegistrationClient extends Context.Tag('@passkey/register/client')<
+ RegistrationClient,
+ RegistrationService
+>() {}
+
+export const RegistrationClientLive = Layer.effect(
+ RegistrationClient,
+ E.gen(function* (_) {
+ const dispatcher = yield* _(Dispatcher)
+ const optionsResolver = makePostRequest(OptionsReq, OptionsRes, OptionsErrors, dispatcher)
+ const verifyResolver = makePostRequest(VerificationReq, VerificationRes, VerificationErrors, dispatcher)
+
+ return {
+ getRegistrationOptions: req => optionsResolver("/passkey/register/options", req),
+ verifyRegistrationCredential: req => verifyResolver("/passkey/register/verify", req)
+ }
+ })
+)
+
+/* Handler */
+
+export class RegistrationHandler extends Context.Tag('@passkey/register/handler')<
+ RegistrationHandler,
+ RegistrationService
+>() {}
diff --git a/packages/shared/src/rpc/social.ts b/packages/shared/src/rpc/social.ts
new file mode 100644
index 0000000..ef1447a
--- /dev/null
+++ b/packages/shared/src/rpc/social.ts
@@ -0,0 +1,82 @@
+import * as S from '@effect/schema/Schema'
+import { Context, Effect as E, Layer } from 'effect'
+
+import { BadRequest, Disabled, Duplicate, Forbidden, NotFound, Unauthorized } from '../error/error.js'
+import {
+ Principal
+} from '../schema/schema.js'
+import { makePostRequest } from './client.js'
+import { Dispatcher } from './dispatcher.js'
+
+const Provider = S.Literal('apple', 'google')
+
+/* Registration */
+
+export class PrincipalRes extends S.Class('@social/principalRes')({ principal: Principal }) {}
+
+export class RegisterOidcReq extends S.Class('@social/oidc/registerReq')({
+ provider: Provider,
+ idToken: S.String,
+ givenName: S.Option(S.String),
+ familyName: S.Option(S.String),
+ nonce: S.String,
+}) {}
+
+export const RegisterOidcErrors = S.Union(BadRequest, Unauthorized, Forbidden, Disabled, Duplicate)
+
+export type RegisterOidcErrors = S.Schema.Type
+
+/* Authentication */
+
+export class AuthOidcReq extends S.Class('@social/oidc/authReq')({
+ provider: Provider,
+ idToken: S.String,
+ nonce: S.String,
+}) {}
+
+export const AuthOidcErrors = S.Union(BadRequest, Unauthorized, Forbidden, Disabled, NotFound)
+
+export type AuthOidcErrors = S.Schema.Type
+
+/* Service */
+
+export type SocialService = {
+ registerOidc: (
+ req: RegisterOidcReq,
+ ) => E.Effect
+
+ authenticateOidc: (
+ req: AuthOidcReq,
+ ) => E.Effect
+}
+
+/* Client */
+
+export const OIDC_REGISTER_ENDPOINT = '/social/oidc/register'
+export const OIDC_AUTH_ENDPOINT = '/social/oidc/auth'
+
+export class SocialClient extends Context.Tag('@social/client')<
+ SocialClient,
+ SocialService
+>() {}
+
+export const SocialClientLive = Layer.effect(
+ SocialClient,
+ E.gen(function* (_) {
+ const dispatcher = yield* _(Dispatcher)
+ const registerResolver = makePostRequest(RegisterOidcReq, PrincipalRes, RegisterOidcErrors, dispatcher)
+ const authenticateResolver = makePostRequest(AuthOidcReq, PrincipalRes, AuthOidcErrors, dispatcher)
+
+ return {
+ registerOidc: req => registerResolver(OIDC_REGISTER_ENDPOINT, req),
+ authenticateOidc: req => authenticateResolver(OIDC_AUTH_ENDPOINT, req)
+ }
+ })
+)
+
+/* Handler */
+
+export class SocialHandler extends Context.Tag('@social/handler')<
+ SocialHandler,
+ SocialService
+>() {}
\ No newline at end of file
diff --git a/packages/shared/src/rpc/user.ts b/packages/shared/src/rpc/user.ts
new file mode 100644
index 0000000..f009717
--- /dev/null
+++ b/packages/shared/src/rpc/user.ts
@@ -0,0 +1,89 @@
+import * as S from '@effect/schema/Schema'
+import { Context, Effect as E, Layer } from 'effect'
+
+import { Principal, VerifyEmail } from '../schema/schema.js'
+
+import { BadRequest, Disabled, Forbidden, NotFound, Unauthorized } from '../error/error.js'
+import { makePostRequest } from './client.js'
+import { Dispatcher } from './dispatcher.js'
+
+/* Is existing user */
+
+export class IsExistingUserReq extends S.Class('@user/isExistingUserReq')({
+ email: S.String
+}) {}
+
+export class IsExistingUserRes extends S.Class('@user/isExistingUserRes')({
+ existingUser: S.Boolean,
+ detail: S.optional(S.String),
+}) {}
+
+/* Verify email */
+
+export class VerifyEmailReq extends S.Class('@user/verifyEmailReq')({
+ code: S.String,
+ token: S.String,
+}) {}
+
+export class VerifyEmailRes extends S.Class('@user/verifyEmailRes')({
+ principal: Principal,
+}) {}
+
+export const VerifyEmailErrors = S.Union(BadRequest, NotFound, Disabled, Unauthorized, Forbidden)
+
+export type VerifyEmailErrors = S.Schema.Type
+
+/* Resend email */
+
+export class ResendEmailReq extends S.Class('@user/resendEmailReq',)({
+ userId: S.String,
+ verifyEmail: VerifyEmail,
+ }) { }
+
+export class ResendEmailRes extends S.Class('@user/resendEmailRes')({ }) {}
+
+export const ResendEmailErrors = S.Union(BadRequest, NotFound, Disabled)
+
+export type ResendEmailErrors = S.Schema.Type
+
+/* Service */
+
+export type UserService = {
+ isExistingUser: (req: IsExistingUserReq) => E.Effect
+ verifyEmail: (req: VerifyEmailReq) => E.Effect
+ resendVerificationEmail: (req: ResendEmailReq) => E.Effect
+}
+
+/* Client */
+
+export const USER_STATUS_ENDPOINT = '/user/status'
+export const VERIFY_EMAIL_ENDPOINT = '/user/verify-email'
+export const RESEND_EMAIL_ENDPOINT = '/user/verify-email/resend'
+
+export class UserClient extends Context.Tag('@user/client')<
+ UserClient,
+ UserService
+>() {}
+
+export const UserClientLive = Layer.effect(
+ UserClient,
+ E.gen(function* (_) {
+ const dispatcher = yield* _(Dispatcher)
+ const isExistingUserResolver = makePostRequest(IsExistingUserReq, IsExistingUserRes, S.Never, dispatcher)
+ const verifyEmailResolver = makePostRequest(VerifyEmailReq, VerifyEmailRes, VerifyEmailErrors, dispatcher)
+ const resendEmailResolver = makePostRequest(ResendEmailReq, ResendEmailRes, ResendEmailErrors, dispatcher)
+
+ return {
+ isExistingUser: req => isExistingUserResolver(USER_STATUS_ENDPOINT, req),
+ verifyEmail: req => verifyEmailResolver(VERIFY_EMAIL_ENDPOINT, req),
+ resendVerificationEmail: req => resendEmailResolver(RESEND_EMAIL_ENDPOINT, req)
+ }
+ })
+)
+
+/* Handler */
+
+export class UserHandler extends Context.Tag('@user/handler')<
+ UserClient,
+ UserService
+>() {}
\ No newline at end of file
diff --git a/packages/shared/src/schema/schema.ts b/packages/shared/src/schema/schema.ts
new file mode 100644
index 0000000..3022aa1
--- /dev/null
+++ b/packages/shared/src/schema/schema.ts
@@ -0,0 +1,212 @@
+import * as S from '@effect/schema/Schema'
+import { formatError } from '@effect/schema/TreeFormatter'
+import { Effect as E, pipe } from 'effect'
+
+const optional = (s: S.Schema) => S.optional(s, { exact: true })
+
+export class ParsingError extends S.TaggedError()('ParsingError', {
+ message: S.String,
+ detail: S.String,
+}) {}
+
+/* Components */
+
+export const VerifyEmailLink = S.Struct({
+ method: S.Literal('link'),
+ redirectUrl: S.String
+})
+
+export type VerifyEmailLink = S.Schema.Type
+
+export const VerifyEmailCode = S.Struct({
+ method: S.Literal('code'),
+})
+
+export type VerifyEmailCode = S.Schema.Type
+
+export const VerifyEmail = S.Union(VerifyEmailLink, VerifyEmailCode)
+
+export type VerifyEmail = S.Schema.Type