From 99111b69ebd5038dbc08e21f7ebd4fcca117d5bc Mon Sep 17 00:00:00 2001 From: Kodemon Date: Wed, 24 Sep 2025 01:20:09 +0200 Subject: [PATCH] feat: add supertokens --- .bruno/identity/{get-by-id.bru => Get.bru} | 6 +- .bruno/identity/Roles.bru | 32 ++ .bruno/identity/Update.bru | 43 ++ .bruno/identity/login/code.bru | 18 +- .bruno/identity/login/email.bru | 4 +- .bruno/identity/login/folder.bru | 2 +- .bruno/identity/login/sudo.bru | 21 + .bruno/identity/me.bru | 4 +- .bruno/identity/register.bru | 25 -- .bruno/workspace/create.bru | 21 + .bruno/workspace/folder.bru | 8 + .vscode/settings.json | 2 - api/server.ts | 10 +- platform/cerbos/config.yaml => cerbos.yaml | 0 deno.json | 7 +- deno.lock | 411 +++++++++++++----- docker-compose.yml | 79 +++- modules/identity/aggregates/code.ts | 66 --- modules/identity/aggregates/identity.ts | 211 --------- modules/identity/auth.ts | 15 - modules/identity/auth/jwt.ts | 9 - modules/identity/auth/principal.ts | 39 -- .../identity/cerbos/policies/identity.yaml | 23 + modules/identity/cerbos/policies/role.yaml | 14 + .../identity}/cerbos/resources.ts | 9 +- modules/identity/client.ts | 8 +- modules/identity/config.ts | 49 --- modules/identity/crypto/password.ts | 11 - modules/identity/database.ts | 49 --- modules/identity/errors.ts | 7 - modules/identity/events/code.ts | 18 - modules/identity/events/identity.ts | 21 - modules/identity/models/identity.ts | 36 -- modules/identity/package.json | 13 +- .../identity/routes/identities/get/handle.ts | 15 +- .../identity/routes/identities/get/spec.ts | 6 +- .../identity/routes/identities/me/handle.ts | 12 - modules/identity/routes/identities/me/spec.ts | 5 - .../routes/identities/register/handle.ts | 11 - .../routes/identities/register/spec.ts | 17 - .../routes/identities/resolve/handle.ts | 13 - .../routes/identities/resolve/keys.ts | 5 - .../routes/identities/resolve/spec.ts | 12 - .../routes/identities/update/handle.ts | 40 ++ .../identity/routes/identities/update/spec.ts | 29 ++ modules/identity/routes/login/code/handle.ts | 88 +--- modules/identity/routes/login/code/spec.ts | 14 +- modules/identity/routes/login/email/handle.ts | 24 +- modules/identity/routes/login/email/spec.ts | 3 +- modules/identity/routes/login/sudo/handle.ts | 48 ++ modules/identity/routes/login/sudo/spec.ts | 8 + modules/identity/routes/me/handle.ts | 5 + modules/identity/routes/me/spec.ts | 4 + modules/identity/routes/roles/handle.ts | 30 ++ modules/identity/routes/roles/spec.ts | 19 + modules/identity/schemas/contact.ts | 9 - modules/identity/schemas/role.ts | 5 - modules/identity/schemas/strategies.ts | 37 -- modules/identity/server.ts | 94 +--- .../workspace/aggregates/workspace-user.ts | 66 +++ modules/workspace/aggregates/workspace.ts | 120 +++++ .../workspace/cerbos/policies/workspace.yaml | 33 ++ .../cerbos/policies/workspace_user.yaml | 62 +-- modules/workspace/cerbos/resources.ts | 22 + modules/workspace/client.ts | 0 modules/workspace/database.ts | 27 ++ .../{identity => workspace}/event-store.ts | 6 +- modules/workspace/events/workspace-user.ts | 23 + modules/workspace/events/workspace.ts | 19 + modules/workspace/models/workspace-user.ts | 38 ++ modules/workspace/models/workspace.ts | 32 ++ modules/workspace/package.json | 19 + .../routes/workspaces/create/handle.ts | 20 + .../routes/workspaces/create/spec.ts | 14 + modules/workspace/server.ts | 30 ++ .../value-objects}/avatar.ts | 0 modules/workspace/value-objects/contact.ts | 13 + .../value-objects}/email.ts | 0 .../value-objects}/name.ts | 0 platform/cerbos/policies/identity.yaml | 47 -- platform/relay/libraries/context.ts | 2 + platform/relay/libraries/errors.ts | 6 +- platform/relay/package.json | 2 +- .../auth => platform/supertoken}/access.ts | 14 +- .../client.ts => supertoken/cerbos.ts} | 0 platform/supertoken/config.ts | 44 ++ platform/{cerbos => supertoken}/package.json | 5 +- platform/supertoken/principal.ts | 46 ++ platform/supertoken/server.ts | 133 ++++++ platform/supertoken/session.ts | 37 ++ .../supertoken/types.ts | 20 +- platform/supertoken/users.ts | 10 + 92 files changed, 1613 insertions(+), 1141 deletions(-) rename .bruno/identity/{get-by-id.bru => Get.bru} (56%) create mode 100644 .bruno/identity/Roles.bru create mode 100644 .bruno/identity/Update.bru create mode 100644 .bruno/identity/login/sudo.bru delete mode 100644 .bruno/identity/register.bru create mode 100644 .bruno/workspace/create.bru create mode 100644 .bruno/workspace/folder.bru rename platform/cerbos/config.yaml => cerbos.yaml (100%) delete mode 100644 modules/identity/aggregates/code.ts delete mode 100644 modules/identity/aggregates/identity.ts delete mode 100644 modules/identity/auth.ts delete mode 100644 modules/identity/auth/jwt.ts delete mode 100644 modules/identity/auth/principal.ts create mode 100644 modules/identity/cerbos/policies/identity.yaml create mode 100644 modules/identity/cerbos/policies/role.yaml rename {platform => modules/identity}/cerbos/resources.ts (62%) delete mode 100644 modules/identity/crypto/password.ts delete mode 100644 modules/identity/database.ts delete mode 100644 modules/identity/errors.ts delete mode 100644 modules/identity/events/code.ts delete mode 100644 modules/identity/events/identity.ts delete mode 100644 modules/identity/models/identity.ts delete mode 100644 modules/identity/routes/identities/me/handle.ts delete mode 100644 modules/identity/routes/identities/me/spec.ts delete mode 100644 modules/identity/routes/identities/register/handle.ts delete mode 100644 modules/identity/routes/identities/register/spec.ts delete mode 100644 modules/identity/routes/identities/resolve/handle.ts delete mode 100644 modules/identity/routes/identities/resolve/keys.ts delete mode 100644 modules/identity/routes/identities/resolve/spec.ts create mode 100644 modules/identity/routes/identities/update/handle.ts create mode 100644 modules/identity/routes/identities/update/spec.ts create mode 100644 modules/identity/routes/login/sudo/handle.ts create mode 100644 modules/identity/routes/login/sudo/spec.ts create mode 100644 modules/identity/routes/me/handle.ts create mode 100644 modules/identity/routes/me/spec.ts create mode 100644 modules/identity/routes/roles/handle.ts create mode 100644 modules/identity/routes/roles/spec.ts delete mode 100644 modules/identity/schemas/contact.ts delete mode 100644 modules/identity/schemas/role.ts delete mode 100644 modules/identity/schemas/strategies.ts create mode 100644 modules/workspace/aggregates/workspace-user.ts create mode 100644 modules/workspace/aggregates/workspace.ts create mode 100644 modules/workspace/cerbos/policies/workspace.yaml rename platform/cerbos/policies/workspace.yaml => modules/workspace/cerbos/policies/workspace_user.yaml (56%) create mode 100644 modules/workspace/cerbos/resources.ts create mode 100644 modules/workspace/client.ts create mode 100644 modules/workspace/database.ts rename modules/{identity => workspace}/event-store.ts (88%) create mode 100644 modules/workspace/events/workspace-user.ts create mode 100644 modules/workspace/events/workspace.ts create mode 100644 modules/workspace/models/workspace-user.ts create mode 100644 modules/workspace/models/workspace.ts create mode 100644 modules/workspace/package.json create mode 100644 modules/workspace/routes/workspaces/create/handle.ts create mode 100644 modules/workspace/routes/workspaces/create/spec.ts create mode 100644 modules/workspace/server.ts rename modules/{identity/schemas => workspace/value-objects}/avatar.ts (100%) create mode 100644 modules/workspace/value-objects/contact.ts rename modules/{identity/schemas => workspace/value-objects}/email.ts (100%) rename modules/{identity/schemas => workspace/value-objects}/name.ts (100%) delete mode 100644 platform/cerbos/policies/identity.yaml rename {modules/identity/auth => platform/supertoken}/access.ts (83%) rename platform/{cerbos/client.ts => supertoken/cerbos.ts} (100%) create mode 100644 platform/supertoken/config.ts rename platform/{cerbos => supertoken}/package.json (66%) create mode 100644 platform/supertoken/principal.ts create mode 100644 platform/supertoken/server.ts create mode 100644 platform/supertoken/session.ts rename modules/identity/types.d.ts => platform/supertoken/types.ts (53%) create mode 100644 platform/supertoken/users.ts diff --git a/.bruno/identity/get-by-id.bru b/.bruno/identity/Get.bru similarity index 56% rename from .bruno/identity/get-by-id.bru rename to .bruno/identity/Get.bru index e0a2198..1637c3d 100644 --- a/.bruno/identity/get-by-id.bru +++ b/.bruno/identity/Get.bru @@ -1,17 +1,17 @@ meta { - name: Get By ID + name: Get type: http seq: 2 } get { - url: {{url}}/identities/:id + url: {{url}}/identity/:id body: none auth: inherit } params:path { - id: 16b88034-ca82-4a8e-9fe5-13bd0dd29b75 + id: } settings { diff --git a/.bruno/identity/Roles.bru b/.bruno/identity/Roles.bru new file mode 100644 index 0000000..d658976 --- /dev/null +++ b/.bruno/identity/Roles.bru @@ -0,0 +1,32 @@ +meta { + name: Roles + type: http + seq: 4 +} + +put { + url: {{url}}/identity/:id/roles + body: json + auth: inherit +} + +params:path { + id: +} + +body:json { + [ + { + "type": "add", + "roles": [] + }, + { + "type": "remove", + "roles": [] + } + ] +} + +settings { + encodeUrl: true +} diff --git a/.bruno/identity/Update.bru b/.bruno/identity/Update.bru new file mode 100644 index 0000000..552cde0 --- /dev/null +++ b/.bruno/identity/Update.bru @@ -0,0 +1,43 @@ +meta { + name: Update + type: http + seq: 4 +} + +put { + url: {{url}}/identity/:id + body: json + auth: inherit +} + +params:path { + id: +} + +body:json { + [ + { + "type": "add", + "key": "", + "value": "" + }, + { + "type": "push", + "key": "", + "values": "" + }, + { + "type": "pop", + "key": "", + "values": "" + }, + { + "type": "remove", + "key": "" + } + ] +} + +settings { + encodeUrl: true +} diff --git a/.bruno/identity/login/code.bru b/.bruno/identity/login/code.bru index 9402175..60e315f 100644 --- a/.bruno/identity/login/code.bru +++ b/.bruno/identity/login/code.bru @@ -1,19 +1,21 @@ meta { name: Code type: http - seq: 2 + seq: 3 } -get { - url: {{url}}/identities/login/code/:identityId/code/:codeId/:value - body: none +post { + url: {{url}}/identity/login/code + body: json auth: inherit } -params:path { - identityId: efefa471-905d-4702-bd0a-863d8cf70424 - codeId: 7055b769-0814-47b8-836e-cfef2d8c2e68 - value: 00597 +body:json { + { + "deviceId": "", + "preAuthSessionId": "", + "userInputCode": "" + } } script:post-response { diff --git a/.bruno/identity/login/email.bru b/.bruno/identity/login/email.bru index 227a806..b39f783 100644 --- a/.bruno/identity/login/email.bru +++ b/.bruno/identity/login/email.bru @@ -1,11 +1,11 @@ meta { name: Email type: http - seq: 1 + seq: 2 } post { - url: {{url}}/identities/login/email + url: {{url}}/identity/login/email body: json auth: inherit } diff --git a/.bruno/identity/login/folder.bru b/.bruno/identity/login/folder.bru index d460507..9b12963 100644 --- a/.bruno/identity/login/folder.bru +++ b/.bruno/identity/login/folder.bru @@ -1,6 +1,6 @@ meta { name: Login - seq: 2 + seq: 3 } auth { diff --git a/.bruno/identity/login/sudo.bru b/.bruno/identity/login/sudo.bru new file mode 100644 index 0000000..21f8712 --- /dev/null +++ b/.bruno/identity/login/sudo.bru @@ -0,0 +1,21 @@ +meta { + name: Sudo + type: http + seq: 1 +} + +post { + url: {{url}}/identities/login/sudo + body: json + auth: inherit +} + +body:json { + { + "email": "john.doe@fixture.none" + } +} + +settings { + encodeUrl: true +} diff --git a/.bruno/identity/me.bru b/.bruno/identity/me.bru index 1dc0a82..58fdd69 100644 --- a/.bruno/identity/me.bru +++ b/.bruno/identity/me.bru @@ -1,11 +1,11 @@ meta { name: Me type: http - seq: 3 + seq: 1 } get { - url: {{url}}/identities/me + url: {{url}}/identity/me body: none auth: inherit } diff --git a/.bruno/identity/register.bru b/.bruno/identity/register.bru deleted file mode 100644 index b2344ec..0000000 --- a/.bruno/identity/register.bru +++ /dev/null @@ -1,25 +0,0 @@ -meta { - name: Register - type: http - seq: 1 -} - -post { - url: {{url}}/identities - body: json - auth: inherit -} - -body:json { - { - "name": { - "given": "Jane", - "family": "Doe" - }, - "email": "jane.doe@fixture.none" - } -} - -settings { - encodeUrl: true -} diff --git a/.bruno/workspace/create.bru b/.bruno/workspace/create.bru new file mode 100644 index 0000000..38cd8e0 --- /dev/null +++ b/.bruno/workspace/create.bru @@ -0,0 +1,21 @@ +meta { + name: Create + type: http + seq: 1 +} + +post { + url: {{url}}/workspace + body: json + auth: inherit +} + +body:json { + { + "name": "valkyr" + } +} + +settings { + encodeUrl: true +} diff --git a/.bruno/workspace/folder.bru b/.bruno/workspace/folder.bru new file mode 100644 index 0000000..46b85fa --- /dev/null +++ b/.bruno/workspace/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Workspace + seq: 2 +} + +auth { + mode: inherit +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 52da05f..5dc7b9c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,6 +27,4 @@ "**/.DS_Store": true, "**/Thumbs.db": true }, - "vue.format.style.initialIndent": true, - "vue.format.script.initialIndent": true } diff --git a/api/server.ts b/api/server.ts index a9b4594..bdec019 100644 --- a/api/server.ts +++ b/api/server.ts @@ -1,4 +1,5 @@ import identity from "@modules/identity/server.ts"; +import workspace from "@modules/workspace/server.ts"; import database from "@platform/database/server.ts"; import { logger } from "@platform/logger"; import { context } from "@platform/relay"; @@ -6,6 +7,7 @@ import { Api } from "@platform/server/api.ts"; import server from "@platform/server/server.ts"; import socket from "@platform/socket/server.ts"; import { storage } from "@platform/storage"; +import supertokens from "@platform/supertoken/server.ts"; import { config } from "./config.ts"; @@ -22,10 +24,11 @@ const log = logger.prefix("Server"); await database.bootstrap(); await server.bootstrap(); await socket.bootstrap(); +await supertokens.bootsrap(); // ### Modules -await identity.bootstrap(); +await workspace.bootstrap(); /* |-------------------------------------------------------------------------------- @@ -33,7 +36,7 @@ await identity.bootstrap(); |-------------------------------------------------------------------------------- */ -const api = new Api([...identity.routes]); +const api = new Api([...identity.routes, ...workspace.routes]); /* |-------------------------------------------------------------------------------- @@ -58,8 +61,7 @@ Deno.serve( await server.resolve(request); await socket.resolve(); - - await identity.resolve(request); + await supertokens.resolve(request); // ### Fetch // Execute fetch against the api instance. diff --git a/platform/cerbos/config.yaml b/cerbos.yaml similarity index 100% rename from platform/cerbos/config.yaml rename to cerbos.yaml diff --git a/deno.json b/deno.json index de41fac..cc2dbae 100644 --- a/deno.json +++ b/deno.json @@ -5,7 +5,7 @@ "api", "apps/react", "modules/identity", - "platform/cerbos", + "modules/workspace", "platform/config", "platform/database", "platform/logger", @@ -14,12 +14,14 @@ "platform/socket", "platform/spec", "platform/storage", + "platform/supertoken", "platform/vault" ], "imports": { "@modules/identity/client.ts": "./modules/identity/client.ts", "@modules/identity/server.ts": "./modules/identity/server.ts", - "@platform/cerbos/": "./platform/cerbos/", + "@modules/workspace/client.ts": "./modules/workspace/client.ts", + "@modules/workspace/server.ts": "./modules/workspace/server.ts", "@platform/config/": "./platform/config/", "@platform/database/": "./platform/database/", "@platform/logger": "./platform/logger/mod.ts", @@ -28,6 +30,7 @@ "@platform/socket/": "./platform/socket/", "@platform/spec/": "./platform/spec/", "@platform/storage": "./platform/storage/storage.ts", + "@platform/supertoken/": "./platform/supertoken/", "@platform/vault": "./platform/vault/vault.ts" }, "tasks": { diff --git a/deno.lock b/deno.lock index 56fbc93..2a0e7d7 100644 --- a/deno.lock +++ b/deno.lock @@ -3,24 +3,21 @@ "specifiers": { "npm:@cerbos/http@0.23.1": "0.23.1", "npm:@eslint/js@9.35.0": "9.35.0", - "npm:@jsr/felix__bcrypt@1.0.5": "1.0.5", "npm:@jsr/std__assert@1.0.14": "1.0.14", "npm:@jsr/std__dotenv@0.225.5": "0.225.5", "npm:@jsr/std__testing@1.0.15": "1.0.15", - "npm:@jsr/valkyr__auth@2.1.4": "2.1.4", "npm:@jsr/valkyr__db@2.0.0": "2.0.0", "npm:@jsr/valkyr__event-emitter@1.0.1": "1.0.1", "npm:@jsr/valkyr__event-store@2.0.1": "2.0.1", "npm:@jsr/valkyr__inverse@1.0.1": "1.0.1", "npm:@jsr/valkyr__json-rpc@1.1.0": "1.1.0", - "npm:@tailwindcss/vite@4.1.13": "4.1.13_vite@7.1.6__picomatch@4.0.3_@types+node@24.2.0", + "npm:@tailwindcss/vite@4.1.13": "4.1.13_vite@7.1.6__picomatch@4.0.3", "npm:@tanstack/react-query@5.89.0": "5.89.0_react@19.1.1", "npm:@tanstack/react-router-devtools@1.131.47": "1.131.47_@tanstack+react-router@1.131.47__react@19.1.1__react-dom@19.1.1___react@19.1.1_react@19.1.1_react-dom@19.1.1__react@19.1.1", "npm:@tanstack/react-router@1.131.47": "1.131.47_react@19.1.1_react-dom@19.1.1__react@19.1.1", - "npm:@types/node@*": "24.2.0", "npm:@types/react-dom@19.1.9": "19.1.9_@types+react@19.1.13", "npm:@types/react@19.1.13": "19.1.13", - "npm:@vitejs/plugin-react@4.7.0": "4.7.0_vite@7.1.6__picomatch@4.0.3_@babel+core@7.28.4_@types+node@24.2.0", + "npm:@vitejs/plugin-react@4.7.0": "4.7.0_vite@7.1.6__picomatch@4.0.3_@babel+core@7.28.4", "npm:cookie@1.0.2": "1.0.2", "npm:eslint-plugin-react-hooks@5.2.0": "5.2.0_eslint@9.35.0", "npm:eslint-plugin-react-refresh@0.4.20": "0.4.20_eslint@9.35.0", @@ -35,10 +32,11 @@ "npm:prettier@3.6.2": "3.6.2", "npm:react-dom@19.1.1": "19.1.1_react@19.1.1", "npm:react@19.1.1": "19.1.1", + "npm:supertokens-node@23.0.1": "23.0.1", "npm:tailwindcss@4.1.13": "4.1.13", "npm:typescript-eslint@8.44.0": "8.44.0_eslint@9.35.0_typescript@5.9.2_@typescript-eslint+parser@8.44.0__eslint@9.35.0__typescript@5.9.2", "npm:typescript@5.9.2": "5.9.2", - "npm:vite@7.1.6": "7.1.6_picomatch@4.0.3_@types+node@24.2.0", + "npm:vite@7.1.6": "7.1.6_picomatch@4.0.3", "npm:zod@4.1.11": "4.1.11" }, "npm": { @@ -425,23 +423,6 @@ "@jridgewell/sourcemap-codec" ] }, - "@jsr/denosaurs__plug@1.1.0": { - "integrity": "sha512-GNRMr8XcYWbv8C1B5OjDa5u8q3p2lz7YVWQLhH5HAy0pkpb0+Y3npSxzjM49v5ajTFIzUCwIKv1gQukPm9q7qw==", - "dependencies": [ - "@jsr/std__encoding", - "@jsr/std__fmt", - "@jsr/std__fs", - "@jsr/std__path" - ], - "tarball": "https://npm.jsr.io/~/11/@jsr/denosaurs__plug/1.1.0.tgz" - }, - "@jsr/felix__bcrypt@1.0.5": { - "integrity": "sha512-XJAQ+NIs23r5YNUgFMtMbl6lhzn/Ms2x1fDO5qJdcVwHKcTFebc5ZH/EQlJss/YfVYdYC6Ng6QQQLzjhrLD/aw==", - "dependencies": [ - "@jsr/denosaurs__plug" - ], - "tarball": "https://npm.jsr.io/~/11/@jsr/felix__bcrypt/1.0.5.tgz" - }, "@jsr/std__assert@1.0.14": { "integrity": "sha512-BcjBimpxuy7mXjWo7sZ3TtPitx91w3UqssyY92RmJIuoMGYywZRGxaxqK9/oybljbZbZpPOSrkgQI9wKpgZ9vQ==", "dependencies": [ @@ -464,14 +445,6 @@ "integrity": "sha512-qrBt3wfQgvXbjo+Up6lyzBGxk0IPhDqW9Jx7CJQUQpsxqhoqnBmD8gn0Mt8i+RHHI9uZFCO+FP122ClAC8yljg==", "tarball": "https://npm.jsr.io/~/11/@jsr/std__dotenv/0.225.5.tgz" }, - "@jsr/std__encoding@1.0.10": { - "integrity": "sha512-WK2njnDTyKefroRNk2Ooq7GStp6Y0ccAvr4To+Z/zecRAGe7+OSvH9DbiaHpAKwEi2KQbmpWMOYsdNt+TsdmSw==", - "tarball": "https://npm.jsr.io/~/11/@jsr/std__encoding/1.0.10.tgz" - }, - "@jsr/std__fmt@1.0.8": { - "integrity": "sha512-miZHzj9OgjuajrcMKzpqNVwFb9O71UHZzV/FHVq0E0Uwmv/1JqXgmXAoBNPrn+MP0fHT3mMgaZ6XvQO7dam67Q==", - "tarball": "https://npm.jsr.io/~/11/@jsr/std__fmt/1.0.8.tgz" - }, "@jsr/std__fs@1.0.19": { "integrity": "sha512-TEjyE8g+46jPlu7dJHLrwc8NMGl8zfG+JjWxyNQyDbxP0RtqZ4JmYZfR9vy4RWYWJQbLpw6Kbt2n+K/2zAO/JA==", "dependencies": [ @@ -507,14 +480,6 @@ ], "tarball": "https://npm.jsr.io/~/11/@jsr/std__testing/1.0.15.tgz" }, - "@jsr/valkyr__auth@2.1.4": { - "integrity": "sha512-z4/OfPJ+7KZKdILrCWMO9nDQgOsLFw0A6HHgV3h7N1DpH1Ok0uRHUNAUA2IbI5KZif8yUPw+wsKztM6pOEMjbg==", - "dependencies": [ - "jose", - "zod" - ], - "tarball": "https://npm.jsr.io/~/11/@jsr/valkyr__auth/2.1.4.tgz" - }, "@jsr/valkyr__db@2.0.0": { "integrity": "sha512-0gIauba+vQW6ssqMACLO1Z/METlhzoX+y4t9Sawh/IafQ986Rgvp6gCI+WArp7vbsO5hpItixrqjkxnnNC+h5g==", "dependencies": [ @@ -800,16 +765,7 @@ "@tailwindcss/node", "@tailwindcss/oxide", "tailwindcss", - "vite@7.1.6_picomatch@4.0.3" - ] - }, - "@tailwindcss/vite@4.1.13_vite@7.1.6__picomatch@4.0.3_@types+node@24.2.0": { - "integrity": "sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==", - "dependencies": [ - "@tailwindcss/node", - "@tailwindcss/oxide", - "tailwindcss", - "vite@7.1.6_picomatch@4.0.3_@types+node@24.2.0" + "vite" ] }, "@tanstack/history@1.131.2": { @@ -916,12 +872,6 @@ "@types/json-schema@7.0.15": { "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, - "@types/node@24.2.0": { - "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", - "dependencies": [ - "undici-types" - ] - }, "@types/react-dom@19.1.9_@types+react@19.1.13": { "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", "dependencies": [ @@ -1052,19 +1002,7 @@ "@rolldown/pluginutils", "@types/babel__core", "react-refresh", - "vite@7.1.6_picomatch@4.0.3" - ] - }, - "@vitejs/plugin-react@4.7.0_vite@7.1.6__picomatch@4.0.3_@babel+core@7.28.4_@types+node@24.2.0": { - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dependencies": [ - "@babel/core", - "@babel/plugin-transform-react-jsx-self", - "@babel/plugin-transform-react-jsx-source", - "@rolldown/pluginutils", - "@types/babel__core", - "react-refresh", - "vite@7.1.6_picomatch@4.0.3_@types+node@24.2.0" + "vite" ] }, "acorn-jsx@5.3.2_acorn@8.15.0": { @@ -1077,6 +1015,12 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "bin": true }, + "agent-base@6.0.2": { + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dependencies": [ + "debug" + ] + }, "ajv@6.12.6": { "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dependencies": [ @@ -1095,9 +1039,23 @@ "argparse@2.0.1": { "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, + "asynckit@0.4.0": { + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios@1.11.0": { + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "dependencies": [ + "follow-redirects", + "form-data", + "proxy-from-env" + ] + }, "balanced-match@1.0.2": { "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "base64-js@1.5.1": { + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, "baseline-browser-mapping@2.8.6": { "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", "bin": true @@ -1135,6 +1093,16 @@ "bson@6.10.4": { "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==" }, + "buffer-equal-constant-time@1.0.1": { + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "buffer@6.0.3": { + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dependencies": [ + "base64-js", + "ieee754" + ] + }, "call-bind-apply-helpers@1.0.2": { "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dependencies": [ @@ -1177,18 +1145,36 @@ "color-name@1.1.4": { "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "combined-stream@1.0.8": { + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": [ + "delayed-stream" + ] + }, "concat-map@0.0.1": { "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "content-type@1.0.5": { + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==" + }, "convert-source-map@2.0.0": { "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" }, "cookie-es@1.2.2": { "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==" }, + "cookie@0.7.2": { + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==" + }, "cookie@1.0.2": { "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==" }, + "cross-fetch@3.2.0": { + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "dependencies": [ + "node-fetch" + ] + }, "cross-spawn@7.0.6": { "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": [ @@ -1197,9 +1183,15 @@ "which" ] }, + "crypto-js@4.2.0": { + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "csstype@3.1.3": { "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "dayjs@1.11.18": { + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==" + }, "debug@4.4.3": { "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dependencies": [ @@ -1209,6 +1201,9 @@ "deep-is@0.1.4": { "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" }, + "delayed-stream@1.0.0": { + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "detect-libc@2.1.0": { "integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==" }, @@ -1220,6 +1215,12 @@ "gopd" ] }, + "ecdsa-sig-formatter@1.0.11": { + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": [ + "safe-buffer" + ] + }, "electron-to-chromium@1.5.222": { "integrity": "sha512-gA7psSwSwQRE60CEoLz6JBCQPIxNeuzB2nL8vE03GK/OHxlvykbLyeiumQy1iH5C2f3YbRAZpGCMT12a/9ih9w==" }, @@ -1242,6 +1243,15 @@ "es-errors" ] }, + "es-set-tostringtag@2.1.0": { + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": [ + "es-errors", + "get-intrinsic", + "has-tostringtag", + "hasown" + ] + }, "esbuild@0.25.10": { "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", "optionalDependencies": [ @@ -1448,6 +1458,19 @@ "flatted@3.3.3": { "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==" }, + "follow-redirects@1.15.11": { + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" + }, + "form-data@4.0.4": { + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "dependencies": [ + "asynckit", + "combined-stream", + "es-set-tostringtag", + "hasown", + "mime-types" + ] + }, "fsevents@2.3.3": { "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "os": ["darwin"], @@ -1520,15 +1543,31 @@ "has-symbols@1.1.0": { "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" }, + "has-tostringtag@1.0.2": { + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dependencies": [ + "has-symbols" + ] + }, "hasown@2.0.2": { "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dependencies": [ "function-bind" ] }, + "https-proxy-agent@5.0.1": { + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dependencies": [ + "agent-base", + "debug" + ] + }, "idb@8.0.3": { "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==" }, + "ieee754@1.2.1": { + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, "ignore@5.3.2": { "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" }, @@ -1567,6 +1606,9 @@ "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "bin": true }, + "jose@4.15.9": { + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==" + }, "jose@6.1.0": { "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==" }, @@ -1597,6 +1639,36 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "bin": true }, + "jsonwebtoken@9.0.2": { + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": [ + "jws", + "lodash.includes", + "lodash.isboolean", + "lodash.isinteger", + "lodash.isnumber", + "lodash.isplainobject", + "lodash.isstring", + "lodash.once", + "ms", + "semver@7.7.2" + ] + }, + "jwa@1.4.2": { + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "dependencies": [ + "buffer-equal-constant-time", + "ecdsa-sig-formatter", + "safe-buffer" + ] + }, + "jws@3.2.2": { + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": [ + "jwa", + "safe-buffer" + ] + }, "keyv@4.5.4": { "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dependencies": [ @@ -1610,6 +1682,9 @@ "type-check" ] }, + "libphonenumber-js@1.12.21": { + "integrity": "sha512-z/o0jBYS3d8js1QBksrHxZUARjmM0S6uvpINkyJ9IcPkXIoUh5l4S3rTbGAlq9ThbCExdSV0wWp8gw7729A/ww==" + }, "lightningcss-darwin-arm64@1.30.1": { "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", "os": ["darwin"], @@ -1684,9 +1759,30 @@ "p-locate" ] }, + "lodash.includes@4.3.0": { + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "lodash.isboolean@3.0.3": { + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "lodash.isinteger@4.0.4": { + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "lodash.isnumber@3.0.3": { + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "lodash.isplainobject@4.0.6": { + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "lodash.isstring@4.0.1": { + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "lodash.merge@4.6.2": { "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "lodash.once@4.1.1": { + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "lru-cache@5.1.1": { "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dependencies": [ @@ -1715,6 +1811,15 @@ "picomatch@2.3.1" ] }, + "mime-db@1.52.0": { + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types@2.1.35": { + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": [ + "mime-db" + ] + }, "mingo@6.6.1": { "integrity": "sha512-KC6b1ODYoSdYu5fBm+SzQb7fa4ARmGwfa3Cf9F7U+2mnfD4Zhf89qQgO1cPTtaJ68w3ntIT5dVujgF52HvN7+g==" }, @@ -1733,21 +1838,17 @@ "minipass@7.1.2": { "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" }, - "minizlib@3.0.2": { - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "minizlib@3.1.0": { + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dependencies": [ "minipass" ] }, - "mkdirp@3.0.1": { - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "bin": true - }, "mongodb-connection-string-url@3.0.2": { "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", "dependencies": [ "@types/whatwg-url", - "whatwg-url" + "whatwg-url@14.2.0" ] }, "mongodb@6.20.0": { @@ -1772,9 +1873,18 @@ "natural-compare@1.4.0": { "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==" }, + "node-fetch@2.7.0": { + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": [ + "whatwg-url@5.0.0" + ] + }, "node-releases@2.0.21": { "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==" }, + "nodemailer@6.10.1": { + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==" + }, "object-inspect@1.13.4": { "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==" }, @@ -1801,6 +1911,9 @@ "p-limit" ] }, + "pako@2.1.0": { + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, "parent-module@1.0.1": { "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dependencies": [ @@ -1825,6 +1938,12 @@ "picomatch@4.0.3": { "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" }, + "pkce-challenge@3.1.0": { + "integrity": "sha512-bQ/0XPZZ7eX+cdAkd61uYWpfMhakH3NeteUF1R8GNa+LMqX8QFAkbCLqq+AYAns1/ueACBu/BMWhrlKGrdvGZg==", + "dependencies": [ + "crypto-js" + ] + }, "postcss@8.5.6": { "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dependencies": [ @@ -1843,6 +1962,12 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "bin": true }, + "process@0.11.10": { + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==" + }, + "proxy-from-env@1.1.0": { + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, @@ -1852,6 +1977,9 @@ "side-channel" ] }, + "querystringify@2.2.0": { + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "queue-microtask@1.2.3": { "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" }, @@ -1868,6 +1996,9 @@ "react@19.1.1": { "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==" }, + "requires-port@1.0.0": { + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, "resolve-from@4.0.0": { "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==" }, @@ -1918,9 +2049,15 @@ "tslib" ] }, + "safe-buffer@5.2.1": { + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, "scheduler@0.26.0": { "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" }, + "scmp@2.1.0": { + "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==" + }, "semver@6.3.1": { "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "bin": true @@ -1938,6 +2075,9 @@ "seroval@1.3.2": { "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==" }, + "set-cookie-parser@2.7.1": { + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, "shebang-command@2.0.0": { "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dependencies": [ @@ -2003,6 +2143,29 @@ "strip-json-comments@3.1.1": { "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==" }, + "supertokens-js-override@0.0.4": { + "integrity": "sha512-r0JFBjkMIdep3Lbk3JA+MpnpuOtw4RSyrlRAbrzMcxwiYco3GFWl/daimQZ5b1forOiUODpOlXbSOljP/oyurg==" + }, + "supertokens-node@23.0.1": { + "integrity": "sha512-cCuY9Y5Mj93Pg1ktbqilouWgAoQWniQauftB4Ef6rfOchogx13XTo1pNP14zezn2rSf7WIPb9iaZb5zif6TKtQ==", + "dependencies": [ + "buffer", + "content-type", + "cookie@0.7.2", + "cross-fetch", + "debug", + "jose@4.15.9", + "libphonenumber-js", + "nodemailer", + "pako", + "pkce-challenge", + "process", + "set-cookie-parser", + "supertokens-js-override", + "tldts", + "twilio" + ] + }, "supports-color@7.2.0": { "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dependencies": [ @@ -2015,14 +2178,13 @@ "tapable@2.2.3": { "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==" }, - "tar@7.4.3": { - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "tar@7.4.4": { + "integrity": "sha512-O1z7ajPkjTgEgmTGz0v9X4eqeEXTDREPTO77pVC1Nbs86feBU1Zhdg+edzavPmYW1olxkwsqA2v4uOw6E8LeDg==", "dependencies": [ "@isaacs/fs-minipass", "chownr", "minipass", "minizlib", - "mkdirp", "yallist@5.0.0" ] }, @@ -2039,12 +2201,25 @@ "picomatch@4.0.3" ] }, + "tldts-core@6.1.86": { + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==" + }, + "tldts@6.1.86": { + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dependencies": [ + "tldts-core" + ], + "bin": true + }, "to-regex-range@5.0.1": { "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dependencies": [ "is-number" ] }, + "tr46@0.0.3": { + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, "tr46@5.1.1": { "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dependencies": [ @@ -2060,6 +2235,19 @@ "tslib@2.8.1": { "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "twilio@4.23.0": { + "integrity": "sha512-LdNBQfOe0dY2oJH2sAsrxazpgfFQo5yXGxe96QA8UWB5uu+433PrUbkv8gQ5RmrRCqUTPQ0aOrIyAdBr1aB03Q==", + "dependencies": [ + "axios", + "dayjs", + "https-proxy-agent", + "jsonwebtoken", + "qs", + "scmp", + "url-parse", + "xmlbuilder" + ] + }, "type-check@0.4.0": { "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dependencies": [ @@ -2081,9 +2269,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "bin": true }, - "undici-types@7.10.0": { - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" - }, "update-browserslist-db@1.1.3_browserslist@4.26.2": { "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "dependencies": [ @@ -2099,6 +2284,13 @@ "punycode" ] }, + "url-parse@1.5.10": { + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": [ + "querystringify", + "requires-port" + ] + }, "use-sync-external-store@1.5.0_react@19.1.1": { "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", "dependencies": [ @@ -2124,24 +2316,8 @@ ], "bin": true }, - "vite@7.1.6_picomatch@4.0.3_@types+node@24.2.0": { - "integrity": "sha512-SRYIB8t/isTwNn8vMB3MR6E+EQZM/WG1aKmmIUCfDXfVvKfc20ZpamngWHKzAmmu9ppsgxsg4b2I7c90JZudIQ==", - "dependencies": [ - "@types/node", - "esbuild", - "fdir", - "picomatch@4.0.3", - "postcss", - "rollup", - "tinyglobby" - ], - "optionalDependencies": [ - "fsevents" - ], - "optionalPeers": [ - "@types/node" - ], - "bin": true + "webidl-conversions@3.0.1": { + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "webidl-conversions@7.0.0": { "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" @@ -2149,8 +2325,15 @@ "whatwg-url@14.2.0": { "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dependencies": [ - "tr46", - "webidl-conversions" + "tr46@5.1.1", + "webidl-conversions@7.0.0" + ] + }, + "whatwg-url@5.0.0": { + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": [ + "tr46@0.0.3", + "webidl-conversions@3.0.1" ] }, "which@2.0.2": { @@ -2163,6 +2346,9 @@ "word-wrap@1.2.5": { "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==" }, + "xmlbuilder@13.0.2": { + "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==" + }, "yallist@3.1.1": { "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, @@ -2226,20 +2412,16 @@ "modules/identity": { "packageJson": { "dependencies": [ - "npm:@cerbos/http@0.23.1", - "npm:@jsr/felix__bcrypt@1.0.5", - "npm:@jsr/valkyr__auth@2.1.4", - "npm:@jsr/valkyr__event-store@2.0.1", - "npm:cookie@1.0.2", + "npm:supertokens-node@23.0.1", "npm:zod@4.1.11" ] } }, - "platform/cerbos": { + "modules/workspace": { "packageJson": { "dependencies": [ - "npm:@cerbos/http@0.23.1", - "npm:@jsr/valkyr__auth@2.1.4", + "npm:@jsr/valkyr__event-store@2.0.1", + "npm:cookie@1.0.2", "npm:zod@4.1.11" ] } @@ -2272,7 +2454,6 @@ "platform/relay": { "packageJson": { "dependencies": [ - "npm:@jsr/valkyr__auth@2.1.4", "npm:path-to-regexp@8", "npm:zod@4.1.11" ] @@ -2300,6 +2481,16 @@ ] } }, + "platform/supertoken": { + "packageJson": { + "dependencies": [ + "npm:@cerbos/http@0.23.1", + "npm:cookie@1.0.2", + "npm:supertokens-node@23.0.1", + "npm:zod@4.1.11" + ] + } + }, "platform/vault": { "packageJson": { "dependencies": [ diff --git a/docker-compose.yml b/docker-compose.yml index 1aba769..4dbb9a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,8 @@ services: - cerbos: - container_name: cerbos - image: ghcr.io/cerbos/cerbos:latest - command: ["server", "--config=/config.yaml"] # <--- ensure config is used - ports: - - "3592:3592" - - "3593:3593" - - "3594:3594" - volumes: - - ./platform/cerbos/config.yaml:/config.yaml # <--- mount config - - ./platform/cerbos/policies:/data/policies # <--- mount policies - networks: - - localdev + + # MongoDB + # -------------------------------------------------------------------------------- + # Used by event store and read store for managing and reading application data. mongo: image: mongo:8 @@ -26,6 +17,68 @@ services: networks: - localdev + # Super Tokens Database + # -------------------------------------------------------------------------------- + # Used by supertokens instance to store and manage principals. + + stdb: + image: 'postgres:latest' + environment: + POSTGRES_USER: supertokens_user + POSTGRES_PASSWORD: somePassword + POSTGRES_DB: supertokens + ports: + - 5432:5432 + networks: + - localdev + restart: unless-stopped + healthcheck: + test: ['CMD', 'pg_isready', '-U', 'supertokens_user', '-d', 'supertokens'] + interval: 5s + timeout: 5s + retries: 5 + + # Super Tokens + # -------------------------------------------------------------------------------- + + supertokens: + image: registry.supertokens.io/supertokens/supertokens-postgresql:latest + depends_on: + stdb: + condition: service_healthy + ports: + - 3567:3567 + environment: + POSTGRESQL_CONNECTION_URI: "postgresql://supertokens_user:somePassword@stdb:5432/supertokens" + networks: + - localdev + restart: unless-stopped + healthcheck: + test: > + bash -c 'exec 3<>/dev/tcp/127.0.0.1/3567 && echo -e "GET /hello HTTP/1.1\r\nhost: 127.0.0.1:3567\r\nConnection: close\r\n\r\n" >&3 && cat <&3 | grep "Hello"' + interval: 10s + timeout: 5s + retries: 5 + + # Cerbos + # -------------------------------------------------------------------------------- + # Policy engine for application access control. + + cerbos: + container_name: cerbos + image: ghcr.io/cerbos/cerbos:latest + command: ["server", "--config=/config.yaml"] + ports: + - "3592:3592" + - "3593:3593" + - "3594:3594" + volumes: + - ./cerbos.yaml:/config.yaml + - ./modules/identity/cerbos/policies:/data/policies/identity + - ./modules/workspace/cerbos/policies:/data/policies/workspace + networks: + - localdev + networks: localdev: driver: bridge diff --git a/modules/identity/aggregates/code.ts b/modules/identity/aggregates/code.ts deleted file mode 100644 index 31c49d2..0000000 --- a/modules/identity/aggregates/code.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { AggregateRoot, getDate } from "@valkyr/event-store"; - -import { EventRecord, EventStoreFactory } from "../event-store.ts"; -import { CodeIdentity } from "../events/code.ts"; - -export class Code extends AggregateRoot { - static override readonly name = "code"; - - identity!: CodeIdentity; - value!: string; - - createdAt!: Date; - claimedAt?: Date; - - // ------------------------------------------------------------------------- - // Accessors - // ------------------------------------------------------------------------- - - get isClaimed(): boolean { - return this.claimedAt !== undefined; - } - - // ------------------------------------------------------------------------- - // Folder - // ------------------------------------------------------------------------- - - with(event: EventRecord): void { - switch (event.type) { - case "code:created": { - this.value = event.data.value; - this.identity = event.data.identity; - this.createdAt = getDate(event.created); - break; - } - case "code:claimed": { - this.claimedAt = getDate(event.created); - break; - } - } - } - - // ------------------------------------------------------------------------- - // Actions - // ------------------------------------------------------------------------- - - create(identity: CodeIdentity): this { - return this.push({ - type: "code:created", - stream: this.id, - data: { - identity, - value: crypto - .getRandomValues(new Uint8Array(5)) - .map((v) => v % 10) - .join(""), - }, - }); - } - - claim(): this { - return this.push({ - type: "code:claimed", - stream: this.id, - }); - } -} diff --git a/modules/identity/aggregates/identity.ts b/modules/identity/aggregates/identity.ts deleted file mode 100644 index 05dfd5c..0000000 --- a/modules/identity/aggregates/identity.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { AuditActor, auditors } from "@platform/spec/audit/actor.ts"; -import { AggregateRoot, getDate } from "@valkyr/event-store"; - -import { db } from "../database.ts"; -import { type EventRecord, eventStore, type EventStoreFactory, projector } from "../event-store.ts"; -import type { Avatar } from "../schemas/avatar.ts"; -import type { Contact } from "../schemas/contact.ts"; -import type { Email } from "../schemas/email.ts"; -import type { Name } from "../schemas/name.ts"; -import type { Role } from "../schemas/role.ts"; -import type { Strategy } from "../schemas/strategies.ts"; - -export class Identity extends AggregateRoot { - static override readonly name = "identity"; - - avatar?: Avatar; - name?: Name; - contact: Contact = { - emails: [], - }; - strategies: Strategy[] = []; - roles: Role[] = []; - - createdAt!: Date; - updatedAt!: Date; - - // ------------------------------------------------------------------------- - // Reducer - // ------------------------------------------------------------------------- - - with(event: EventRecord): void { - switch (event.type) { - case "identity:created": { - this.id = event.stream; - this.createdAt = getDate(event.created); - break; - } - case "identity:avatar:added": { - this.avatar = { url: event.data }; - this.updatedAt = getDate(event.created); - break; - } - case "identity:name:added": { - this.name = event.data; - this.updatedAt = getDate(event.created); - break; - } - case "identity:email:added": { - this.contact.emails.push(event.data); - this.updatedAt = getDate(event.created); - break; - } - case "identity:role:added": { - this.roles.push(event.data); - this.updatedAt = getDate(event.created); - break; - } - case "identity:strategy:email:added": { - this.strategies.push({ type: "email", value: event.data }); - this.updatedAt = getDate(event.created); - break; - } - case "identity:strategy:password:added": { - this.strategies.push({ type: "password", ...event.data }); - this.updatedAt = getDate(event.created); - break; - } - } - } - - // ------------------------------------------------------------------------- - // Actions - // ------------------------------------------------------------------------- - - create(meta: AuditActor = auditors.system) { - return this.push({ - stream: this.id, - type: "identity:created", - meta, - }); - } - - addAvatar(url: string, meta: AuditActor = auditors.system): this { - return this.push({ - stream: this.id, - type: "identity:avatar:added", - data: url, - meta, - }); - } - - addName(name: Name, meta: AuditActor = auditors.system): this { - return this.push({ - stream: this.id, - type: "identity:name:added", - data: name, - meta, - }); - } - - addEmail(email: Email, meta: AuditActor = auditors.system): this { - return this.push({ - stream: this.id, - type: "identity:email:added", - data: email, - meta, - }); - } - - addRole(role: Role, meta: AuditActor = auditors.system): this { - return this.push({ - stream: this.id, - type: "identity:role:added", - data: role, - meta, - }); - } - - addEmailStrategy(email: string, meta: AuditActor = auditors.system): this { - return this.push({ - stream: this.id, - type: "identity:strategy:email:added", - data: email, - meta, - }); - } - - addPasswordStrategy(alias: string, password: string, meta: AuditActor = auditors.system): this { - return this.push({ - stream: this.id, - type: "identity:strategy:password:added", - data: { alias, password }, - meta, - }); - } -} - -/* - |-------------------------------------------------------------------------------- - | Utilities - |-------------------------------------------------------------------------------- - */ - -export async function isEmailClaimed(email: string): Promise { - const relations = await eventStore.relations.getByKey(getIdentityEmailRelation(email)); - if (relations.length > 0) { - return true; - } - return false; -} - -/* - |-------------------------------------------------------------------------------- - | Relations - |-------------------------------------------------------------------------------- - */ - -export function getIdentityEmailRelation(email: string): string { - return `/identities/emails/${email}`; -} - -export function getIdentityAliasRelation(alias: string): string { - return `/identities/aliases/${alias}`; -} - -/* - |-------------------------------------------------------------------------------- - | Projectors - |-------------------------------------------------------------------------------- - */ - -projector.on("identity:created", async ({ stream: id }) => { - await db.collection("identities").insertOne({ - id, - name: { - given: null, - family: null, - }, - contact: { - emails: [], - }, - strategies: [], - roles: [], - }); -}); - -projector.on("identity:avatar:added", async ({ stream: id, data: url }) => { - await db.collection("identities").updateOne({ id }, { $set: { avatar: { url } } }); -}); - -projector.on("identity:name:added", async ({ stream: id, data: name }) => { - await db.collection("identities").updateOne({ id }, { $set: { name } }); -}); - -projector.on("identity:email:added", async ({ stream: id, data: email }) => { - await db.collection("identities").updateOne({ id }, { $push: { "contact.emails": email } }); -}); - -projector.on("identity:role:added", async ({ stream: id, data: role }) => { - await db.collection("identities").updateOne({ id }, { $push: { roles: role } }); -}); - -projector.on("identity:strategy:email:added", async ({ stream: id, data: email }) => { - await eventStore.relations.insert(getIdentityEmailRelation(email), id); - await db.collection("identities").updateOne({ id }, { $push: { strategies: { type: "email", value: email } } }); -}); - -projector.on("identity:strategy:password:added", async ({ stream: id, data: strategy }) => { - await eventStore.relations.insert(getIdentityAliasRelation(strategy.alias), id); - await db.collection("identities").updateOne({ id }, { $push: { strategies: { type: "password", ...strategy } } }); -}); diff --git a/modules/identity/auth.ts b/modules/identity/auth.ts deleted file mode 100644 index 88a9fea..0000000 --- a/modules/identity/auth.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { resources } from "@platform/cerbos/resources.ts"; -import { Auth } from "@valkyr/auth"; - -import { access } from "./auth/access.ts"; -import { jwt } from "./auth/jwt.ts"; -import { principal } from "./auth/principal.ts"; - -export const auth = new Auth({ - principal, - resources, - access, - jwt, -}); - -export type Session = typeof auth.$session; diff --git a/modules/identity/auth/jwt.ts b/modules/identity/auth/jwt.ts deleted file mode 100644 index d5bc976..0000000 --- a/modules/identity/auth/jwt.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { config } from "../config.ts"; - -export const jwt = { - algorithm: "RS256", - privateKey: config.auth.privateKey, - publicKey: config.auth.publicKey, - issuer: "http://localhost", - audience: "http://localhost", -}; diff --git a/modules/identity/auth/principal.ts b/modules/identity/auth/principal.ts deleted file mode 100644 index 2cfba34..0000000 --- a/modules/identity/auth/principal.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { HttpAdapter, makeClient } from "@platform/relay"; -import { PrincipalProvider } from "@valkyr/auth"; -import z from "zod"; - -import { config } from "../config.ts"; -import resolve from "../routes/identities/resolve/spec.ts"; -import { RoleSchema } from "../schemas/role.ts"; - -export const identity = makeClient( - { - adapter: new HttpAdapter({ - url: config.url, - }), - }, - { - resolve: resolve.crypto({ - publicKey: config.internal.publicKey, - }), - }, -); - -export const principal = new PrincipalProvider( - RoleSchema, - { - workspaceIds: z.array(z.string()).optional().default([]), - }, - async function (id: string) { - const response = await identity.resolve({ params: { id } }); - if ("data" in response) { - return { - id, - roles: response.data.roles, - attributes: this.attributes.parse(response.data.attributes), - }; - } - }, -); - -export type Principal = typeof principal.$principal; diff --git a/modules/identity/cerbos/policies/identity.yaml b/modules/identity/cerbos/policies/identity.yaml new file mode 100644 index 0000000..57ff976 --- /dev/null +++ b/modules/identity/cerbos/policies/identity.yaml @@ -0,0 +1,23 @@ +# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json +# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies + +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + resource: identity + version: default + rules: + + # Admins can read any identity with limited fields + + - actions: ["read", "update"] + effect: EFFECT_ALLOW + roles: ["admin"] + + # Users can fully read, update, or delete their own identity + + - actions: ["read", "update", "delete"] + effect: EFFECT_ALLOW + roles: ["user"] + condition: + match: + expr: request.resource.id == request.principal.id diff --git a/modules/identity/cerbos/policies/role.yaml b/modules/identity/cerbos/policies/role.yaml new file mode 100644 index 0000000..147908c --- /dev/null +++ b/modules/identity/cerbos/policies/role.yaml @@ -0,0 +1,14 @@ +# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json +# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies + +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + resource: role + version: default + rules: + + # Admin can manage roles + + - actions: ["manage"] + effect: EFFECT_ALLOW + roles: ["admin"] diff --git a/platform/cerbos/resources.ts b/modules/identity/cerbos/resources.ts similarity index 62% rename from platform/cerbos/resources.ts rename to modules/identity/cerbos/resources.ts index 3c0644d..0e561d3 100644 --- a/platform/cerbos/resources.ts +++ b/modules/identity/cerbos/resources.ts @@ -1,14 +1,11 @@ -import { ResourceRegistry } from "@valkyr/auth"; - +/* export const resources = new ResourceRegistry([ { kind: "identity", - attr: {}, - }, - { - kind: "workspace", + actions: ["read", "update", "delete"], attr: {}, }, ] as const); export type Resource = typeof resources.$resource; +*/ diff --git a/modules/identity/client.ts b/modules/identity/client.ts index eb728b2..7d96283 100644 --- a/modules/identity/client.ts +++ b/modules/identity/client.ts @@ -2,11 +2,10 @@ import { HttpAdapter, makeClient } from "@platform/relay"; import { config } from "./config.ts"; import getById from "./routes/identities/get/spec.ts"; -import me from "./routes/identities/me/spec.ts"; -import register from "./routes/identities/register/spec.ts"; import loginByPassword from "./routes/login/code/spec.ts"; import loginByEmail from "./routes/login/email/spec.ts"; import loginByCode from "./routes/login/password/spec.ts"; +import me from "./routes/me/spec.ts"; export const identity = makeClient( { @@ -15,11 +14,6 @@ export const identity = makeClient( }), }, { - /** - * TODO ... - */ - register, - /** * TODO ... */ diff --git a/modules/identity/config.ts b/modules/identity/config.ts index fb6548f..50506a1 100644 --- a/modules/identity/config.ts +++ b/modules/identity/config.ts @@ -1,8 +1,4 @@ -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; - import { getEnvironmentVariable } from "@platform/config/environment.ts"; -import type { SerializeOptions } from "cookie"; import z from "zod"; export const config = { @@ -11,49 +7,4 @@ export const config = { type: z.url(), fallback: "http://localhost:8370", }), - auth: { - privateKey: getEnvironmentVariable({ - key: "AUTH_PRIVATE_KEY", - type: z.string(), - fallback: await readFile(resolve(import.meta.dirname!, ".keys", "private"), "utf-8"), - }), - publicKey: getEnvironmentVariable({ - key: "AUTH_PUBLIC_KEY", - type: z.string(), - fallback: await readFile(resolve(import.meta.dirname!, ".keys", "public"), "utf-8"), - }), - }, - internal: { - privateKey: getEnvironmentVariable({ - key: "INTERNAL_PRIVATE_KEY", - type: z.string(), - fallback: - "-----BEGIN PRIVATE KEY-----\n" + - "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg2WYKMJZUWff5XOWC\n" + - "XGuU+wmsRzhQGEIzfUoL6rrGoaehRANCAATCpiGiFQxTA76EIVG0cBbj+AFt6BuJ\n" + - "t4q+zoInPUzkChCdwI+XfAYokrZwBjcyRGluC02HaN3cptrmjYSGSMSx\n" + - "-----END PRIVATE KEY-----", - }), - publicKey: getEnvironmentVariable({ - key: "INTERNAL_PUBLIC_KEY", - type: z.string(), - fallback: - "-----BEGIN PUBLIC KEY-----\n" + - "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwqYhohUMUwO+hCFRtHAW4/gBbegb\n" + - "ibeKvs6CJz1M5AoQncCPl3wGKJK2cAY3MkRpbgtNh2jd3Kba5o2EhkjEsQ==\n" + - "-----END PUBLIC KEY-----", - }), - }, - cookie: (maxAge: number) => - ({ - httpOnly: true, - secure: getEnvironmentVariable({ - key: "AUTH_COOKIE_SECURE", - type: z.coerce.boolean(), - fallback: "false", - }), // Set to true for HTTPS in production - maxAge, - path: "/", - sameSite: "strict", - }) satisfies SerializeOptions, }; diff --git a/modules/identity/crypto/password.ts b/modules/identity/crypto/password.ts deleted file mode 100644 index ad83a79..0000000 --- a/modules/identity/crypto/password.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as bcrypt from "@felix/bcrypt"; - -export const password = { hash, verify }; - -async function hash(password: string): Promise { - return bcrypt.hash(password); -} - -async function verify(password: string, hash: string): Promise { - return bcrypt.verify(password, hash); -} diff --git a/modules/identity/database.ts b/modules/identity/database.ts deleted file mode 100644 index aa42ba9..0000000 --- a/modules/identity/database.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { getDatabaseAccessor } from "@platform/database/accessor.ts"; - -import { type Identity, parseIdentity } from "./models/identity.ts"; -import type { PasswordStrategy } from "./schemas/strategies.ts"; - -export const db = getDatabaseAccessor<{ - identities: Identity; -}>(`identity:read-store`); - -/* - |-------------------------------------------------------------------------------- - | Identity - |-------------------------------------------------------------------------------- - */ - -/** - * Retrieve a single account by its primary identifier. - * - * @param id - Unique identity. - */ -export async function getIdentityById(id: string): Promise { - return db - .collection("identities") - .findOne({ id }) - .then((document) => parseIdentity(document)); -} - -/** - * Get strategy details for the given password strategy alias. - * - * @param alias - Alias to get strategy for. - */ -export async function getPasswordStrategyByAlias( - alias: string, -): Promise<({ accountId: string } & PasswordStrategy) | undefined> { - const account = await db.collection("identities").findOne({ - strategies: { - $elemMatch: { type: "password", alias }, - }, - }); - if (account === null) { - return undefined; - } - const strategy = account.strategies.find((strategy) => strategy.type === "password" && strategy.alias === alias); - if (strategy === undefined) { - return undefined; - } - return { accountId: account.id, ...strategy } as { accountId: string } & PasswordStrategy; -} diff --git a/modules/identity/errors.ts b/modules/identity/errors.ts deleted file mode 100644 index 60700ad..0000000 --- a/modules/identity/errors.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ConflictError } from "@platform/relay"; - -export class IdentityEmailClaimedError extends ConflictError { - constructor(email: string) { - super(`Email '${email}' is already claimed by another identity.`); - } -} diff --git a/modules/identity/events/code.ts b/modules/identity/events/code.ts deleted file mode 100644 index 42a06a2..0000000 --- a/modules/identity/events/code.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { event } from "@valkyr/event-store"; -import z from "zod"; - -const CodeIdentitySchema = z.object({ - id: z.string(), -}); - -export default [ - event.type("code:created").data( - z.object({ - identity: CodeIdentitySchema, - value: z.string(), - }), - ), - event.type("code:claimed"), -]; - -export type CodeIdentity = z.infer; diff --git a/modules/identity/events/identity.ts b/modules/identity/events/identity.ts deleted file mode 100644 index 86eb995..0000000 --- a/modules/identity/events/identity.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { AuditActorSchema } from "@platform/spec/audit/actor.ts"; -import { event } from "@valkyr/event-store"; -import z from "zod"; - -import { EmailSchema } from "../schemas/email.ts"; -import { NameSchema } from "../schemas/name.ts"; -import { RoleSchema } from "../schemas/role.ts"; - -export default [ - event.type("identity:created").meta(AuditActorSchema), - event.type("identity:avatar:added").data(z.string()).meta(AuditActorSchema), - event.type("identity:name:added").data(NameSchema).meta(AuditActorSchema), - event.type("identity:email:added").data(EmailSchema).meta(AuditActorSchema), - event.type("identity:role:added").data(RoleSchema).meta(AuditActorSchema), - event.type("identity:strategy:email:added").data(z.string()).meta(AuditActorSchema), - event.type("identity:strategy:passkey:added").meta(AuditActorSchema), - event - .type("identity:strategy:password:added") - .data(z.object({ alias: z.string(), password: z.string() })) - .meta(AuditActorSchema), -]; diff --git a/modules/identity/models/identity.ts b/modules/identity/models/identity.ts deleted file mode 100644 index 77d19f4..0000000 --- a/modules/identity/models/identity.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { makeDocumentParser } from "@platform/database/utilities.ts"; -import { z } from "zod"; - -import { AvatarSchema } from "../schemas/avatar.ts"; -import { ContactSchema } from "../schemas/contact.ts"; -import { NameSchema } from "../schemas/name.ts"; -import { RoleSchema } from "../schemas/role.ts"; -import { StrategySchema } from "../schemas/strategies.ts"; - -export const IdentitySchema = z.object({ - id: z.uuid(), - avatar: AvatarSchema.optional(), - name: NameSchema.optional(), - contact: ContactSchema.default({ - emails: [], - }), - strategies: z.array(StrategySchema).default([]), - roles: z.array(RoleSchema).default([]), - attributes: z.record(z.string(), z.any()), -}); - -/* - |-------------------------------------------------------------------------------- - | Parsers - |-------------------------------------------------------------------------------- - */ - -export const parseIdentity = makeDocumentParser(IdentitySchema); - -/* - |-------------------------------------------------------------------------------- - | Types - |-------------------------------------------------------------------------------- - */ - -export type Identity = z.infer; diff --git a/modules/identity/package.json b/modules/identity/package.json index 9a8be51..83b28c9 100644 --- a/modules/identity/package.json +++ b/modules/identity/package.json @@ -7,22 +7,11 @@ "./client.ts": "./client.ts", "./server.ts": "./server.ts" }, - "types": "types.d.ts", "dependencies": { - "@cerbos/http": "0.23.1", - "@felix/bcrypt": "npm:@jsr/felix__bcrypt@1.0.5", - "@platform/cerbos": "workspace:*", "@platform/config": "workspace:*", - "@platform/database": "workspace:*", "@platform/logger": "workspace:*", "@platform/relay": "workspace:*", - "@platform/server": "workspace:*", - "@platform/spec": "workspace:*", - "@platform/storage": "workspace:*", - "@platform/vault": "workspace:*", - "@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.4", - "@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1", - "cookie": "1.0.2", + "supertokens-node": "23.0.1", "zod": "4.1.11" } } \ No newline at end of file diff --git a/modules/identity/routes/identities/get/handle.ts b/modules/identity/routes/identities/get/handle.ts index 1a135b1..b9b57fc 100644 --- a/modules/identity/routes/identities/get/handle.ts +++ b/modules/identity/routes/identities/get/handle.ts @@ -1,16 +1,21 @@ import { ForbiddenError, NotFoundError } from "@platform/relay"; +import { getPrincipalAttributes, getPrincipalRoles } from "@platform/supertoken/principal.ts"; +import { getUserById } from "@platform/supertoken/users.ts"; -import { getIdentityById } from "../../../database.ts"; import route from "./spec.ts"; export default route.access("session").handle(async ({ params: { id } }, { access }) => { - const identity = await getIdentityById(id); - if (identity === undefined) { + const user = await getUserById(id); + if (user === undefined) { return new NotFoundError("Identity does not exist, or has been removed."); } - const decision = await access.isAllowed({ kind: "identity", id: identity.id, attr: {} }, "read"); + const decision = await access.isAllowed({ kind: "identity", id: user.id, attr: {} }, "read"); if (decision === false) { return new ForbiddenError("You do not have permission to view this identity."); } - return identity; + return { + id: user.id, + roles: await getPrincipalRoles(id), + attr: await getPrincipalAttributes(id), + }; }); diff --git a/modules/identity/routes/identities/get/spec.ts b/modules/identity/routes/identities/get/spec.ts index b9b9d06..828389e 100644 --- a/modules/identity/routes/identities/get/spec.ts +++ b/modules/identity/routes/identities/get/spec.ts @@ -1,12 +1,10 @@ import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay"; import z from "zod"; -import { IdentitySchema } from "../../../models/identity.ts"; - export default route - .get("/api/v1/identities/:id") + .get("/api/v1/identity/:id") .params({ id: z.string(), }) .errors([UnauthorizedError, ForbiddenError, NotFoundError]) - .response(IdentitySchema); + .response(z.any()); diff --git a/modules/identity/routes/identities/me/handle.ts b/modules/identity/routes/identities/me/handle.ts deleted file mode 100644 index babd738..0000000 --- a/modules/identity/routes/identities/me/handle.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { UnauthorizedError } from "@platform/relay"; - -import { getIdentityById } from "../../../database.ts"; -import route from "./spec.ts"; - -export default route.access("session").handle(async ({ principal }) => { - const identity = await getIdentityById(principal.id); - if (identity === undefined) { - return new UnauthorizedError("You must be signed in to view your session."); - } - return identity; -}); diff --git a/modules/identity/routes/identities/me/spec.ts b/modules/identity/routes/identities/me/spec.ts deleted file mode 100644 index ed750db..0000000 --- a/modules/identity/routes/identities/me/spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { NotFoundError, route, UnauthorizedError } from "@platform/relay"; - -import { IdentitySchema } from "../../../models/identity.ts"; - -export default route.get("/api/v1/identities/me").response(IdentitySchema).errors([UnauthorizedError, NotFoundError]); diff --git a/modules/identity/routes/identities/register/handle.ts b/modules/identity/routes/identities/register/handle.ts deleted file mode 100644 index 1b62a62..0000000 --- a/modules/identity/routes/identities/register/handle.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Identity, isEmailClaimed } from "../../../aggregates/identity.ts"; -import { IdentityEmailClaimedError } from "../../../errors.ts"; -import { eventStore } from "../../../event-store.ts"; -import route from "./spec.ts"; - -export default route.access("public").handle(async ({ body: { name, email } }) => { - if ((await isEmailClaimed(email)) === true) { - return new IdentityEmailClaimedError(email); - } - return eventStore.aggregate.from(Identity).create().addName(name).addEmailStrategy(email).addRole("user").save(); -}); diff --git a/modules/identity/routes/identities/register/spec.ts b/modules/identity/routes/identities/register/spec.ts deleted file mode 100644 index b6df314..0000000 --- a/modules/identity/routes/identities/register/spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { route } from "@platform/relay"; -import z from "zod"; - -import { IdentityEmailClaimedError } from "../../../errors.ts"; -import { IdentitySchema } from "../../../models/identity.ts"; -import { NameSchema } from "../../../schemas/name.ts"; - -export default route - .post("/api/v1/identities") - .body( - z.object({ - name: NameSchema, - email: z.email(), - }), - ) - .errors([IdentityEmailClaimedError]) - .response(IdentitySchema); diff --git a/modules/identity/routes/identities/resolve/handle.ts b/modules/identity/routes/identities/resolve/handle.ts deleted file mode 100644 index 772aa38..0000000 --- a/modules/identity/routes/identities/resolve/handle.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NotFoundError } from "@platform/relay"; - -import { config } from "../../../config.ts"; -import { getIdentityById } from "../../../database.ts"; -import route from "./spec.ts"; - -export default route.access(["internal:public", config.internal.privateKey]).handle(async ({ params: { id } }) => { - const identity = await getIdentityById(id); - if (identity === undefined) { - return new NotFoundError(); - } - return identity; -}); diff --git a/modules/identity/routes/identities/resolve/keys.ts b/modules/identity/routes/identities/resolve/keys.ts deleted file mode 100644 index 5cc2e99..0000000 --- a/modules/identity/routes/identities/resolve/keys.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { importVault } from "@platform/vault"; - -import { config } from "../../../config.ts"; - -export const vault = importVault(config.internal); diff --git a/modules/identity/routes/identities/resolve/spec.ts b/modules/identity/routes/identities/resolve/spec.ts deleted file mode 100644 index e017d28..0000000 --- a/modules/identity/routes/identities/resolve/spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NotFoundError, route, UnauthorizedError } from "@platform/relay"; -import z from "zod"; - -import { IdentitySchema } from "../../../models/identity.ts"; - -export default route - .get("/api/v1/identities/:id/resolve") - .params({ - id: z.string(), - }) - .response(IdentitySchema) - .errors([UnauthorizedError, NotFoundError]); diff --git a/modules/identity/routes/identities/update/handle.ts b/modules/identity/routes/identities/update/handle.ts new file mode 100644 index 0000000..28c9d46 --- /dev/null +++ b/modules/identity/routes/identities/update/handle.ts @@ -0,0 +1,40 @@ +import { ForbiddenError } from "@platform/relay"; +import { getPrincipalAttributes } from "@platform/supertoken/principal.ts"; +import UserMetadata from "supertokens-node/recipe/usermetadata"; + +import route from "./spec.ts"; + +export default route.access("session").handle(async ({ params: { id }, body: ops }, { access }) => { + const decision = await access.isAllowed({ kind: "identity", id, attr: {} }, "update"); + if (decision === false) { + return new ForbiddenError("You do not have permission to update this identity."); + } + const attr = await getPrincipalAttributes(id); + for (const op of ops) { + switch (op.type) { + case "add": { + attr[op.key] = op.value; + break; + } + case "push": { + if (attr[op.key] === undefined) { + attr[op.key] = op.values; + } else { + attr[op.key] = [...attr[op.key], ...op.values]; + } + break; + } + case "pop": { + if (Array.isArray(attr[op.key])) { + attr[op.key] = attr[op.key].filter((value: any) => op.values.includes(value) === false); + } + break; + } + case "remove": { + delete attr[op.key]; + break; + } + } + } + await UserMetadata.updateUserMetadata(id, { attr }); +}); diff --git a/modules/identity/routes/identities/update/spec.ts b/modules/identity/routes/identities/update/spec.ts new file mode 100644 index 0000000..9d3a0f2 --- /dev/null +++ b/modules/identity/routes/identities/update/spec.ts @@ -0,0 +1,29 @@ +import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay"; +import z from "zod"; + +export default route + .put("/api/v1/identity/:id") + .params({ + id: z.string(), + }) + .body( + z.array( + z.union([ + z.strictObject({ + type: z.union([z.literal("add")]), + key: z.string(), + value: z.any(), + }), + z.strictObject({ + type: z.union([z.literal("push"), z.literal("pop")]), + key: z.string(), + values: z.array(z.any()), + }), + z.strictObject({ + type: z.union([z.literal("remove")]), + key: z.string(), + }), + ]), + ), + ) + .errors([UnauthorizedError, ForbiddenError, NotFoundError]); diff --git a/modules/identity/routes/login/code/handle.ts b/modules/identity/routes/login/code/handle.ts index 738863f..7fa1649 100644 --- a/modules/identity/routes/login/code/handle.ts +++ b/modules/identity/routes/login/code/handle.ts @@ -1,85 +1,25 @@ import { logger } from "@platform/logger"; -import cookie from "cookie"; +import { NotFoundError } from "@platform/relay"; +import { getSessionHeaders } from "@platform/supertoken/session.ts"; +import Passwordless from "supertokens-node/recipe/passwordless"; -import { Code } from "../../../aggregates/code.ts"; -import { Identity } from "../../../aggregates/identity.ts"; -import { auth } from "../../../auth.ts"; -import { config } from "../../../config.ts"; -import { eventStore } from "../../../event-store.ts"; import route from "./spec.ts"; -export default route.access("public").handle(async ({ params: { identityId, codeId, value }, query: { next } }) => { - const code = await eventStore.aggregate.getByStream(Code, codeId); - - if (code === undefined) { - return logger.info({ - type: "code:claimed", - session: false, - message: "Invalid Code ID", - received: codeId, - }); +export default route.access("public").handle(async ({ body: { preAuthSessionId, deviceId, userInputCode } }) => { + const response = await Passwordless.consumeCode({ tenantId: "public", preAuthSessionId, deviceId, userInputCode }); + if (response.status !== "OK") { + return new NotFoundError(); } - if (code.claimedAt !== undefined) { - return logger.info({ - type: "code:claimed", - session: false, - message: "Code Already Claimed", - received: codeId, - }); - } - - await code.claim().save(); - - if (code.value !== value) { - return logger.info({ - type: "code:claimed", - session: false, - message: "Invalid Value", - expected: code.value, - received: value, - }); - } - - if (code.identity.id !== identityId) { - return logger.info({ - type: "code:claimed", - session: false, - message: "Invalid Identity ID", - expected: code.identity.id, - received: identityId, - }); - } - - const account = await eventStore.aggregate.getByStream(Identity, identityId); - if (account === undefined) { - return logger.info({ - type: "code:claimed", - session: false, - message: "Account Not Found", - expected: code.identity.id, - received: undefined, - }); - } - - logger.info({ type: "code:claimed", session: true }); - - const options = config.cookie(1000 * 60 * 60 * 24 * 7); - - if (next !== undefined) { - return new Response(null, { - status: 302, - headers: { - location: next, - "set-cookie": cookie.serialize("token", await auth.generate({ id: account.id }, "1 week"), options), - }, - }); - } + logger.info({ + type: "code:claimed", + session: true, + message: "Identity resolved", + user: response.user.toJson(), + }); return new Response(null, { status: 200, - headers: { - "set-cookie": cookie.serialize("token", await auth.generate({ id: account.id }, "1 week"), options), - }, + headers: await getSessionHeaders("public", response.recipeUserId), }); }); diff --git a/modules/identity/routes/login/code/spec.ts b/modules/identity/routes/login/code/spec.ts index 531b981..78ed314 100644 --- a/modules/identity/routes/login/code/spec.ts +++ b/modules/identity/routes/login/code/spec.ts @@ -2,12 +2,14 @@ import { route } from "@platform/relay"; import z from "zod"; export default route - .get("/api/v1/identities/login/code/:identityId/code/:codeId/:value") - .params({ - identityId: z.string(), - codeId: z.string(), - value: z.string(), - }) + .post("/api/v1/identity/login/code") + .body( + z.strictObject({ + deviceId: z.string(), + preAuthSessionId: z.string(), + userInputCode: z.string(), + }), + ) .query({ next: z.string().optional(), }); diff --git a/modules/identity/routes/login/email/handle.ts b/modules/identity/routes/login/email/handle.ts index e27ce78..d50ba65 100644 --- a/modules/identity/routes/login/email/handle.ts +++ b/modules/identity/routes/login/email/handle.ts @@ -1,27 +1,23 @@ import { logger } from "@platform/logger"; +import Passwordless from "supertokens-node/recipe/passwordless"; -import { Code } from "../../../aggregates/code.ts"; -import { getIdentityEmailRelation, Identity } from "../../../aggregates/identity.ts"; -import { eventStore } from "../../../event-store.ts"; import route from "./spec.ts"; -export default route.access("public").handle(async ({ body: { base, email } }) => { - const identity = await eventStore.aggregate.getByRelation(Identity, getIdentityEmailRelation(email)); - if (identity === undefined) { +export default route.access("public").handle(async ({ body: { email } }) => { + const response = await Passwordless.createCode({ tenantId: "public", email }); + if (response.status !== "OK") { return logger.info({ - type: "auth:email", - code: false, - message: "Identity Not Found", + type: "auth:passwordless", + message: "Create code failed.", received: email, }); } - const code = await eventStore.aggregate.from(Code).create({ id: identity.id }).save(); logger.info({ - type: "auth:email", + type: "auth:passwordless", data: { - code: code.id, - identityId: identity.id, + deviceId: response.deviceId, + preAuthSessionId: response.preAuthSessionId, + userInputCode: response.userInputCode, }, - link: `${base}/api/v1/admin/auth/${identity.id}/code/${code.id}/${code.value}?next=${base}/admin`, }); }); diff --git a/modules/identity/routes/login/email/spec.ts b/modules/identity/routes/login/email/spec.ts index 6da65bc..a075483 100644 --- a/modules/identity/routes/login/email/spec.ts +++ b/modules/identity/routes/login/email/spec.ts @@ -1,9 +1,8 @@ import { route } from "@platform/relay"; import z from "zod"; -export default route.post("/api/v1/identities/login/email").body( +export default route.post("/api/v1/identity/login/email").body( z.object({ - base: z.url(), email: z.email(), }), ); diff --git a/modules/identity/routes/login/sudo/handle.ts b/modules/identity/routes/login/sudo/handle.ts new file mode 100644 index 0000000..f276087 --- /dev/null +++ b/modules/identity/routes/login/sudo/handle.ts @@ -0,0 +1,48 @@ +import { logger } from "@platform/logger"; +import { NotFoundError } from "@platform/relay"; +import { getSessionHeaders } from "@platform/supertoken/session.ts"; +import Passwordless from "supertokens-node/recipe/passwordless"; + +import route from "./spec.ts"; + +export default route.access("public").handle(async ({ body: { email } }) => { + const code = await Passwordless.createCode({ tenantId: "public", email }); + if (code.status !== "OK") { + return logger.info({ + type: "auth:passwordless", + message: "Create code failed.", + received: email, + }); + } + + logger.info({ + type: "auth:passwordless", + data: { + deviceId: code.deviceId, + preAuthSessionId: code.preAuthSessionId, + userInputCode: code.userInputCode, + }, + }); + + const response = await Passwordless.consumeCode({ + tenantId: "public", + preAuthSessionId: code.preAuthSessionId, + deviceId: code.deviceId, + userInputCode: code.userInputCode, + }); + if (response.status !== "OK") { + return new NotFoundError(); + } + + logger.info({ + type: "code:claimed", + session: true, + message: "Identity resolved", + user: response.user.toJson(), + }); + + return new Response(null, { + status: 200, + headers: await getSessionHeaders("public", response.recipeUserId), + }); +}); diff --git a/modules/identity/routes/login/sudo/spec.ts b/modules/identity/routes/login/sudo/spec.ts new file mode 100644 index 0000000..b8fc493 --- /dev/null +++ b/modules/identity/routes/login/sudo/spec.ts @@ -0,0 +1,8 @@ +import { route } from "@platform/relay"; +import z from "zod"; + +export default route.post("/api/v1/identities/login/sudo").body( + z.object({ + email: z.email(), + }), +); diff --git a/modules/identity/routes/me/handle.ts b/modules/identity/routes/me/handle.ts new file mode 100644 index 0000000..ef1e7ee --- /dev/null +++ b/modules/identity/routes/me/handle.ts @@ -0,0 +1,5 @@ +import route from "./spec.ts"; + +export default route.access("session").handle(async ({ principal }) => { + return principal; +}); diff --git a/modules/identity/routes/me/spec.ts b/modules/identity/routes/me/spec.ts new file mode 100644 index 0000000..6c2067d --- /dev/null +++ b/modules/identity/routes/me/spec.ts @@ -0,0 +1,4 @@ +import { NotFoundError, route, UnauthorizedError } from "@platform/relay"; +import z from "zod"; + +export default route.get("/api/v1/identity/me").errors([UnauthorizedError, NotFoundError]).response(z.any()); diff --git a/modules/identity/routes/roles/handle.ts b/modules/identity/routes/roles/handle.ts new file mode 100644 index 0000000..000fa28 --- /dev/null +++ b/modules/identity/routes/roles/handle.ts @@ -0,0 +1,30 @@ +import { ForbiddenError } from "@platform/relay"; +import { getPrincipalRoles } from "@platform/supertoken/principal.ts"; +import UserMetadata from "supertokens-node/recipe/usermetadata"; + +import route from "./spec.ts"; + +export default route.access("session").handle(async ({ params: { id }, body: ops }, { access }) => { + const decision = await access.isAllowed({ kind: "role", id, attr: {} }, "manage"); + if (decision === false) { + return new ForbiddenError("You do not have permission to modify roles for this identity."); + } + const roles: Set = new Set(await getPrincipalRoles(id)); + for (const op of ops) { + switch (op.type) { + case "add": { + for (const role of op.roles) { + roles.add(role); + } + break; + } + case "remove": { + for (const role of op.roles) { + roles.delete(role); + } + break; + } + } + } + await UserMetadata.updateUserMetadata(id, { roles: Array.from(roles) }); +}); diff --git a/modules/identity/routes/roles/spec.ts b/modules/identity/routes/roles/spec.ts new file mode 100644 index 0000000..d159b0c --- /dev/null +++ b/modules/identity/routes/roles/spec.ts @@ -0,0 +1,19 @@ +import { ForbiddenError, NotFoundError, route, UnauthorizedError } from "@platform/relay"; +import z from "zod"; + +export default route + .put("/api/v1/identity/:id/roles") + .params({ + id: z.string(), + }) + .body( + z.array( + z.union([ + z.strictObject({ + type: z.union([z.literal("add"), z.literal("remove")]), + roles: z.array(z.any()), + }), + ]), + ), + ) + .errors([UnauthorizedError, ForbiddenError, NotFoundError]); diff --git a/modules/identity/schemas/contact.ts b/modules/identity/schemas/contact.ts deleted file mode 100644 index 5d885e0..0000000 --- a/modules/identity/schemas/contact.ts +++ /dev/null @@ -1,9 +0,0 @@ -import z from "zod"; - -import { EmailSchema } from "./email.ts"; - -export const ContactSchema = z.object({ - emails: z.array(EmailSchema).default([]).describe("A list of email addresses associated with the contact."), -}); - -export type Contact = z.infer; diff --git a/modules/identity/schemas/role.ts b/modules/identity/schemas/role.ts deleted file mode 100644 index e9ea8f4..0000000 --- a/modules/identity/schemas/role.ts +++ /dev/null @@ -1,5 +0,0 @@ -import z from "zod"; - -export const RoleSchema = z.union([z.literal("user"), z.literal("admin")]); - -export type Role = z.infer; diff --git a/modules/identity/schemas/strategies.ts b/modules/identity/schemas/strategies.ts deleted file mode 100644 index 3ef6c29..0000000 --- a/modules/identity/schemas/strategies.ts +++ /dev/null @@ -1,37 +0,0 @@ -import z from "zod"; - -const EmailStrategySchema = z.object({ - type: z.literal("email"), - value: z.string(), -}); - -const PasswordStrategySchema = z.object({ - type: z.literal("password"), - alias: z.string(), - password: z.string(), -}); - -const PasskeyStrategySchema = z.object({ - type: z.literal("passkey"), - credId: z.string(), - credPublicKey: z.string(), - webauthnUserId: z.string(), - counter: z.number(), - backupEligible: z.boolean(), - backupStatus: z.boolean(), - transports: z.string(), - createdAt: z.date(), - lastUsed: z.date(), -}); - -export const StrategySchema = z.discriminatedUnion("type", [ - EmailStrategySchema, - PasswordStrategySchema, - PasskeyStrategySchema, -]); - -export type EmailStrategy = z.infer; -export type PasswordStrategy = z.infer; -export type PasskeyStrategy = z.infer; - -export type Strategy = z.infer; diff --git a/modules/identity/server.ts b/modules/identity/server.ts index 6550cb4..da1e43e 100644 --- a/modules/identity/server.ts +++ b/modules/identity/server.ts @@ -1,96 +1,12 @@ -import "./types.d.ts"; - -import { idIndex } from "@platform/database/id.ts"; -import { register as registerReadStore } from "@platform/database/registrar.ts"; -import { UnauthorizedError } from "@platform/relay"; -import { context } from "@platform/relay"; -import { storage } from "@platform/storage"; -import { register as registerEventStore } from "@valkyr/event-store/mongo"; -import cookie from "cookie"; - -import { auth } from "./auth.ts"; -import { db } from "./database.ts"; -import { eventStore } from "./event-store.ts"; - export default { routes: [ (await import("./routes/identities/get/handle.ts")).default, - (await import("./routes/identities/register/handle.ts")).default, - (await import("./routes/identities/me/handle.ts")).default, - (await import("./routes/identities/resolve/handle.ts")).default, + (await import("./routes/identities/update/handle.ts")).default, (await import("./routes/login/code/handle.ts")).default, (await import("./routes/login/email/handle.ts")).default, - (await import("./routes/login/password/handle.ts")).default, + // (await import("./routes/login/password/handle.ts")).default, + (await import("./routes/login/sudo/handle.ts")).default, + (await import("./routes/me/handle.ts")).default, + (await import("./routes/roles/handle.ts")).default, ], - - /** - * TODO ... - */ - bootstrap: async (): Promise => { - await registerReadStore(db.db, [ - { - name: "identities", - indexes: [ - idIndex, - [{ "strategies.type": 1, "strategies.alias": 1 }, { name: "strategy.password" }], - [{ "strategies.type": 1, "strategies.value": 1 }, { name: "strategy.email" }], - ], - }, - ]); - await registerEventStore(eventStore.db.db, console.info); - Object.defineProperties(context, { - /** - * TODO ... - */ - isAuthenticated: { - get() { - return storage.getStore()?.principal !== undefined; - }, - }, - - /** - * TODO ... - */ - principal: { - get() { - const principal = storage.getStore()?.principal; - if (principal === undefined) { - throw new UnauthorizedError(); - } - return principal; - }, - }, - - /** - * TODO ... - */ - access: { - get() { - const access = storage.getStore()?.access; - if (access === undefined) { - throw new UnauthorizedError(); - } - return access; - }, - }, - }); - }, - - /** - * TODO ... - */ - resolve: async (request: Request): Promise => { - const token = cookie.parse(request.headers.get("cookie") ?? "").token; - if (token !== undefined) { - const session = await auth.resolve(token); - if (session.valid === true) { - const context = storage.getStore(); - if (context === undefined) { - return; - } - context.principal = session.principal; - context.access = session.access; - } - } - }, }; diff --git a/modules/workspace/aggregates/workspace-user.ts b/modules/workspace/aggregates/workspace-user.ts new file mode 100644 index 0000000..27d1da5 --- /dev/null +++ b/modules/workspace/aggregates/workspace-user.ts @@ -0,0 +1,66 @@ +import { AuditActor, auditors } from "@platform/spec/audit/actor.ts"; +import { AggregateRoot, getDate } from "@valkyr/event-store"; + +import { db } from "../database.ts"; +import { EventRecord, EventStoreFactory, projector } from "../event-store.ts"; + +export class WorkspaceUser extends AggregateRoot { + static override readonly name = "workspace:user"; + + workspaceId!: string; + identityId!: string; + + createdAt!: Date; + updatedAt?: Date; + + // ------------------------------------------------------------------------- + // Reducer + // ------------------------------------------------------------------------- + + with(event: EventRecord): void { + switch (event.type) { + case "workspace:user:created": { + this.workspaceId = event.data.workspaceId; + this.identityId = event.data.identityId; + break; + } + } + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + create(workspaceId: string, identityId: string, meta: AuditActor = auditors.system) { + return this.push({ + stream: this.id, + type: "workspace:user:created", + data: { + workspaceId, + identityId, + }, + meta, + }); + } +} + +/* + |-------------------------------------------------------------------------------- + | Projectors + |-------------------------------------------------------------------------------- + */ + +projector.on("workspace:user:created", async ({ stream: id, data: { workspaceId, identityId }, meta, created }) => { + await db.collection("workspace:users").insertOne({ + id, + workspaceId, + identityId, + name: { + given: "", + family: "", + }, + contacts: [], + createdAt: getDate(created), + createdBy: meta.user.uid ?? "Unknown", + }); +}); diff --git a/modules/workspace/aggregates/workspace.ts b/modules/workspace/aggregates/workspace.ts new file mode 100644 index 0000000..dc58eb8 --- /dev/null +++ b/modules/workspace/aggregates/workspace.ts @@ -0,0 +1,120 @@ +import { AuditActor, auditors } from "@platform/spec/audit/actor.ts"; +import { AggregateRoot, getDate } from "@valkyr/event-store"; + +import { db } from "../database.ts"; +import { EventRecord, EventStoreFactory, projector } from "../event-store.ts"; + +export class Workspace extends AggregateRoot { + static override readonly name = "workspace"; + + ownerId!: string; + + name!: string; + description?: string; + archived = false; + + createdAt!: Date; + updatedAt?: Date; + + // ------------------------------------------------------------------------- + // Reducer + // ------------------------------------------------------------------------- + + with(event: EventRecord): void { + switch (event.type) { + case "workspace:created": { + this.id = event.stream; + this.ownerId = event.data.ownerId; + this.name = event.data.name; + this.createdAt = getDate(event.created); + break; + } + case "workspace:name:added": { + this.name = event.data; + this.updatedAt = getDate(event.created); + break; + } + case "workspace:description:added": { + this.description = event.data; + this.updatedAt = getDate(event.created); + break; + } + case "workspace:archived": { + this.archived = true; + this.updatedAt = getDate(event.created); + break; + } + case "workspace:restored": { + this.archived = false; + this.updatedAt = getDate(event.created); + break; + } + } + } + + // ------------------------------------------------------------------------- + // Actions + // ------------------------------------------------------------------------- + + create(ownerId: string, name: string, meta: AuditActor = auditors.system) { + return this.push({ + stream: this.id, + type: "workspace:created", + data: { + ownerId, + name, + }, + meta, + }); + } + + setName(name: string, meta: AuditActor = auditors.system) { + return this.push({ + stream: this.id, + type: "workspace:name:added", + data: name, + meta, + }); + } + + setDescription(description: string, meta: AuditActor = auditors.system) { + return this.push({ + stream: this.id, + type: "workspace:description:added", + data: description, + meta, + }); + } + + archive(meta: AuditActor = auditors.system) { + return this.push({ + stream: this.id, + type: "workspace:archived", + meta, + }); + } + + restore(meta: AuditActor = auditors.system) { + return this.push({ + stream: this.id, + type: "workspace:restored", + meta, + }); + } +} + +/* + |-------------------------------------------------------------------------------- + | Projectors + |-------------------------------------------------------------------------------- + */ + +projector.on("workspace:created", async ({ stream: id, data: { ownerId, name }, meta, created }) => { + await db.collection("workspaces").insertOne({ + id, + ownerId, + name, + createdAt: getDate(created), + createdBy: meta.user.uid ?? "Unknown", + }); +}); diff --git a/modules/workspace/cerbos/policies/workspace.yaml b/modules/workspace/cerbos/policies/workspace.yaml new file mode 100644 index 0000000..1b6ed00 --- /dev/null +++ b/modules/workspace/cerbos/policies/workspace.yaml @@ -0,0 +1,33 @@ +# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json +# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies + +apiVersion: api.cerbos.dev/v1 +resourcePolicy: + resource: workspace + version: default + rules: + + - actions: ["create"] + effect: EFFECT_ALLOW + roles: ["super"] + + - actions: ["read"] + effect: EFFECT_ALLOW + roles: ["super", "admin", "user"] + condition: + match: + expr: R.attr.id in P.attr.workspaceIds + + - actions: ["update"] + effect: EFFECT_ALLOW + roles: ["super", "admin"] + condition: + match: + expr: R.attr.id in P.attr.workspaceIds + + - actions: ["delete"] + effect: EFFECT_ALLOW + roles: ["super"] + condition: + match: + expr: R.attr.id in P.attr.workspaceIds diff --git a/platform/cerbos/policies/workspace.yaml b/modules/workspace/cerbos/policies/workspace_user.yaml similarity index 56% rename from platform/cerbos/policies/workspace.yaml rename to modules/workspace/cerbos/policies/workspace_user.yaml index 85f1c6e..47e314e 100644 --- a/platform/cerbos/policies/workspace.yaml +++ b/modules/workspace/cerbos/policies/workspace_user.yaml @@ -3,13 +3,47 @@ apiVersion: api.cerbos.dev/v1 resourcePolicy: - resource: workspace + resource: workspace_user version: default rules: - ### Read + # Admins can invite new members into their own workspace - actions: + - invite + effect: EFFECT_ALLOW + roles: + - admin + condition: + match: + expr: request.principal.workspaceIds.includes(request.resource.workspaceId) + + # Admins can remove members from their own workspace + + - actions: + - remove + effect: EFFECT_ALLOW + roles: + - admin + condition: + match: + expr: request.principal.workspaceIds.includes(request.resource.workspaceId) + + # Admins can update member roles in their own workspace + + - actions: + - update_role + effect: EFFECT_ALLOW + roles: + - admin + condition: + match: + expr: request.principal.workspaceIds.includes(request.resource.workspaceId) + + # Admins and users can list/read members of their own workspace + + - actions: + - list - read effect: EFFECT_ALLOW roles: @@ -17,26 +51,4 @@ resourcePolicy: - user condition: match: - expr: request.principal.workspaceIds.includes(request.resource.id) - - ### Update - - - actions: - - update - effect: EFFECT_ALLOW - roles: - - admin - condition: - match: - expr: request.principal.workspaceIds.includes(request.resource.id) - - ### Delete - - - actions: - - delete - effect: EFFECT_ALLOW - roles: - - admin - condition: - match: - expr: request.principal.workspaceIds.includes(request.resource.id) + expr: request.principal.workspaceIds.includes(request.resource.workspaceId) diff --git a/modules/workspace/cerbos/resources.ts b/modules/workspace/cerbos/resources.ts new file mode 100644 index 0000000..6ceb564 --- /dev/null +++ b/modules/workspace/cerbos/resources.ts @@ -0,0 +1,22 @@ +import z from "zod"; + +/* +export const resources = new ResourceRegistry([ + { + kind: "workspace", + actions: [], + attr: { + workspaceId: z.string(), + }, + }, + { + kind: "workspace_user", + actions: [], + attr: { + workspaceId: z.string(), + }, + }, +] as const); + +export type Resource = typeof resources.$resource; +*/ diff --git a/modules/workspace/client.ts b/modules/workspace/client.ts new file mode 100644 index 0000000..e69de29 diff --git a/modules/workspace/database.ts b/modules/workspace/database.ts new file mode 100644 index 0000000..ed3c2c7 --- /dev/null +++ b/modules/workspace/database.ts @@ -0,0 +1,27 @@ +import { getDatabaseAccessor } from "@platform/database/accessor.ts"; + +import { parseWorkspace, type Workspace } from "./models/workspace.ts"; +import { WorkspaceUser } from "./models/workspace-user.ts"; + +export const db = getDatabaseAccessor<{ + workspaces: Workspace; + "workspace:users": WorkspaceUser; +}>(`workspace:read-store`); + +/* + |-------------------------------------------------------------------------------- + | Identity + |-------------------------------------------------------------------------------- + */ + +/** + * Retrieve a single workspace by its primary identifier. + * + * @param id - Unique identity. + */ +export async function getWorkspaceById(id: string): Promise { + return db + .collection("workspaces") + .findOne({ id }) + .then((document) => parseWorkspace(document)); +} diff --git a/modules/identity/event-store.ts b/modules/workspace/event-store.ts similarity index 88% rename from modules/identity/event-store.ts rename to modules/workspace/event-store.ts index da376c1..bb433ef 100644 --- a/modules/identity/event-store.ts +++ b/modules/workspace/event-store.ts @@ -9,8 +9,8 @@ import { MongoAdapter } from "@valkyr/event-store/mongo"; */ const eventFactory = new EventFactory([ - ...(await import("./events/code.ts")).default, - ...(await import("./events/identity.ts")).default, + ...(await import("./events/workspace.ts")).default, + ...(await import("./events/workspace-user.ts")).default, ]); /* @@ -20,7 +20,7 @@ const eventFactory = new EventFactory([ */ export const eventStore = new EventStore({ - adapter: new MongoAdapter(() => container.get("mongo"), `identity:event-store`), + adapter: new MongoAdapter(() => container.get("mongo"), `workspace:event-store`), events: eventFactory, snapshot: "auto", }); diff --git a/modules/workspace/events/workspace-user.ts b/modules/workspace/events/workspace-user.ts new file mode 100644 index 0000000..225c7cf --- /dev/null +++ b/modules/workspace/events/workspace-user.ts @@ -0,0 +1,23 @@ +import { AuditActorSchema } from "@platform/spec/audit/actor.ts"; +import { event } from "@valkyr/event-store"; +import z from "zod"; + +import { AvatarSchema } from "../value-objects/avatar.ts"; +import { ContactSchema } from "../value-objects/contact.ts"; +import { NameSchema } from "../value-objects/name.ts"; + +export default [ + event + .type("workspace:user:created") + .data( + z.strictObject({ + workspaceId: z.string(), + identityId: z.string(), + }), + ) + .meta(AuditActorSchema), + event.type("workspace:user:name-set").data(NameSchema).meta(AuditActorSchema), + event.type("workspace:user:avatar-set").data(AvatarSchema).meta(AuditActorSchema), + event.type("workspace:user:contacts-added").data(z.array(ContactSchema)).meta(AuditActorSchema), + event.type("workspace:user:contacts-removed").data(z.array(z.string())).meta(AuditActorSchema), +]; diff --git a/modules/workspace/events/workspace.ts b/modules/workspace/events/workspace.ts new file mode 100644 index 0000000..7c5d984 --- /dev/null +++ b/modules/workspace/events/workspace.ts @@ -0,0 +1,19 @@ +import { AuditActorSchema } from "@platform/spec/audit/actor.ts"; +import { event } from "@valkyr/event-store"; +import z from "zod"; + +export default [ + event + .type("workspace:created") + .data( + z.strictObject({ + ownerId: z.uuid(), + name: z.string(), + }), + ) + .meta(AuditActorSchema), + event.type("workspace:name:added").data(z.string()).meta(AuditActorSchema), + event.type("workspace:description:added").data(z.string()).meta(AuditActorSchema), + event.type("workspace:archived").meta(AuditActorSchema), + event.type("workspace:restored").meta(AuditActorSchema), +]; diff --git a/modules/workspace/models/workspace-user.ts b/modules/workspace/models/workspace-user.ts new file mode 100644 index 0000000..4d368b2 --- /dev/null +++ b/modules/workspace/models/workspace-user.ts @@ -0,0 +1,38 @@ +import { makeDocumentParser } from "@platform/database/utilities.ts"; +import { z } from "zod"; + +import { AvatarSchema } from "../value-objects/avatar.ts"; +import { ContactSchema } from "../value-objects/contact.ts"; +import { NameSchema } from "../value-objects/name.ts"; + +export const WorkspaceUserSchema = z.object({ + id: z.uuid(), + + workspaceId: z.uuid(), + identityId: z.string(), + + name: NameSchema.optional(), + avatar: AvatarSchema.optional(), + contacts: z.array(ContactSchema).default([]), + + createdAt: z.coerce.date(), + createdBy: z.string(), + updatedAt: z.coerce.date().optional(), + updatedBy: z.string().optional(), +}); + +/* + |-------------------------------------------------------------------------------- + | Parsers + |-------------------------------------------------------------------------------- + */ + +export const parseWorkspaceUser = makeDocumentParser(WorkspaceUserSchema); + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type WorkspaceUser = z.infer; diff --git a/modules/workspace/models/workspace.ts b/modules/workspace/models/workspace.ts new file mode 100644 index 0000000..7240c52 --- /dev/null +++ b/modules/workspace/models/workspace.ts @@ -0,0 +1,32 @@ +import { makeDocumentParser } from "@platform/database/utilities.ts"; +import { z } from "zod"; + +export const WorkspaceSchema = z.object({ + id: z.uuid(), + + ownerId: z.uuid(), + + name: z.string(), + description: z.string().optional(), + + createdAt: z.coerce.date(), + createdBy: z.string(), + updatedAt: z.coerce.date().optional(), + updatedBy: z.string().optional(), +}); + +/* + |-------------------------------------------------------------------------------- + | Parsers + |-------------------------------------------------------------------------------- + */ + +export const parseWorkspace = makeDocumentParser(WorkspaceSchema); + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type Workspace = z.infer; diff --git a/modules/workspace/package.json b/modules/workspace/package.json new file mode 100644 index 0000000..429711d --- /dev/null +++ b/modules/workspace/package.json @@ -0,0 +1,19 @@ +{ + "name": "@modules/workspace", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./client.ts": "./client.ts", + "./server.ts": "./server.ts" + }, + "types": "types.d.ts", + "dependencies": { + "@platform/database": "workspace:*", + "@platform/relay": "workspace:*", + "@platform/spec": "workspace:*", + "@valkyr/event-store": "npm:@jsr/valkyr__event-store@2.0.1", + "cookie": "1.0.2", + "zod": "4.1.11" + } +} \ No newline at end of file diff --git a/modules/workspace/routes/workspaces/create/handle.ts b/modules/workspace/routes/workspaces/create/handle.ts new file mode 100644 index 0000000..d01ec59 --- /dev/null +++ b/modules/workspace/routes/workspaces/create/handle.ts @@ -0,0 +1,20 @@ +import { ForbiddenError } from "@platform/relay"; + +import { Workspace } from "../../../aggregates/workspace.ts"; +import { eventStore } from "../../../event-store.ts"; +import route from "./spec.ts"; + +export default route.access("session").handle(async ({ body: { name } }, { access, principal }) => { + const decision = await access.isAllowed({ kind: "workspace", id: "1", attr: {} }, "create"); + if (decision === false) { + return new ForbiddenError("You do not have permission to create workspaces."); + } + const workspace = await eventStore.aggregate.from(Workspace).create(principal.id, name).save(); + return { + id: workspace.id, + ownerId: workspace.ownerId, + name: workspace.name, + createdAt: workspace.createdAt, + createdBy: principal.id, + }; +}); diff --git a/modules/workspace/routes/workspaces/create/spec.ts b/modules/workspace/routes/workspaces/create/spec.ts new file mode 100644 index 0000000..2b4fd57 --- /dev/null +++ b/modules/workspace/routes/workspaces/create/spec.ts @@ -0,0 +1,14 @@ +import { ForbiddenError, InternalServerError, route, UnauthorizedError, ValidationError } from "@platform/relay"; +import z from "zod"; + +import { WorkspaceSchema } from "../../../models/workspace.ts"; + +export default route + .post("/api/v1/workspace") + .body( + z.strictObject({ + name: z.string(), + }), + ) + .errors([UnauthorizedError, ForbiddenError, ValidationError, InternalServerError]) + .response(WorkspaceSchema); diff --git a/modules/workspace/server.ts b/modules/workspace/server.ts new file mode 100644 index 0000000..9e986f8 --- /dev/null +++ b/modules/workspace/server.ts @@ -0,0 +1,30 @@ +import { idIndex } from "@platform/database/id.ts"; +import { register as registerReadStore } from "@platform/database/registrar.ts"; +import { register as registerEventStore } from "@valkyr/event-store/mongo"; + +import { db } from "./database.ts"; +import { eventStore } from "./event-store.ts"; + +export default { + routes: [(await import("./routes/workspaces/create/handle.ts")).default], + + bootstrap: async (): Promise => { + await registerReadStore(db.db, [ + { + name: "workspaces", + indexes: [ + idIndex, + // [{ "strategies.type": 1, "strategies.alias": 1 }, { name: "strategy.password" }], + ], + }, + { + name: "workspace:users", + indexes: [ + idIndex, + // [{ "strategies.type": 1, "strategies.alias": 1 }, { name: "strategy.password" }], + ], + }, + ]); + await registerEventStore(eventStore.db.db, console.info); + }, +}; diff --git a/modules/identity/schemas/avatar.ts b/modules/workspace/value-objects/avatar.ts similarity index 100% rename from modules/identity/schemas/avatar.ts rename to modules/workspace/value-objects/avatar.ts diff --git a/modules/workspace/value-objects/contact.ts b/modules/workspace/value-objects/contact.ts new file mode 100644 index 0000000..384d09f --- /dev/null +++ b/modules/workspace/value-objects/contact.ts @@ -0,0 +1,13 @@ +import z from "zod"; + +import { EmailSchema } from "./email.ts"; + +export const ContactSchema = z.union([ + z.object({ + id: z.string(), + type: z.literal("email"), + email: EmailSchema, + }), +]); + +export type Contact = z.infer; diff --git a/modules/identity/schemas/email.ts b/modules/workspace/value-objects/email.ts similarity index 100% rename from modules/identity/schemas/email.ts rename to modules/workspace/value-objects/email.ts diff --git a/modules/identity/schemas/name.ts b/modules/workspace/value-objects/name.ts similarity index 100% rename from modules/identity/schemas/name.ts rename to modules/workspace/value-objects/name.ts diff --git a/platform/cerbos/policies/identity.yaml b/platform/cerbos/policies/identity.yaml deleted file mode 100644 index 86f22db..0000000 --- a/platform/cerbos/policies/identity.yaml +++ /dev/null @@ -1,47 +0,0 @@ -# yaml-language-server: $schema=https://api.cerbos.dev/latest/cerbos/policy/v1/Policy.schema.json -# docs: https://docs.cerbos.dev/cerbos/latest/policies/resource_policies - -apiVersion: api.cerbos.dev/v1 -resourcePolicy: - resource: identity - version: default - rules: - - ### Read - - - actions: - - read - effect: EFFECT_ALLOW - roles: - - admin - - - actions: - - read - effect: EFFECT_ALLOW - roles: - - user - condition: - match: - expr: request.resource.id == request.principal.id - - ### Update - - - actions: - - update - effect: EFFECT_ALLOW - roles: - - user - condition: - match: - expr: request.resource.id == request.principal.id - - ### Delete - - - actions: - - delete - effect: EFFECT_ALLOW - roles: - - user - condition: - match: - expr: request.resource.id == request.principal.id diff --git a/platform/relay/libraries/context.ts b/platform/relay/libraries/context.ts index 616527a..1129f36 100644 --- a/platform/relay/libraries/context.ts +++ b/platform/relay/libraries/context.ts @@ -1,3 +1,5 @@ +import "@platform/supertoken/types.ts"; + // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface ServerContext {} diff --git a/platform/relay/libraries/errors.ts b/platform/relay/libraries/errors.ts index dc16946..5f39630 100644 --- a/platform/relay/libraries/errors.ts +++ b/platform/relay/libraries/errors.ts @@ -286,7 +286,7 @@ export class UnprocessableContentError extends ServerError { +export class ValidationError extends ServerError { readonly code = "VALIDATION"; /** @@ -298,7 +298,7 @@ export class ValidationError extends ServerError { * @param message - Optional message to send with the error. Default: "Validation Failed". * @param data - Data with validation failure details. */ - constructor(message = "Validation Failed", data: ValidationErrorData) { + constructor(message = "Validation Failed", data: TData) { super(message, 422, data); } @@ -322,7 +322,7 @@ export class ValidationError extends ServerError { message: issue.message, }; }), - }); + } satisfies ValidationErrorData); } } diff --git a/platform/relay/package.json b/platform/relay/package.json index 4607b79..b8cb70b 100644 --- a/platform/relay/package.json +++ b/platform/relay/package.json @@ -10,8 +10,8 @@ "dependencies": { "@platform/auth": "workspace:*", "@platform/socket": "workspace:*", + "@platform/supertokens": "workspace:*", "@platform/vault": "workspace:*", - "@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.4", "path-to-regexp": "8", "zod": "4.1.11" } diff --git a/modules/identity/auth/access.ts b/platform/supertoken/access.ts similarity index 83% rename from modules/identity/auth/access.ts rename to platform/supertoken/access.ts index cd4f4ed..7323142 100644 --- a/modules/identity/auth/access.ts +++ b/platform/supertoken/access.ts @@ -1,9 +1,7 @@ -import { cerbos } from "@platform/cerbos/client.ts"; -import { Resource } from "@platform/cerbos/resources.ts"; - +import { cerbos } from "./cerbos.ts"; import type { Principal } from "./principal.ts"; -export function access(principal: Principal) { +export function getAccessControlMethods(principal: Principal) { return { /** * Check if a principal is allowed to perform an action on a resource. @@ -22,7 +20,7 @@ export function access(principal: Principal) { * "view" * ); // => true */ - isAllowed(resource: Resource, action: string) { + isAllowed(resource: any, action: string) { return cerbos.isAllowed({ principal, resource, action }); }, @@ -45,7 +43,7 @@ export function access(principal: Principal) { * * decision.isAllowed("view"); // => true */ - checkResource(resource: Resource, actions: string[]) { + checkResource(resource: any, actions: string[]) { return cerbos.checkResource({ principal, resource, actions }); }, @@ -80,10 +78,10 @@ export function access(principal: Principal) { * action: "view", * }); // => true */ - checkResources(resources: { resource: Resource; actions: string[] }[]) { + checkResources(resources: { resource: any; actions: string[] }[]) { return cerbos.checkResources({ principal, resources }); }, }; } -export type Access = ReturnType; +export type AccessControlMethods = ReturnType; diff --git a/platform/cerbos/client.ts b/platform/supertoken/cerbos.ts similarity index 100% rename from platform/cerbos/client.ts rename to platform/supertoken/cerbos.ts diff --git a/platform/supertoken/config.ts b/platform/supertoken/config.ts new file mode 100644 index 0000000..7d9a0d1 --- /dev/null +++ b/platform/supertoken/config.ts @@ -0,0 +1,44 @@ +import { getEnvironmentVariable } from "@platform/config/environment.ts"; +import type { SerializeOptions } from "cookie"; +import z from "zod"; + +export const config = { + supertokens: { + connectionURI: getEnvironmentVariable({ + key: "SUPERTOKEN_URI", + type: z.string(), + fallback: "http://localhost:3567", + }), + }, + appInfo: { + appName: getEnvironmentVariable({ + key: "PROJECT_NAME", + type: z.string(), + fallback: "Boilerplate", + }), + apiDomain: getEnvironmentVariable({ + key: "API_DOMAIN", + type: z.string(), + fallback: "http://localhost:8370", + }), + websiteDomain: getEnvironmentVariable({ + key: "APP_DOMAIN", + type: z.string(), + fallback: "http://localhost:3000", + }), + apiBasePath: "/api/v1/identity", + websiteBasePath: "/auth", + }, + cookie: (maxAge: number) => + ({ + httpOnly: true, + secure: getEnvironmentVariable({ + key: "AUTH_COOKIE_SECURE", + type: z.coerce.boolean(), + fallback: "false", + }), // Set to true for HTTPS in production + maxAge, + path: "/", + sameSite: "strict", + }) satisfies SerializeOptions, +}; diff --git a/platform/cerbos/package.json b/platform/supertoken/package.json similarity index 66% rename from platform/cerbos/package.json rename to platform/supertoken/package.json index fc0d63d..e06c480 100644 --- a/platform/cerbos/package.json +++ b/platform/supertoken/package.json @@ -1,12 +1,13 @@ { - "name": "@platform/cerbos", + "name": "@platform/supertoken", "version": "0.0.0", "private": true, "type": "module", "dependencies": { "@cerbos/http": "0.23.1", "@platform/config": "workspace:*", - "@valkyr/auth": "npm:@jsr/valkyr__auth@2.1.4", + "cookie": "1.0.2", + "supertokens-node": "23.0.1", "zod": "4.1.11" } } \ No newline at end of file diff --git a/platform/supertoken/principal.ts b/platform/supertoken/principal.ts new file mode 100644 index 0000000..14be140 --- /dev/null +++ b/platform/supertoken/principal.ts @@ -0,0 +1,46 @@ +import UserMetadata from "supertokens-node/recipe/usermetadata"; +import z from "zod"; + +/* + |-------------------------------------------------------------------------------- + | Schema + |-------------------------------------------------------------------------------- + */ + +export const PrincipalSchema = z.object({ + id: z.string(), + roles: z.array(z.string()), + attr: z.record(z.string(), z.any()), +}); + +/* + |-------------------------------------------------------------------------------- + | Utilities + |-------------------------------------------------------------------------------- + */ + +/** + * Get principal roles from the provided userId. + * + * @param userId - User to get principal roles from. + */ +export async function getPrincipalRoles(userId: string): Promise { + return (await UserMetadata.getUserMetadata(userId)).metadata?.roles ?? []; +} + +/** + * Get principal attributes from the provided userId. + * + * @param userId - User to get principal attributes from. + */ +export async function getPrincipalAttributes(userId: string): Promise> { + return (await UserMetadata.getUserMetadata(userId)).metadata?.attr ?? {}; +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +export type Principal = z.infer; diff --git a/platform/supertoken/server.ts b/platform/supertoken/server.ts new file mode 100644 index 0000000..38d04c8 --- /dev/null +++ b/platform/supertoken/server.ts @@ -0,0 +1,133 @@ +import "./types.ts"; + +import { UnauthorizedError } from "@platform/relay"; +import { context } from "@platform/relay"; +import { storage } from "@platform/storage"; +import cookie from "cookie"; +import supertokens from "supertokens-node"; +import Passwordless from "supertokens-node/recipe/passwordless"; +import Session from "supertokens-node/recipe/session"; + +import { getAccessControlMethods } from "./access.ts"; +import { config } from "./config.ts"; +import { getPrincipalAttributes, getPrincipalRoles, Principal } from "./principal.ts"; +import { getSessionByAccessToken } from "./session.ts"; + +/* + |-------------------------------------------------------------------------------- + | Server Module + |-------------------------------------------------------------------------------- + */ + +export default { + bootsrap: async () => { + bootstrapSuperTokens(); + bootstrapStorageContext(); + }, + resolve: async (request: Request): Promise => { + await resolveSession(request); + }, +}; + +/* + |-------------------------------------------------------------------------------- + | Bootstrap Methods + |-------------------------------------------------------------------------------- + */ + +function bootstrapSuperTokens() { + supertokens.init({ + framework: "custom", + supertokens: config.supertokens, + appInfo: config.appInfo, + recipeList: [ + Passwordless.init({ + flowType: "USER_INPUT_CODE", + contactMethod: "EMAIL", + }), + Session.init({ + getTokenTransferMethod: () => "cookie", + }), + ], + }); +} + +function bootstrapStorageContext() { + Object.defineProperties(context, { + /** + * TODO ... + */ + isAuthenticated: { + get() { + return storage.getStore()?.principal !== undefined; + }, + }, + + /** + * TODO ... + */ + session: { + get() { + const session = storage.getStore()?.session; + if (session === undefined) { + throw new UnauthorizedError(); + } + return session; + }, + }, + + /** + * TODO ... + */ + principal: { + get() { + const principal = storage.getStore()?.principal; + if (principal === undefined) { + throw new UnauthorizedError(); + } + return principal; + }, + }, + + /** + * TODO ... + */ + access: { + get() { + const access = storage.getStore()?.access; + if (access === undefined) { + throw new UnauthorizedError(); + } + return access; + }, + }, + }); +} + +/* + |-------------------------------------------------------------------------------- + | Request Middleware + |-------------------------------------------------------------------------------- + */ + +async function resolveSession(request: Request): Promise { + const accessToken = cookie.parse(request.headers.get("cookie") ?? "").sAccessToken; + if (accessToken !== undefined) { + const session = await getSessionByAccessToken(accessToken); + + const store = storage.getStore(); + if (store === undefined) { + return; + } + + const principal: Principal = { + id: session.getUserId(), + roles: await getPrincipalRoles(session.getUserId()), + attr: await getPrincipalAttributes(session.getUserId()), + }; + + store.session = session; + store.principal = principal; + store.access = getAccessControlMethods(principal); + } +} diff --git a/platform/supertoken/session.ts b/platform/supertoken/session.ts new file mode 100644 index 0000000..306c488 --- /dev/null +++ b/platform/supertoken/session.ts @@ -0,0 +1,37 @@ +import cookie from "cookie"; +import type { RecipeUserId } from "supertokens-node/index.js"; +import Session from "supertokens-node/recipe/session"; + +import { config } from "./config.ts"; + +/** + * Get session headers which can be applied on a Response object to apply + * an authenticted session to the respondant. + * + * @param tenantId - Tenant scope the session belongs to. + * @param recipeUserId - User recipe to apply to the session. + */ +export async function getSessionHeaders(tenantId: string, recipeUserId: RecipeUserId): Promise { + const session = await Session.createNewSessionWithoutRequestResponse(tenantId, recipeUserId); + const tokens = session.getAllSessionTokensDangerously(); + const options = config.cookie(await session.getExpiry()); + + const headers = new Headers({ "set-cookie": cookie.serialize("sAccessToken", tokens.accessToken, options) }); + if (tokens.refreshToken !== undefined) { + headers.append("set-cookie", cookie.serialize("sRefreshToken", tokens.refreshToken, options)); + } + return headers; +} + +/** + * Get session container from access token. + * + * @param accessToken - Access token to resolve session from. + * @param antiCsrfToken - Optional CSRF token. + */ +export async function getSessionByAccessToken( + accessToken: string, + antiCsrfToken?: string, +): Promise { + return Session.getSessionWithoutRequestResponse(accessToken, antiCsrfToken); +} diff --git a/modules/identity/types.d.ts b/platform/supertoken/types.ts similarity index 53% rename from modules/identity/types.d.ts rename to platform/supertoken/types.ts index da3cc86..51ee3f5 100644 --- a/modules/identity/types.d.ts +++ b/platform/supertoken/types.ts @@ -1,11 +1,18 @@ import "@platform/relay"; import "@platform/storage"; -import type { Access } from "./auth/access.ts"; -import type { Principal } from "./auth/principal.ts"; +import type Session from "supertokens-node/recipe/session"; + +import type { AccessControlMethods } from "./access.ts"; +import type { Principal } from "./principal.ts"; declare module "@platform/storage" { interface StorageContext { + /** + * TODO ... + */ + session?: Session.SessionContainer; + /** * TODO ... */ @@ -14,7 +21,7 @@ declare module "@platform/storage" { /** * TODO ... */ - access?: Access; + access?: AccessControlMethods; } } @@ -25,6 +32,11 @@ declare module "@platform/relay" { */ isAuthenticated: boolean; + /** + * TODO ... + */ + session: Session.SessionContainer; + /** * TODO ... */ @@ -33,6 +45,6 @@ declare module "@platform/relay" { /** * TODO ... */ - access: Access; + access: AccessControlMethods; } } diff --git a/platform/supertoken/users.ts b/platform/supertoken/users.ts new file mode 100644 index 0000000..2f8251e --- /dev/null +++ b/platform/supertoken/users.ts @@ -0,0 +1,10 @@ +import supertokens, { type User } from "supertokens-node"; + +/** + * Get a user by provided user id. + * + * @param userId - User id to retrieve. + */ +export async function getUserById(userId: string): Promise { + return supertokens.getUser(userId); +}